2914 lines
101 KiB
HTML
2914 lines
101 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>ai-image-rs</title>
|
||
<style>
|
||
* {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, "Segoe UI", sans-serif;
|
||
background: #f0f2f6;
|
||
min-height: 100vh;
|
||
padding: 20px 24px 40px;
|
||
}
|
||
|
||
/* ── 顶部 ── */
|
||
header {
|
||
max-width: 1400px;
|
||
margin: 0 auto 20px;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 16px;
|
||
}
|
||
|
||
header h1 {
|
||
font-size: 22px;
|
||
color: #1a1a2e;
|
||
font-weight: 700;
|
||
}
|
||
|
||
header p {
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* ── Tab 切换 ── */
|
||
.tabs {
|
||
max-width: 1400px;
|
||
margin: 0 auto 16px;
|
||
display: flex;
|
||
gap: 4px;
|
||
border-bottom: 2px solid #e2e8f0;
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
.tab-btn {
|
||
padding: 9px 22px;
|
||
border: none;
|
||
background: none;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #64748b;
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -2px;
|
||
border-radius: 6px 6px 0 0;
|
||
transition: color .15s;
|
||
}
|
||
|
||
.tab-btn:hover {
|
||
color: #2563eb;
|
||
}
|
||
|
||
.tab-btn.active {
|
||
color: #2563eb;
|
||
border-bottom-color: #2563eb;
|
||
background: #fff;
|
||
}
|
||
|
||
.tab-panel {
|
||
display: none;
|
||
}
|
||
|
||
.tab-panel.active {
|
||
display: block;
|
||
}
|
||
|
||
/* ── 两栏布局 ── */
|
||
.layout {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 20px;
|
||
align-items: start;
|
||
}
|
||
|
||
/* ── 卡片 ── */
|
||
.card {
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
padding: 24px;
|
||
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.07);
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: #94a3b8;
|
||
text-transform: uppercase;
|
||
letter-spacing: .06em;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.sticky-card {
|
||
position: sticky;
|
||
top: 20px;
|
||
}
|
||
|
||
/* ── 上传区 ── */
|
||
.upload-zone {
|
||
border: 2px dashed #cbd5e1;
|
||
border-radius: 10px;
|
||
min-height: 80px;
|
||
padding: 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
color: #94a3b8;
|
||
font-size: 13px;
|
||
transition: border-color .2s, background .2s;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.upload-zone:hover,
|
||
.upload-zone.drag {
|
||
border-color: #2563eb;
|
||
background: #eff6ff;
|
||
color: #2563eb;
|
||
}
|
||
|
||
.upload-zone.filled {
|
||
border-style: solid;
|
||
border-color: #e2e8f0;
|
||
padding: 6px;
|
||
display: block;
|
||
}
|
||
|
||
.upload-zone.filled img {
|
||
display: block;
|
||
width: 100%;
|
||
border-radius: 6px;
|
||
max-height: 120px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
input[type=file] {
|
||
display: none;
|
||
}
|
||
|
||
/* ── 画布区 ── */
|
||
.canvas-wrap {
|
||
position: relative;
|
||
border: 2px dashed #cbd5e1;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
background: #f8fafc;
|
||
min-height: 300px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: none;
|
||
}
|
||
|
||
.canvas-wrap .empty-hint {
|
||
color: #cbd5e1;
|
||
font-size: 14px;
|
||
text-align: center;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.canvas-wrap canvas {
|
||
display: block;
|
||
max-width: 100%;
|
||
touch-action: none;
|
||
}
|
||
|
||
.canvas-wrap.has-image {
|
||
border-style: solid;
|
||
border-color: #e2e8f0;
|
||
}
|
||
|
||
/* ── 画笔工具条 ── */
|
||
.brush-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.color-dot {
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
border: 3px solid transparent;
|
||
transition: transform .1s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.color-dot:hover {
|
||
transform: scale(1.15);
|
||
}
|
||
|
||
.color-dot.active {
|
||
border-color: #1e293b;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.brush-sep {
|
||
width: 1px;
|
||
height: 24px;
|
||
background: #e2e8f0;
|
||
}
|
||
|
||
.brush-size-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
}
|
||
|
||
.brush-size-wrap input[type=range] {
|
||
width: 80px;
|
||
accent-color: #2563eb;
|
||
}
|
||
|
||
.brush-action-btn {
|
||
padding: 4px 12px;
|
||
font-size: 12px;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 6px;
|
||
background: #f8fafc;
|
||
cursor: pointer;
|
||
color: #475569;
|
||
transition: background .15s;
|
||
}
|
||
|
||
.brush-action-btn:hover {
|
||
background: #e2e8f0;
|
||
}
|
||
|
||
/* ── 表单元素 ── */
|
||
.field {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.field label {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
textarea {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
resize: vertical;
|
||
min-height: 80px;
|
||
outline: none;
|
||
color: #1e293b;
|
||
line-height: 1.6;
|
||
transition: border-color .2s;
|
||
}
|
||
|
||
textarea:focus {
|
||
border-color: #2563eb;
|
||
box-shadow: 0 0 0 3px rgba(37, 99, 235, .1);
|
||
}
|
||
|
||
details {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
details summary {
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
font-weight: 600;
|
||
user-select: none;
|
||
list-style: none;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
details summary::before {
|
||
content: '▶';
|
||
font-size: 10px;
|
||
transition: transform .15s;
|
||
}
|
||
|
||
details[open] summary::before {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.adv-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.adv-item label {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: #64748b;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.adv-item input[type=range] {
|
||
width: 100%;
|
||
accent-color: #2563eb;
|
||
}
|
||
|
||
.adv-item input[type=number] {
|
||
width: 100%;
|
||
padding: 5px 8px;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
outline: none;
|
||
}
|
||
|
||
.adv-item input[type=checkbox] {
|
||
accent-color: #2563eb;
|
||
}
|
||
|
||
/* ── 主按钮 ── */
|
||
.run-btn {
|
||
width: 100%;
|
||
margin-top: 18px;
|
||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||
color: #fff;
|
||
border: none;
|
||
padding: 13px;
|
||
border-radius: 10px;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
letter-spacing: .02em;
|
||
transition: opacity .2s, transform .1s;
|
||
}
|
||
|
||
.run-btn:hover:not(:disabled) {
|
||
opacity: .9;
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.run-btn:active:not(:disabled) {
|
||
transform: none;
|
||
}
|
||
|
||
.run-btn:disabled {
|
||
opacity: .5;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
|
||
/* ── 状态 ── */
|
||
.status-box {
|
||
margin-top: 12px;
|
||
padding: 10px 14px;
|
||
border-radius: 8px;
|
||
display: none;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.s-running {
|
||
background: #fef9c3;
|
||
color: #854d0e;
|
||
}
|
||
|
||
.s-success {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.s-error {
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
|
||
0%,
|
||
100% {
|
||
opacity: 1
|
||
}
|
||
|
||
50% {
|
||
opacity: .4
|
||
}
|
||
}
|
||
|
||
.pulsing {
|
||
animation: pulse 1.4s ease-in-out infinite;
|
||
}
|
||
|
||
/* ── 结果区 ── */
|
||
.result-placeholder {
|
||
min-height: 380px;
|
||
border: 2px dashed #e2e8f0;
|
||
border-radius: 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
color: #cbd5e1;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.result-placeholder span:first-child {
|
||
font-size: 36px;
|
||
}
|
||
|
||
.result-img-wrap {
|
||
display: none;
|
||
}
|
||
|
||
.result-img-wrap img {
|
||
width: 100%;
|
||
border-radius: 10px;
|
||
display: block;
|
||
}
|
||
|
||
.dl-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
margin-top: 12px;
|
||
padding: 10px;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
background: #f8fafc;
|
||
color: #2563eb;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
transition: background .15s;
|
||
}
|
||
|
||
.dl-btn:hover {
|
||
background: #eff6ff;
|
||
}
|
||
|
||
.action-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.action-row .dl-btn,
|
||
.action-row .apply-btn,
|
||
.action-row .run-btn {
|
||
margin-top: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.apply-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
padding: 10px;
|
||
border: 1px solid #bfdbfe;
|
||
border-radius: 8px;
|
||
background: #eff6ff;
|
||
color: #1d4ed8;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: background .15s;
|
||
}
|
||
|
||
.apply-btn:hover:not(:disabled) {
|
||
background: #dbeafe;
|
||
}
|
||
|
||
.apply-btn:disabled {
|
||
opacity: .5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* ── Gradio 风格右上角 × 清除按钮 ── */
|
||
.img-clear-btn {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
background: rgba(15, 23, 42, 0.55);
|
||
color: #fff;
|
||
border: none;
|
||
cursor: pointer;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 15px;
|
||
line-height: 1;
|
||
z-index: 10;
|
||
backdrop-filter: blur(4px);
|
||
transition: background .15s;
|
||
}
|
||
|
||
.img-clear-btn:hover {
|
||
background: rgba(220, 38, 38, 0.85);
|
||
}
|
||
|
||
.cancel-result-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
padding: 8px;
|
||
border: 1px solid #fecaca;
|
||
border-radius: 8px;
|
||
background: #fff5f5;
|
||
color: #dc2626;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
width: 100%;
|
||
transition: background .15s;
|
||
}
|
||
|
||
.cancel-result-btn:hover {
|
||
background: #fee2e2;
|
||
}
|
||
|
||
/* ── 全局 Token 栏 ── */
|
||
.token-bar {
|
||
max-width: 1400px;
|
||
margin: 0 auto 16px;
|
||
background: #fff;
|
||
border-radius: 10px;
|
||
padding: 12px 18px;
|
||
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.07);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.token-bar label {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.token-bar input {
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
outline: none;
|
||
color: #1e293b;
|
||
transition: border-color .2s;
|
||
font-family: ui-monospace, monospace;
|
||
}
|
||
|
||
.token-bar input:focus {
|
||
border-color: #2563eb;
|
||
box-shadow: 0 0 0 3px rgba(37, 99, 235, .1);
|
||
}
|
||
|
||
.token-bar .hint {
|
||
font-size: 11px;
|
||
color: #94a3b8;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.token-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: #cbd5e1;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.token-dot.active {
|
||
background: #22c55e;
|
||
}
|
||
|
||
/* ── 封面合成 Tab ── */
|
||
.cover-layout {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
display: grid;
|
||
grid-template-columns: 420px 1fr;
|
||
gap: 20px;
|
||
align-items: start;
|
||
}
|
||
|
||
.cover-left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.cover-section-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.cover-section-header .badge {
|
||
font-size: 11px;
|
||
padding: 2px 8px;
|
||
border-radius: 20px;
|
||
background: #f1f5f9;
|
||
color: #64748b;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.cover-section-header .badge.done {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.cover-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 6px;
|
||
margin-top: 10px;
|
||
max-height: 240px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.cover-grid-item {
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
border: 2px solid transparent;
|
||
background: #f8fafc;
|
||
transition: border-color .15s;
|
||
}
|
||
|
||
.cover-grid-item:hover {
|
||
border-color: #94a3b8;
|
||
}
|
||
|
||
.cover-grid-item.selected {
|
||
border-color: #2563eb;
|
||
}
|
||
|
||
.cover-grid-item img {
|
||
width: 100%;
|
||
display: block;
|
||
object-fit: contain;
|
||
min-height: 48px;
|
||
background: #f1f5f9;
|
||
}
|
||
|
||
.cover-sel-area {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.cover-sel-area img {
|
||
width: 100%;
|
||
border-radius: 8px;
|
||
display: block;
|
||
background: repeating-conic-gradient(#e2e8f0 0% 25%, #fff 0% 50%) 0 0 / 12px 12px;
|
||
}
|
||
|
||
.cover-result-area {
|
||
margin-top: 10px;
|
||
padding: 10px;
|
||
border-radius: 8px;
|
||
background: #f8fafc;
|
||
border: 1px solid #e2e8f0;
|
||
}
|
||
|
||
.cover-result-area img {
|
||
width: 100%;
|
||
border-radius: 6px;
|
||
display: block;
|
||
background: repeating-conic-gradient(#e2e8f0 0% 25%, #fff 0% 50%) 0 0 / 12px 12px;
|
||
}
|
||
|
||
.cover-confirm-btn {
|
||
width: 100%;
|
||
margin-top: 10px;
|
||
padding: 9px;
|
||
background: #dcfce7;
|
||
border: 1px solid #86efac;
|
||
border-radius: 8px;
|
||
color: #166534;
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: background .15s;
|
||
}
|
||
|
||
.cover-confirm-btn:hover {
|
||
background: #bbf7d0;
|
||
}
|
||
|
||
.cover-history-area {
|
||
margin-top: 12px;
|
||
padding: 10px;
|
||
background: #f8fafc;
|
||
border-radius: 8px;
|
||
border: 1px solid #e2e8f0;
|
||
}
|
||
|
||
.cover-history-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.cover-history-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 0;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
}
|
||
|
||
.cover-history-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.cover-history-thumb {
|
||
height: 56px;
|
||
width: 80px;
|
||
object-fit: contain;
|
||
border-radius: 4px;
|
||
flex-shrink: 0;
|
||
background: repeating-conic-gradient(#e2e8f0 0% 25%, #fff 0% 50%) 0 0 / 10px 10px;
|
||
}
|
||
|
||
.cover-history-place-btn {
|
||
flex-shrink: 0;
|
||
padding: 4px 10px;
|
||
font-size: 11px;
|
||
border: 1px solid #2563eb;
|
||
color: #2563eb;
|
||
border-radius: 6px;
|
||
background: #eff6ff;
|
||
cursor: pointer;
|
||
transition: background .15s;
|
||
}
|
||
|
||
.cover-history-place-btn:hover {
|
||
background: #dbeafe;
|
||
}
|
||
|
||
/* Artboard */
|
||
.artboard-outer {
|
||
background: #1e293b;
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.artboard-wrap {
|
||
position: relative;
|
||
width: 100%;
|
||
overflow: visible;
|
||
border-radius: 8px;
|
||
background: #0f172a;
|
||
touch-action: none;
|
||
}
|
||
|
||
.artboard-wrap canvas#cover-bg-canvas {
|
||
display: block;
|
||
width: 100%;
|
||
height: auto;
|
||
}
|
||
|
||
.artboard-item {
|
||
position: absolute;
|
||
cursor: move;
|
||
user-select: none;
|
||
transform-origin: center center;
|
||
}
|
||
|
||
.artboard-item img {
|
||
display: block;
|
||
width: 100%;
|
||
height: auto;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.artboard-item.art-active {
|
||
outline: 2px solid #2563eb;
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* 旋转手柄 — 顶部中间,在元素内 */
|
||
.art-rotate-handle {
|
||
position: absolute;
|
||
top: -24px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
background: #2563eb;
|
||
border: 2px solid #fff;
|
||
cursor: grab;
|
||
z-index: 20;
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||
display: none;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.art-rotate-handle::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: -10px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 2px;
|
||
height: 10px;
|
||
background: #2563eb;
|
||
}
|
||
|
||
.art-active .art-rotate-handle {
|
||
display: block;
|
||
}
|
||
|
||
/* 四角缩放手柄 — 在元素角落内侧 */
|
||
.art-resize-handle {
|
||
position: absolute;
|
||
width: 14px;
|
||
height: 14px;
|
||
background: #fff;
|
||
border: 2px solid #2563eb;
|
||
border-radius: 2px;
|
||
z-index: 20;
|
||
display: none;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.art-active .art-resize-handle {
|
||
display: block;
|
||
}
|
||
|
||
.art-resize-handle.nw {
|
||
top: -7px;
|
||
left: -7px;
|
||
cursor: nw-resize;
|
||
}
|
||
|
||
.art-resize-handle.ne {
|
||
top: -7px;
|
||
right: -7px;
|
||
cursor: ne-resize;
|
||
}
|
||
|
||
.art-resize-handle.sw {
|
||
bottom: -7px;
|
||
left: -7px;
|
||
cursor: sw-resize;
|
||
}
|
||
|
||
.art-resize-handle.se {
|
||
bottom: -7px;
|
||
right: -7px;
|
||
cursor: se-resize;
|
||
}
|
||
|
||
/* ── 画板控制栏 ── */
|
||
.art-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
padding: 8px 12px;
|
||
background: #f1f5f9;
|
||
border-radius: 8px;
|
||
flex-wrap: wrap;
|
||
font-size: 12px;
|
||
color: #475569;
|
||
}
|
||
|
||
.art-controls label {
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.art-controls input[type=range] {
|
||
width: 100px;
|
||
accent-color: #2563eb;
|
||
}
|
||
|
||
.art-ctrl-btn {
|
||
padding: 4px 10px;
|
||
border: 1px solid #cbd5e1;
|
||
border-radius: 6px;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
color: #475569;
|
||
transition: background .15s;
|
||
}
|
||
|
||
.art-ctrl-btn:hover {
|
||
background: #e2e8f0;
|
||
}
|
||
|
||
.art-ctrl-sep {
|
||
width: 1px;
|
||
height: 20px;
|
||
background: #cbd5e1;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
|
||
<header>
|
||
<h1>AI-IMAGE-RS</h1>
|
||
<p>版本: V1.2</p>
|
||
</header>
|
||
|
||
<!-- 全局 Token 输入 -->
|
||
<div class="token-bar">
|
||
<div class="token-dot" id="token-dot"></div>
|
||
<label>补充Token</label>
|
||
<input type="text" id="global-token" placeholder="可留空,填写 hf_xxxx Token" oninput="updateTokenDot()">
|
||
<span class="hint">留空 = 内置默认</span>
|
||
</div>
|
||
|
||
<!-- Tab 按钮 -->
|
||
<div class="tabs">
|
||
<button class="tab-btn active" onclick="switchTab('edit', this)">编辑</button>
|
||
<button class="tab-btn" onclick="switchTab('png', this)">PNG 贴图</button>
|
||
<button class="tab-btn" onclick="switchTab('cover', this); coverInit()">封面合成</button>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════ -->
|
||
<!-- Tab 1: AI Edit -->
|
||
<!-- ═══════════════════════════════════════════ -->
|
||
<div id="tab-edit" class="tab-panel active">
|
||
<div class="layout">
|
||
|
||
<!-- 左:输入 -->
|
||
<div class="card">
|
||
<div class="card-title">输入</div>
|
||
|
||
<!-- 画布 -->
|
||
<div style="font-size:13px;font-weight:600;color:#374151;margin-bottom:6px;">
|
||
源图 — 可用画笔在图上标注区域
|
||
</div>
|
||
<div class="canvas-wrap" id="edit-canvas-wrap" style="position:relative">
|
||
<div class="empty-hint">🖼️<br>点击上传图片</div>
|
||
<canvas id="edit-canvas" style="display:none"></canvas>
|
||
<button class="img-clear-btn" id="edit-clear-btn" onclick="editRemoveImage()" title="清除图片">✕</button>
|
||
</div>
|
||
<input type="file" id="edit-file" accept="image/*" onchange="editLoadImage(this.files[0])">
|
||
|
||
<!-- 画笔工具条 -->
|
||
<div class="brush-bar" id="edit-brush-bar" style="display:none">
|
||
<div class="color-dot active" style="background:#FF0000" data-color="#FF0000" onclick="editSetColor(this)"
|
||
title="红"></div>
|
||
<div class="color-dot" style="background:#00CC00" data-color="#00CC00" onclick="editSetColor(this)" title="绿">
|
||
</div>
|
||
<div class="color-dot" style="background:#0066FF" data-color="#0066FF" onclick="editSetColor(this)" title="蓝">
|
||
</div>
|
||
<div class="color-dot" style="background:#FFFF00;border-color:#e2e8f0" data-color="#FFFF00"
|
||
onclick="editSetColor(this)" title="黄"></div>
|
||
<div class="color-dot" style="background:#FF00FF" data-color="#FF00FF" onclick="editSetColor(this)" title="紫">
|
||
</div>
|
||
<div class="color-dot" style="background:#FFFFFF;border-color:#e2e8f0" data-color="#FFFFFF"
|
||
onclick="editSetColor(this)" title="白"></div>
|
||
<div class="brush-sep"></div>
|
||
<div class="brush-size-wrap">
|
||
大小 <input type="range" id="edit-size" min="2" max="40" value="10">
|
||
<span id="edit-size-val">10</span>
|
||
</div>
|
||
<div class="brush-sep"></div>
|
||
<button class="brush-action-btn" onclick="editClearDraw()">清除涂抹</button>
|
||
<button class="brush-action-btn" onclick="document.getElementById('edit-file').click()">更换图片</button>
|
||
</div>
|
||
<div style="font-size:11px;color:#94a3b8;margin-top:4px;">
|
||
🔴红 🟢绿 🔵蓝 🟡黄 🟣紫 ⬜白 — 标注后提示词写:<em>去掉红色区域</em> 等
|
||
</div>
|
||
|
||
<!-- 参考图 -->
|
||
<div class="field">
|
||
<label>参考图(可选)</label>
|
||
<div style="position:relative">
|
||
<div class="upload-zone" id="ref-zone" onclick="document.getElementById('ref-file').click()"
|
||
ondragover="event.preventDefault();this.classList.add('drag')" ondragleave="this.classList.remove('drag')"
|
||
ondrop="refDrop(event)">
|
||
📎 点击或拖拽参考图
|
||
</div>
|
||
<button class="img-clear-btn" id="ref-cancel" onclick="refClear()" title="清除参考图">✕</button>
|
||
</div>
|
||
<input type="file" id="ref-file" accept="image/*" onchange="refLoad(this.files[0])">
|
||
</div>
|
||
|
||
<!-- 提示词 -->
|
||
<div class="field">
|
||
<label>编辑提示词 Prompt</label>
|
||
<textarea id="edit-prompt" rows="3" placeholder='例如:去掉红色标注区域的文字,或 将"A2.com"改为"B3.com"'></textarea>
|
||
</div>
|
||
|
||
<!-- 高级设置 -->
|
||
<details>
|
||
<summary>高级设置</summary>
|
||
<div class="adv-grid">
|
||
<div class="adv-item" style="grid-column:1/-1">
|
||
<label>Negative Prompt</label>
|
||
<textarea id="edit-neg" rows="2"
|
||
style="min-height:0">worst quality, low quality, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, jpeg artifacts, signature, watermark, username, blurry</textarea>
|
||
</div>
|
||
<div class="adv-item">
|
||
<label>Seed</label>
|
||
<input type="number" id="edit-seed" value="0" min="0">
|
||
</div>
|
||
<div class="adv-item" style="display:flex;align-items:center;gap:6px;padding-top:16px">
|
||
<input type="checkbox" id="edit-rand" checked>
|
||
<label for="edit-rand" style="margin:0;font-size:12px">随机 Seed</label>
|
||
</div>
|
||
<div class="adv-item">
|
||
<label>Guidance Scale <span id="gs-val">1.0</span></label>
|
||
<input type="range" id="edit-gs" min="1" max="10" step="0.1" value="1.0"
|
||
oninput="document.getElementById('gs-val').textContent=this.value">
|
||
</div>
|
||
<div class="adv-item">
|
||
<label>Steps <span id="steps-val">4</span></label>
|
||
<input type="range" id="edit-steps" min="1" max="50" step="1" value="4"
|
||
oninput="document.getElementById('steps-val').textContent=this.value">
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<div style="display:flex;align-items:center;gap:8px;margin-top:14px;">
|
||
<input type="checkbox" id="edit-color-match">
|
||
<label for="edit-color-match" style="font-size:13px;font-weight:600;color:#374151;cursor:pointer;">Color Match
|
||
— 色彩对齐原图</label>
|
||
</div>
|
||
|
||
<button class="run-btn" id="edit-btn" onclick="runEdit()">开始 AI 编辑</button>
|
||
<div class="status-box" id="edit-status"></div>
|
||
</div>
|
||
|
||
<!-- 右:结果 -->
|
||
<div class="card sticky-card">
|
||
<div class="card-title">结果</div>
|
||
<div class="result-placeholder" id="edit-placeholder">
|
||
<span>🎨</span><span>编辑结果将显示在这里</span>
|
||
</div>
|
||
<div class="result-img-wrap" id="edit-output">
|
||
<div style="position:relative">
|
||
<img id="edit-out-img" style="width:100%;border-radius:10px;display:block;">
|
||
<button class="img-clear-btn" style="display:flex" onclick="clearResult('edit')" title="清除结果">✕</button>
|
||
</div>
|
||
<div class="action-row">
|
||
<a id="edit-dl" class="dl-btn" download="ai_edit.png">⬇️ 下载结果</a>
|
||
<button type="button" id="edit-apply" class="apply-btn">应用到封面</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════ -->
|
||
<!-- Tab 2: PNG Placement(画板模式) -->
|
||
<!-- ═══════════════════════════════════════════ -->
|
||
<div id="tab-png" class="tab-panel">
|
||
<div class="layout">
|
||
|
||
<!-- 左:上传 -->
|
||
<div class="card">
|
||
<div class="card-title">素材</div>
|
||
|
||
<!-- 底图 -->
|
||
<div class="field" style="margin-top:0">
|
||
<label>底图</label>
|
||
<div style="position:relative">
|
||
<div class="upload-zone" id="png2-bg-zone" onclick="document.getElementById('png2-bg-file').click()"
|
||
ondragover="event.preventDefault();this.classList.add('drag')" ondragleave="this.classList.remove('drag')"
|
||
ondrop="event.preventDefault();this.classList.remove('drag');png2LoadBg(event.dataTransfer.files[0])">
|
||
📷 点击或拖拽上传底图
|
||
</div>
|
||
<button class="img-clear-btn" id="png2-bg-clear" onclick="png2ClearBg()" title="清除底图">✕</button>
|
||
</div>
|
||
<input type="file" id="png2-bg-file" accept="image/*" onchange="png2LoadBg(this.files[0])">
|
||
</div>
|
||
|
||
<!-- PNG 素材列表 -->
|
||
<div class="field">
|
||
<label>PNG 素材(可添加多个)</label>
|
||
<div class="upload-zone" id="png2-add-zone" onclick="document.getElementById('png2-add-file').click()"
|
||
ondragover="event.preventDefault();this.classList.add('drag')" ondragleave="this.classList.remove('drag')"
|
||
ondrop="event.preventDefault();this.classList.remove('drag');Array.from(event.dataTransfer.files).forEach(f=>png2AddOverlay(f))">
|
||
📎 点击或拖拽添加 PNG
|
||
</div>
|
||
<input type="file" id="png2-add-file" accept="image/png,image/*" multiple
|
||
onchange="Array.from(this.files).forEach(f=>png2AddOverlay(f));this.value=''">
|
||
<div id="png2-overlay-list" style="margin-top:8px"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右:画板 -->
|
||
<div class="card sticky-card">
|
||
<div class="card-title">画板 <span style="font-size:11px;color:#94a3b8;font-weight:400">— 拖拽移动 · 滚轮缩放 ·
|
||
Shift+滚轮旋转</span></div>
|
||
<div class="artboard-outer">
|
||
<div class="artboard-wrap" id="png2-artboard-wrap" onmousemove="png2MouseMove(event)"
|
||
onmouseup="png2MouseUp()" onmouseleave="png2MouseUp()">
|
||
<canvas id="png2-bg-canvas"></canvas>
|
||
</div>
|
||
</div>
|
||
<!-- 控制栏 -->
|
||
<div class="art-controls" id="png2-controls">
|
||
<label>图层</label>
|
||
<button class="art-ctrl-btn" onclick="png2LayerMove('up')">⬆ 上移</button>
|
||
<button class="art-ctrl-btn" onclick="png2LayerMove('down')">⬇ 下移</button>
|
||
<button class="art-ctrl-btn" onclick="png2RemoveActive()" style="color:#dc2626;border-color:#fecaca">🗑
|
||
删除</button>
|
||
<span id="png2-layer-val" style="min-width:60px;text-align:center;font-size:11px;color:#94a3b8">—</span>
|
||
<div class="art-ctrl-sep"></div>
|
||
<label>旋转</label>
|
||
<button class="art-ctrl-btn" onclick="png2RotateStep(-15)">↺</button>
|
||
<input type="range" id="png2-rotation-slider" min="-180" max="180" value="0" step="1"
|
||
oninput="png2SetRotation(+this.value)">
|
||
<span id="png2-rotation-val">0°</span>
|
||
<button class="art-ctrl-btn" onclick="png2RotateStep(+15)">↻</button>
|
||
<button class="art-ctrl-btn" onclick="png2SetRotation(0)">归零</button>
|
||
<div class="art-ctrl-sep"></div>
|
||
<label>缩放</label>
|
||
<button class="art-ctrl-btn" onclick="png2ScaleActive(0.9)">➖</button>
|
||
<button class="art-ctrl-btn" onclick="png2ScaleActive(1.1)">➕</button>
|
||
<span id="png2-scale-val" style="min-width:40px;text-align:center">100%</span>
|
||
</div>
|
||
<div class="action-row" style="margin-top:14px;">
|
||
<button class="run-btn" id="png2-export-btn" onclick="png2Export()">⬇️ 导出合成图</button>
|
||
<button type="button" id="png2-apply-btn" class="apply-btn" onclick="png2Apply()">应用到封面</button>
|
||
</div>
|
||
<div class="status-box" id="png2-status"></div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════ -->
|
||
<!-- Tab 3: 封面合成 -->
|
||
<!-- ═══════════════════════════════════════════ -->
|
||
<div id="tab-cover" class="tab-panel">
|
||
<div class="cover-layout">
|
||
|
||
<!-- 左:三个素材池 -->
|
||
<div class="cover-left">
|
||
|
||
<!-- 底图 -->
|
||
<div class="card">
|
||
<div class="card-title">底图</div>
|
||
<div style="position:relative">
|
||
<div class="upload-zone" id="cover-bg-zone" onclick="document.getElementById('cover-bg-file').click()"
|
||
ondragover="event.preventDefault();this.classList.add('drag')" ondragleave="this.classList.remove('drag')"
|
||
ondrop="event.preventDefault();this.classList.remove('drag');coverLoadBg(event.dataTransfer.files[0])">
|
||
📷 点击或拖拽上传底图
|
||
</div>
|
||
<button class="img-clear-btn" id="cover-bg-clear" onclick="coverClearBg()" title="清除底图">✕</button>
|
||
</div>
|
||
<input type="file" id="cover-bg-file" accept="image/*" onchange="coverLoadBg(this.files[0])">
|
||
</div>
|
||
|
||
<!-- 三个标题素材池 -->
|
||
<div id="cover-panels-wrap"></div>
|
||
</div>
|
||
|
||
<!-- 右:画板 -->
|
||
<div class="card sticky-card" id="cover-artboard-card">
|
||
<div class="card-title">画板 <span style="font-size:11px;color:#94a3b8;font-weight:400">— 拖拽移动 · 滚轮缩放</span></div>
|
||
<div class="artboard-outer">
|
||
<div class="artboard-wrap" id="cover-artboard-wrap" onmousemove="artMouseMove(event)" onmouseup="artMouseUp()"
|
||
onmouseleave="artMouseUp()">
|
||
<canvas id="cover-bg-canvas"></canvas>
|
||
</div>
|
||
</div>
|
||
<!-- 画板控制栏 -->
|
||
<div class="art-controls" id="art-controls">
|
||
<label>图层</label>
|
||
<button class="art-ctrl-btn" onclick="artLayerMove('up')" title="上移一层">⬆ 上移</button>
|
||
<button class="art-ctrl-btn" onclick="artLayerMove('down')" title="下移一层">⬇ 下移</button>
|
||
<span id="art-layer-val" style="min-width:60px;text-align:center;font-size:11px;color:#94a3b8">—</span>
|
||
<div class="art-ctrl-sep"></div>
|
||
<label>旋转</label>
|
||
<button class="art-ctrl-btn" onclick="artRotateStep(-15)">↺ -15°</button>
|
||
<input type="range" id="art-rotation-slider" min="-180" max="180" value="0" step="1"
|
||
oninput="artSetRotation(+this.value)">
|
||
<span id="art-rotation-val">0°</span>
|
||
<button class="art-ctrl-btn" onclick="artRotateStep(+15)">↻ +15°</button>
|
||
<button class="art-ctrl-btn" onclick="artSetRotation(0)">归零</button>
|
||
<div class="art-ctrl-sep"></div>
|
||
<label>缩放</label>
|
||
<button class="art-ctrl-btn" onclick="artScaleActive(0.9)">➖</button>
|
||
<button class="art-ctrl-btn" onclick="artScaleActive(1.1)">➕</button>
|
||
<span id="art-scale-val" style="min-width:40px;text-align:center">100%</span>
|
||
</div>
|
||
<div style="font-size:11px;color:#94a3b8;margin-top:4px;">
|
||
💡 滚轮缩放 · Shift+滚轮旋转 · 拖拽移动
|
||
</div>
|
||
<div class="action-row" style="margin-top:14px;">
|
||
<button class="run-btn" onclick="exportCover()">⬇️ 导出封面</button>
|
||
<button type="button" id="cover-apply-btn" class="apply-btn" onclick="coverApply()">应用到封面</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════ -->
|
||
<script>
|
||
const SPACE = 'https://sfdhfdjfg-image111.hf.space';
|
||
const DEFAULT_TOKEN = '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';
|
||
|
||
function updateTokenDot() {
|
||
const val = (document.getElementById('global-token').value || '').trim();
|
||
document.getElementById('token-dot').classList.toggle('active', !!val);
|
||
}
|
||
|
||
function getHeaders() {
|
||
const t = (document.getElementById('global-token').value || '').trim() || DEFAULT_TOKEN;
|
||
return { 'Authorization': 'Bearer ' + t };
|
||
}
|
||
|
||
// 保持向后兼容的 HEADERS getter(用 Proxy 保证每次取值都是最新 token)
|
||
const HEADERS = new Proxy({}, {
|
||
get(_, prop) { return getHeaders()[prop]; },
|
||
ownKeys() { return Object.keys(getHeaders()); },
|
||
getOwnPropertyDescriptor(_, prop) {
|
||
const h = getHeaders();
|
||
return prop in h ? { value: h[prop], enumerable: true, configurable: true } : undefined;
|
||
}
|
||
});
|
||
|
||
let editResultObjectUrl = '';
|
||
let editResultMime = 'image/png';
|
||
let png2ResultObjectUrl = '';
|
||
let png2ResultMime = 'image/jpeg';
|
||
let coverResultObjectUrl = '';
|
||
let coverResultMime = 'image/jpeg';
|
||
|
||
function revokeObjectUrlSafe(url) {
|
||
if (!url || !url.startsWith('blob:')) return;
|
||
try { URL.revokeObjectURL(url); } catch (e) { }
|
||
}
|
||
|
||
function rememberResultObject(kind, blob) {
|
||
const objectUrl = URL.createObjectURL(blob);
|
||
if (kind === 'edit') {
|
||
revokeObjectUrlSafe(editResultObjectUrl);
|
||
editResultObjectUrl = objectUrl;
|
||
editResultMime = blob.type || 'image/png';
|
||
} else if (kind === 'png2') {
|
||
revokeObjectUrlSafe(png2ResultObjectUrl);
|
||
png2ResultObjectUrl = objectUrl;
|
||
png2ResultMime = blob.type || 'image/jpeg';
|
||
} else if (kind === 'cover') {
|
||
revokeObjectUrlSafe(coverResultObjectUrl);
|
||
coverResultObjectUrl = objectUrl;
|
||
coverResultMime = blob.type || 'image/jpeg';
|
||
}
|
||
return objectUrl;
|
||
}
|
||
|
||
function postResultToParent(objectUrl, mimeType, fileName) {
|
||
window.parent && window.parent.postMessage({
|
||
type: 'AI_IMAGE_EDIT_RESULT',
|
||
objectUrl,
|
||
mimeType,
|
||
fileName
|
||
}, '*');
|
||
}
|
||
|
||
// ─── Tab 切换 ───────────────────────────────
|
||
function switchTab(id, btn) {
|
||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
document.getElementById('tab-' + id).classList.add('active');
|
||
btn.classList.add('active');
|
||
if (id === 'png' && png2BgImg) {
|
||
setTimeout(() => png2RenderBg(), 0);
|
||
}
|
||
if (id === 'cover') {
|
||
setTimeout(() => {
|
||
if (coverBgImg) renderArtboardBg();
|
||
}, 0);
|
||
}
|
||
}
|
||
|
||
// 画笔圆圈光标(白描边 + 黑虚线,任何背景都可见)
|
||
function drawCursorCircle(ctx, x, y, size) {
|
||
const r = Math.max(1, size / 2);
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||
ctx.lineWidth = 2;
|
||
ctx.setLineDash([]);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.7)';
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// AI Edit 画布
|
||
// ════════════════════════════════════════════
|
||
let editBgImage = null; // HTMLImageElement 原始图
|
||
let editCanvas = document.getElementById('edit-canvas');
|
||
let editCtx = editCanvas.getContext('2d');
|
||
let editColor = '#FF0000';
|
||
let editDrawing = false;
|
||
let editLastX = 0, editLastY = 0;
|
||
let editCursorX = 0, editCursorY = 0, editOnCanvas = false;
|
||
|
||
// offscreen canvas 只存笔迹(透明底)
|
||
let editDrawOffscreen = null;
|
||
|
||
function editLoadImage(file) {
|
||
if (!file) return Promise.resolve();
|
||
return new Promise((resolve, reject) => {
|
||
const url = URL.createObjectURL(file);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
try {
|
||
editBgImage = img;
|
||
// 适配显示宽度;容器暂时不可见时回退到原图尺寸,避免创建 0x0 canvas
|
||
const wrap = document.getElementById('edit-canvas-wrap');
|
||
const maxW = wrap.clientWidth || Math.round(wrap.getBoundingClientRect().width) || img.naturalWidth;
|
||
const scale = Math.min(1, maxW / img.naturalWidth);
|
||
const displayW = Math.max(1, Math.round(img.naturalWidth * scale));
|
||
const displayH = Math.max(1, Math.round(img.naturalHeight * scale));
|
||
editCanvas.width = displayW;
|
||
editCanvas.height = displayH;
|
||
|
||
// 笔迹 offscreen
|
||
editDrawOffscreen = document.createElement('canvas');
|
||
editDrawOffscreen.width = displayW;
|
||
editDrawOffscreen.height = displayH;
|
||
|
||
editCanvas.style.display = 'block';
|
||
wrap.querySelector('.empty-hint').style.display = 'none';
|
||
wrap.classList.add('has-image');
|
||
document.getElementById('edit-brush-bar').style.display = 'flex';
|
||
document.getElementById('edit-clear-btn').style.display = 'flex';
|
||
editRedraw();
|
||
resolve();
|
||
} catch (e) {
|
||
reject(e);
|
||
} finally {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
};
|
||
img.onerror = () => {
|
||
URL.revokeObjectURL(url);
|
||
reject(new Error('图片加载失败'));
|
||
};
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
async function setSelectedFileFromUrl(imageUrl) {
|
||
if (!imageUrl || typeof imageUrl !== 'string') return;
|
||
showStatus('edit-status', 'running', '载入图片中...');
|
||
const resp = await fetch(imageUrl, { headers: { Accept: 'image/*' } });
|
||
if (!resp.ok) throw new Error('图片加载失败 ' + resp.status);
|
||
const blob = await resp.blob();
|
||
const file = new File([blob], 'cover.png', { type: blob.type || 'image/png' });
|
||
await editLoadImage(file);
|
||
png2LoadBg(file);
|
||
coverLoadBg(file);
|
||
showStatus('edit-status', 'success', '图片已载入');
|
||
}
|
||
|
||
function editRedraw() {
|
||
if (!editBgImage || editCanvas.width <= 0 || editCanvas.height <= 0) return;
|
||
editCtx.clearRect(0, 0, editCanvas.width, editCanvas.height);
|
||
editCtx.drawImage(editBgImage, 0, 0, editCanvas.width, editCanvas.height);
|
||
if (editDrawOffscreen && editDrawOffscreen.width > 0 && editDrawOffscreen.height > 0) {
|
||
editCtx.drawImage(editDrawOffscreen, 0, 0);
|
||
}
|
||
if (editOnCanvas) drawCursorCircle(editCtx, editCursorX, editCursorY, +document.getElementById('edit-size').value);
|
||
}
|
||
|
||
function editSetColor(dot) {
|
||
document.querySelectorAll('#edit-brush-bar .color-dot').forEach(d => d.classList.remove('active'));
|
||
dot.classList.add('active');
|
||
editColor = dot.dataset.color;
|
||
}
|
||
|
||
function editClearDraw() {
|
||
if (editDrawOffscreen) {
|
||
editDrawOffscreen.getContext('2d').clearRect(0, 0, editDrawOffscreen.width, editDrawOffscreen.height);
|
||
editRedraw();
|
||
}
|
||
}
|
||
|
||
// 坐标转换(考虑 canvas 缩放)
|
||
function editPos(e) {
|
||
const r = editCanvas.getBoundingClientRect();
|
||
const src = e.touches ? e.touches[0] : e;
|
||
return [
|
||
(src.clientX - r.left) * (editCanvas.width / r.width),
|
||
(src.clientY - r.top) * (editCanvas.height / r.height)
|
||
];
|
||
}
|
||
|
||
editCanvas.addEventListener('mousedown', e => {
|
||
if (!editBgImage) return;
|
||
editDrawing = true;
|
||
[editLastX, editLastY] = editPos(e);
|
||
});
|
||
editCanvas.addEventListener('mousemove', e => {
|
||
const [x, y] = editPos(e);
|
||
editCursorX = x; editCursorY = y; editOnCanvas = true;
|
||
if (editDrawing && editDrawOffscreen && editDrawOffscreen.width > 0 && editDrawOffscreen.height > 0) {
|
||
const dc = editDrawOffscreen.getContext('2d');
|
||
const size = +document.getElementById('edit-size').value;
|
||
dc.strokeStyle = editColor;
|
||
dc.lineWidth = size;
|
||
dc.lineCap = 'round';
|
||
dc.lineJoin = 'round';
|
||
dc.globalAlpha = 0.85;
|
||
dc.beginPath();
|
||
dc.moveTo(editLastX, editLastY);
|
||
dc.lineTo(x, y);
|
||
dc.stroke();
|
||
[editLastX, editLastY] = [x, y];
|
||
}
|
||
editRedraw();
|
||
});
|
||
editCanvas.addEventListener('mouseup', () => { editDrawing = false; });
|
||
editCanvas.addEventListener('mouseleave', () => { editDrawing = false; editOnCanvas = false; editRedraw(); });
|
||
|
||
// 点击未加载时触发上传
|
||
document.getElementById('edit-canvas-wrap').addEventListener('click', e => {
|
||
if (!editBgImage && !e.target.closest('button')) {
|
||
document.getElementById('edit-file').click();
|
||
}
|
||
});
|
||
|
||
// 拖拽上传源图
|
||
const editWrap = document.getElementById('edit-canvas-wrap');
|
||
editWrap.addEventListener('dragover', e => { e.preventDefault(); editWrap.style.outline = '2px dashed #2563eb'; });
|
||
editWrap.addEventListener('dragleave', () => { editWrap.style.outline = ''; });
|
||
editWrap.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
editWrap.style.outline = '';
|
||
const f = e.dataTransfer.files[0];
|
||
if (f && f.type.startsWith('image/')) editLoadImage(f);
|
||
});
|
||
|
||
// 画笔大小
|
||
document.getElementById('edit-size').addEventListener('input', function () {
|
||
document.getElementById('edit-size-val').textContent = this.value;
|
||
});
|
||
|
||
// ─── 参考图 ────────────────────────────────
|
||
let refFile = null;
|
||
function refLoad(file) {
|
||
if (!file) return;
|
||
refFile = file;
|
||
const zone = document.getElementById('ref-zone');
|
||
zone.classList.add('filled');
|
||
zone.innerHTML = `<img src="${URL.createObjectURL(file)}" alt="ref">`;
|
||
document.getElementById('ref-cancel').style.display = 'flex';
|
||
}
|
||
function refDrop(e) {
|
||
e.preventDefault();
|
||
document.getElementById('ref-zone').classList.remove('drag');
|
||
const f = e.dataTransfer.files[0];
|
||
if (f) refLoad(f);
|
||
}
|
||
|
||
// ─── 运行 AI Edit ──────────────────────────
|
||
async function runEdit() {
|
||
if (!editBgImage) { alert('请先上传图片'); return; }
|
||
const prompt = document.getElementById('edit-prompt').value.trim();
|
||
if (!prompt) { alert('请输入提示词'); return; }
|
||
|
||
const btn = document.getElementById('edit-btn');
|
||
btn.disabled = true;
|
||
showStatus('edit-status', 'running', '⏳ 上传中…');
|
||
showResult('edit', false);
|
||
|
||
try {
|
||
// 导出全分辨率合成图(原图 + 笔迹,同一尺寸)
|
||
const compositeBlob = await new Promise(r => {
|
||
const tmp = document.createElement('canvas');
|
||
tmp.width = editBgImage.naturalWidth;
|
||
tmp.height = editBgImage.naturalHeight;
|
||
const c = tmp.getContext('2d');
|
||
c.drawImage(editBgImage, 0, 0);
|
||
if (editDrawOffscreen) c.drawImage(editDrawOffscreen, 0, 0, tmp.width, tmp.height);
|
||
tmp.toBlob(r, 'image/png');
|
||
});
|
||
|
||
// 只上传一个文件,同时作为 background 和 composite
|
||
const compPath = await uploadBlob(compositeBlob, 'source.png');
|
||
|
||
// 上传参考图(可选)
|
||
let refPath = null;
|
||
if (refFile) refPath = await uploadBlob(refFile, refFile.name);
|
||
|
||
showStatus('edit-status', 'running', '⏳ AI 处理中,请稍候…');
|
||
|
||
const sourceEditor = {
|
||
background: fileData(compPath, 'source.png', 'image/png'),
|
||
layers: [],
|
||
composite: fileData(compPath, 'source.png', 'image/png'),
|
||
};
|
||
const refArg = refPath ? fileData(refPath, refFile.name, refFile.type) : null;
|
||
|
||
const data = [
|
||
sourceEditor, refArg,
|
||
prompt,
|
||
document.getElementById('edit-neg').value,
|
||
+document.getElementById('edit-seed').value,
|
||
document.getElementById('edit-rand').checked,
|
||
+document.getElementById('edit-gs').value,
|
||
+document.getElementById('edit-steps').value,
|
||
document.getElementById('edit-color-match').checked,
|
||
];
|
||
|
||
const inferResp = await callInfer('infer', data);
|
||
const result = await pollResult(inferResp.event_id, 'infer', document.getElementById('edit-status'));
|
||
// 服务端已做 color match,客户端直接显示
|
||
await showOutputImage('edit', result[0]);
|
||
showStatus('edit-status', 'success', '✅ 编辑完成!');
|
||
|
||
} catch (e) {
|
||
showStatus('edit-status', 'error', '❌ ' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// PNG Placement 画板模式
|
||
// ════════════════════════════════════════════
|
||
let png2BgImg = null;
|
||
let png2NatW = 0, png2NatH = 0;
|
||
let png2BgFileName = '';
|
||
let png2Items = []; // [{id, blobUrl, name, aspect, x, y, w, rotation, el}]
|
||
let png2NextId = 0;
|
||
let png2LayerOrder = [];
|
||
let png2Drag = null;
|
||
let png2RotDrag = null;
|
||
let png2ResizeDrag = null;
|
||
|
||
function png2LoadBg(file) {
|
||
if (!file) return;
|
||
png2BgFileName = file.name;
|
||
const url = URL.createObjectURL(file);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
png2BgImg = img;
|
||
png2NatW = img.naturalWidth;
|
||
png2NatH = img.naturalHeight;
|
||
const zone = document.getElementById('png2-bg-zone');
|
||
zone.classList.add('filled');
|
||
zone.innerHTML = `<img src="${url}" style="max-height:120px;width:100%;object-fit:contain">`;
|
||
document.getElementById('png2-bg-clear').style.display = 'flex';
|
||
png2RenderBg();
|
||
};
|
||
img.src = url;
|
||
}
|
||
|
||
function png2ClearBg() {
|
||
png2BgImg = null;
|
||
const zone = document.getElementById('png2-bg-zone');
|
||
zone.classList.remove('filled');
|
||
zone.innerHTML = '📷 点击或拖拽上传底图';
|
||
document.getElementById('png2-bg-clear').style.display = 'none';
|
||
document.getElementById('png2-bg-file').value = '';
|
||
const c = document.getElementById('png2-bg-canvas');
|
||
c.getContext('2d').clearRect(0, 0, c.width, c.height);
|
||
}
|
||
|
||
function png2RenderBg() {
|
||
if (!png2BgImg) return;
|
||
const wrap = document.getElementById('png2-artboard-wrap');
|
||
const canvas = document.getElementById('png2-bg-canvas');
|
||
const dw = wrap.clientWidth;
|
||
const dh = Math.round(dw * png2NatH / png2NatW);
|
||
canvas.width = dw; canvas.height = dh;
|
||
canvas.style.height = dh + 'px';
|
||
canvas.getContext('2d').drawImage(png2BgImg, 0, 0, dw, dh);
|
||
png2Items.forEach(it => png2UpdatePos(it.id));
|
||
}
|
||
|
||
function png2AddOverlay(file) {
|
||
if (!file) return;
|
||
const blobUrl = URL.createObjectURL(file);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const id = png2NextId++;
|
||
const item = {
|
||
id, blobUrl, name: file.name,
|
||
aspect: img.naturalWidth / img.naturalHeight,
|
||
x: 0.2, y: 0.2, w: 0.3, rotation: 0, el: null,
|
||
};
|
||
png2Items.push(item);
|
||
png2LayerOrder.push(id);
|
||
png2RenderItem(item);
|
||
png2UpdateList();
|
||
};
|
||
img.src = blobUrl;
|
||
}
|
||
|
||
function png2GetItem(id) { return png2Items.find(it => it.id === id); }
|
||
|
||
function png2RenderItem(item) {
|
||
const wrap = document.getElementById('png2-artboard-wrap');
|
||
const el = document.createElement('div');
|
||
el.id = `png2-item-${item.id}`;
|
||
el.className = 'artboard-item';
|
||
|
||
const img = document.createElement('img');
|
||
img.src = item.blobUrl; img.draggable = false;
|
||
el.appendChild(img);
|
||
|
||
// 旋转手柄
|
||
const rh = document.createElement('div');
|
||
rh.className = 'art-rotate-handle';
|
||
rh.addEventListener('mousedown', e => { e.stopPropagation(); png2StartRotate(e, item.id); });
|
||
el.appendChild(rh);
|
||
|
||
// 缩放手柄
|
||
['nw', 'ne', 'sw', 'se'].forEach(corner => {
|
||
const ch = document.createElement('div');
|
||
ch.className = `art-resize-handle ${corner}`;
|
||
ch.addEventListener('mousedown', e => { e.stopPropagation(); png2StartResize(e, item.id); });
|
||
el.appendChild(ch);
|
||
});
|
||
|
||
el.addEventListener('mousedown', e => {
|
||
if (e.target.classList.contains('art-rotate-handle') || e.target.classList.contains('art-resize-handle')) return;
|
||
png2StartDrag(e, item.id);
|
||
});
|
||
el.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
if (e.shiftKey) {
|
||
item.rotation += (e.deltaY < 0 ? 5 : -5);
|
||
item.rotation = ((item.rotation + 180) % 360) - 180;
|
||
png2UpdatePos(item.id);
|
||
} else {
|
||
png2Scale(item.id, e.deltaY < 0 ? 1.06 : 0.94);
|
||
}
|
||
}, { passive: false });
|
||
el.addEventListener('click', e => { e.stopPropagation(); png2SetActive(item.id); });
|
||
wrap.appendChild(el);
|
||
item.el = el;
|
||
png2SetActive(item.id);
|
||
png2UpdatePos(item.id);
|
||
}
|
||
|
||
function png2UpdatePos(id) {
|
||
const item = png2GetItem(id);
|
||
if (!item || !item.el) return;
|
||
const wrap = document.getElementById('png2-artboard-wrap');
|
||
const ww = wrap.clientWidth;
|
||
const wh = parseInt(document.getElementById('png2-bg-canvas').style.height) || ww;
|
||
const pxW = item.w * ww;
|
||
const pxH = pxW / item.aspect;
|
||
item.el.style.left = (item.x * ww) + 'px';
|
||
item.el.style.top = (item.y * wh) + 'px';
|
||
item.el.style.width = pxW + 'px';
|
||
item.el.querySelector('img').style.height = pxH + 'px';
|
||
item.el.style.transform = `rotate(${item.rotation}deg)`;
|
||
png2UpdateControls(id);
|
||
}
|
||
|
||
function png2SetActive(id) {
|
||
document.querySelectorAll('#png2-artboard-wrap .artboard-item').forEach(el => el.classList.remove('art-active'));
|
||
const item = png2GetItem(id);
|
||
if (item && item.el) item.el.classList.add('art-active');
|
||
png2ApplyZIndex();
|
||
png2UpdateControls(id);
|
||
}
|
||
|
||
function png2ApplyZIndex() {
|
||
png2LayerOrder.forEach((lid, i) => {
|
||
const it = png2GetItem(lid);
|
||
if (it && it.el) it.el.style.zIndex = i + 1;
|
||
});
|
||
}
|
||
|
||
function png2GetActiveId() {
|
||
const el = document.querySelector('#png2-artboard-wrap .artboard-item.art-active');
|
||
if (!el) return null;
|
||
return png2Items.find(it => it.el === el)?.id ?? null;
|
||
}
|
||
|
||
function png2UpdateControls(id) {
|
||
const item = png2GetItem(id);
|
||
if (!item) return;
|
||
document.getElementById('png2-rotation-slider').value = item.rotation;
|
||
document.getElementById('png2-rotation-val').textContent = Math.round(item.rotation) + '°';
|
||
document.getElementById('png2-scale-val').textContent = Math.round(item.w * 100 / 0.3) + '%';
|
||
const idx = png2LayerOrder.indexOf(id);
|
||
document.getElementById('png2-layer-val').textContent = `${item.name.slice(0, 12)} (${idx + 1}/${png2LayerOrder.length})`;
|
||
}
|
||
|
||
function png2UpdateList() {
|
||
const wrap = document.getElementById('png2-overlay-list');
|
||
wrap.innerHTML = png2Items.map(it =>
|
||
`<div style="display:flex;align-items:center;gap:6px;padding:4px 0;font-size:12px;color:#475569">
|
||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${it.name}</span>
|
||
<button class="brush-action-btn" onclick="png2SetActive(${it.id})">选中</button>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
// 拖拽移动
|
||
function png2StartDrag(e, id) {
|
||
e.preventDefault(); png2SetActive(id);
|
||
const wrap = document.getElementById('png2-artboard-wrap');
|
||
const wh = parseInt(document.getElementById('png2-bg-canvas').style.height) || wrap.clientWidth;
|
||
const item = png2GetItem(id);
|
||
png2Drag = {
|
||
id, startX: e.clientX, startY: e.clientY,
|
||
origX: item.x * wrap.clientWidth, origY: item.y * wh,
|
||
ww: wrap.clientWidth, wh
|
||
};
|
||
}
|
||
|
||
function png2StartRotate(e, id) {
|
||
e.preventDefault(); png2SetActive(id);
|
||
const item = png2GetItem(id);
|
||
const rect = item.el.getBoundingClientRect();
|
||
png2RotDrag = { id, centerX: rect.left + rect.width / 2, centerY: rect.top + rect.height / 2 };
|
||
}
|
||
|
||
function png2StartResize(e, id) {
|
||
e.preventDefault(); png2SetActive(id);
|
||
png2ResizeDrag = { id, startX: e.clientX, startY: e.clientY, origW: png2GetItem(id).w };
|
||
}
|
||
|
||
function png2MouseMove(e) {
|
||
if (png2RotDrag) {
|
||
const { id, centerX, centerY } = png2RotDrag;
|
||
const dx = e.clientX - centerX, dy = e.clientY - centerY;
|
||
png2GetItem(id).rotation = Math.round(Math.atan2(dx, -dy) * 180 / Math.PI);
|
||
png2UpdatePos(id); return;
|
||
}
|
||
if (png2ResizeDrag) {
|
||
const { id, startX, startY, origW } = png2ResizeDrag;
|
||
const wrap = document.getElementById('png2-artboard-wrap');
|
||
const item = png2GetItem(id);
|
||
const ww = wrap.clientWidth;
|
||
const wh = parseInt(document.getElementById('png2-bg-canvas').style.height) || ww;
|
||
const delta = (e.clientX - startX) + (e.clientY - startY);
|
||
const oldPxW = item.w * ww, oldPxH = oldPxW / item.aspect;
|
||
const cx = item.x + item.w / 2, cy = item.y + (oldPxH / wh) / 2;
|
||
item.w = Math.max(0.02, Math.min(3, origW + delta / ww));
|
||
const newPxH = item.w * ww / item.aspect;
|
||
item.x = cx - item.w / 2; item.y = cy - (newPxH / wh) / 2;
|
||
png2UpdatePos(id); return;
|
||
}
|
||
if (png2Drag) {
|
||
const { id, startX, startY, origX, origY, ww, wh } = png2Drag;
|
||
const item = png2GetItem(id);
|
||
item.x = (origX + e.clientX - startX) / ww;
|
||
item.y = (origY + e.clientY - startY) / wh;
|
||
png2UpdatePos(id);
|
||
}
|
||
}
|
||
|
||
function png2MouseUp() { png2Drag = null; png2RotDrag = null; png2ResizeDrag = null; }
|
||
|
||
function png2Scale(id, factor) {
|
||
const item = png2GetItem(id);
|
||
const wrap = document.getElementById('png2-artboard-wrap');
|
||
const ww = wrap.clientWidth;
|
||
const wh = parseInt(document.getElementById('png2-bg-canvas').style.height) || ww;
|
||
const oldPxW = item.w * ww, oldPxH = oldPxW / item.aspect;
|
||
const cx = item.x + item.w / 2, cy = item.y + (oldPxH / wh) / 2;
|
||
item.w = Math.max(0.02, Math.min(3, item.w * factor));
|
||
const newPxH = item.w * ww / item.aspect;
|
||
item.x = cx - item.w / 2; item.y = cy - (newPxH / wh) / 2;
|
||
png2UpdatePos(id);
|
||
}
|
||
|
||
function png2SetRotation(deg) { const id = png2GetActiveId(); if (id != null) { png2GetItem(id).rotation = deg; png2UpdatePos(id); } }
|
||
function png2RotateStep(d) { const id = png2GetActiveId(); if (id != null) { const it = png2GetItem(id); it.rotation = ((it.rotation + d + 180) % 360) - 180; png2UpdatePos(id); } }
|
||
function png2ScaleActive(f) { const id = png2GetActiveId(); if (id != null) png2Scale(id, f); }
|
||
|
||
function png2LayerMove(dir) {
|
||
const id = png2GetActiveId(); if (id == null) return;
|
||
const idx = png2LayerOrder.indexOf(id);
|
||
if (dir === 'up' && idx < png2LayerOrder.length - 1) {
|
||
png2LayerOrder.splice(idx, 1); png2LayerOrder.splice(idx + 1, 0, id);
|
||
} else if (dir === 'down' && idx > 0) {
|
||
png2LayerOrder.splice(idx, 1); png2LayerOrder.splice(idx - 1, 0, id);
|
||
} else return;
|
||
png2ApplyZIndex(); png2UpdateControls(id);
|
||
}
|
||
|
||
function png2RemoveActive() {
|
||
const id = png2GetActiveId(); if (id == null) return;
|
||
const item = png2GetItem(id);
|
||
if (item.el) item.el.remove();
|
||
png2Items = png2Items.filter(it => it.id !== id);
|
||
png2LayerOrder = png2LayerOrder.filter(lid => lid !== id);
|
||
png2UpdateList();
|
||
}
|
||
|
||
async function buildPng2ExportData() {
|
||
if (!png2BgImg) { alert('请先上传底图'); return; }
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = png2NatW; canvas.height = png2NatH;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(png2BgImg, 0, 0, png2NatW, png2NatH);
|
||
|
||
const wrap = document.getElementById('png2-artboard-wrap');
|
||
const dw = wrap.clientWidth;
|
||
const dh = parseInt(document.getElementById('png2-bg-canvas').style.height) || dw;
|
||
const sx = png2NatW / dw, sy = png2NatH / dh;
|
||
|
||
for (const lid of png2LayerOrder) {
|
||
const item = png2GetItem(lid);
|
||
if (!item) continue;
|
||
const img = await new Promise(res => { const i = new Image(); i.onload = () => res(i); i.src = item.blobUrl; });
|
||
const pxW = item.w * dw, pxH = pxW / item.aspect;
|
||
const drawX = item.x * dw * sx, drawY = item.y * dh * sy;
|
||
const drawW = pxW * sx, drawH = pxH * sy;
|
||
const cx = drawX + drawW / 2, cy = drawY + drawH / 2;
|
||
ctx.save();
|
||
ctx.translate(cx, cy);
|
||
ctx.rotate(item.rotation * Math.PI / 180);
|
||
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
|
||
ctx.restore();
|
||
}
|
||
|
||
const exportName = png2BgFileName ? png2BgFileName.replace(/\.[^.]+$/, '_贴图.jpg') : 'png_placement.jpg';
|
||
const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.95));
|
||
return { blob, exportName };
|
||
}
|
||
|
||
async function png2Export() {
|
||
const data = await buildPng2ExportData();
|
||
if (!data) return;
|
||
const { blob, exportName } = data;
|
||
rememberResultObject('png2', blob);
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = exportName;
|
||
a.click();
|
||
showStatus('png2-status', 'success', '✅ 导出完成!');
|
||
}
|
||
|
||
async function png2Apply() {
|
||
const data = await buildPng2ExportData();
|
||
if (!data) return;
|
||
const { blob } = data;
|
||
const objectUrl = rememberResultObject('png2', blob);
|
||
showStatus('png2-status', 'success', '✅ 已生成,可应用到封面');
|
||
postResultToParent(objectUrl, png2ResultMime, 'png-placement.jpg');
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// 通用工具函数
|
||
// ════════════════════════════════════════════
|
||
function fileData(path, name, mime) {
|
||
return { path, orig_name: name, mime_type: mime, meta: { _type: 'gradio.FileData' } };
|
||
}
|
||
|
||
async function uploadBlob(blobOrFile, name) {
|
||
const form = new FormData();
|
||
form.append('files', blobOrFile, name);
|
||
// 30 秒超时:Space 休眠时 fetch 会永久挂起
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), 30000);
|
||
let r;
|
||
try {
|
||
r = await fetch(SPACE + '/gradio_api/upload', { method: 'POST', headers: HEADERS, body: form, signal: ctrl.signal });
|
||
} catch (e) {
|
||
if (e.name === 'AbortError') throw new Error('上传超时(Space 可能正在唤醒),请先打开 ' + SPACE + ' 等待加载后重试');
|
||
throw e;
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
if (!r.ok) throw new Error('上传失败 ' + r.status);
|
||
const data = await r.json();
|
||
return Array.isArray(data) ? data[0] : data;
|
||
}
|
||
|
||
async function callInfer(fn, data) {
|
||
const r = await fetch(SPACE + '/gradio_api/call/' + fn, {
|
||
method: 'POST',
|
||
headers: { ...HEADERS, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ data }),
|
||
});
|
||
if (!r.ok) {
|
||
const body = await r.text().catch(() => '');
|
||
throw new Error('请求失败 ' + r.status + (body ? ': ' + body.slice(0, 200) : ''));
|
||
}
|
||
return r.json();
|
||
}
|
||
|
||
async function pollResult(eventId, fn, statusEl) {
|
||
const deadline = Date.now() + 300000; // 5分钟总超时
|
||
|
||
const tryConnect = async () => {
|
||
const r = await fetch(SPACE + '/gradio_api/call/' + fn + '/' + eventId, { headers: HEADERS });
|
||
if (!r.ok) throw new Error('轮询失败 ' + r.status);
|
||
const reader = r.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buf = '';
|
||
|
||
try {
|
||
while (true) {
|
||
if (Date.now() > deadline) throw new Error('TIMEOUT');
|
||
const { done, value } = await reader.read();
|
||
if (value) buf += decoder.decode(value, { stream: !done });
|
||
const lines = buf.split('\n');
|
||
buf = done ? '' : (lines.pop() || '');
|
||
|
||
let isError = false;
|
||
for (const line of lines) {
|
||
if (line.startsWith('event:')) {
|
||
if (line.includes('error')) isError = true;
|
||
continue;
|
||
}
|
||
if (!line.startsWith('data:')) continue;
|
||
const raw = line.slice(5).trim();
|
||
if (!raw || raw === 'null') {
|
||
if (isError) throw new Error('Space 处理出错(event: error),请重试');
|
||
isError = false;
|
||
continue;
|
||
}
|
||
isError = false;
|
||
let d; try { d = JSON.parse(raw); } catch { continue; }
|
||
|
||
// 显示队列/进度状态
|
||
if (d && d.msg === 'estimation' && statusEl) {
|
||
const rank = d.rank != null ? `排队第 ${d.rank} 位…` : '排队中…';
|
||
if (statusEl.textContent !== undefined) statusEl.textContent = '⏳ ' + rank;
|
||
}
|
||
|
||
// 提取结果
|
||
const arr = Array.isArray(d) ? d
|
||
: (d?.data && Array.isArray(d.data) ? d.data
|
||
: (d?.output?.data && Array.isArray(d.output.data) ? d.output.data : null));
|
||
if (arr && arr.length > 0 && arr[0] !== null) return arr;
|
||
|
||
// 错误
|
||
const errMsg = d?.error || (d?.msg === 'process_completed' && !d.success && (d.output?.error || '推理失败'));
|
||
if (errMsg && errMsg !== false) throw new Error(String(errMsg));
|
||
}
|
||
|
||
if (done) return null; // 流正常结束但无结果(需重连)
|
||
}
|
||
} finally {
|
||
try { reader.cancel(); } catch { }
|
||
}
|
||
};
|
||
|
||
for (let attempt = 0; attempt < 10; attempt++) {
|
||
if (Date.now() > deadline) throw new Error('超时(5分钟),请重试');
|
||
try {
|
||
const result = await tryConnect();
|
||
if (result) return result;
|
||
// 流关闭无结果 → 短暂等待后重连
|
||
await new Promise(r => setTimeout(r, 800 + attempt * 400));
|
||
} catch (e) {
|
||
if (e.message === 'TIMEOUT') throw new Error('超时(5分钟),请重试');
|
||
if (attempt >= 9) throw e;
|
||
await new Promise(r => setTimeout(r, 1500));
|
||
}
|
||
}
|
||
throw new Error('连接多次中断,请重试');
|
||
}
|
||
|
||
async function showOutputImage(prefix, imgInfo) {
|
||
const path = imgInfo.path || imgInfo.url || '';
|
||
const url = path.startsWith('http') ? path : SPACE + '/gradio_api/file=' + path;
|
||
const blob = await (await fetch(url, { headers: HEADERS })).blob();
|
||
const obj = rememberResultObject(prefix, blob);
|
||
document.getElementById(prefix + '-out-img').src = obj;
|
||
document.getElementById(prefix + '-dl').href = obj;
|
||
showResult(prefix, true);
|
||
}
|
||
|
||
// ─── Color Match(Reinhard RGB 均值/标准差迁移)────────────
|
||
function computeChannelStats(data, n) {
|
||
const mean = [0, 0, 0];
|
||
for (let i = 0; i < n; i++) {
|
||
mean[0] += data[i * 4];
|
||
mean[1] += data[i * 4 + 1];
|
||
mean[2] += data[i * 4 + 2];
|
||
}
|
||
mean[0] /= n; mean[1] /= n; mean[2] /= n;
|
||
const std = [0, 0, 0];
|
||
for (let i = 0; i < n; i++) {
|
||
std[0] += (data[i * 4] - mean[0]) ** 2;
|
||
std[1] += (data[i * 4 + 1] - mean[1]) ** 2;
|
||
std[2] += (data[i * 4 + 2] - mean[2]) ** 2;
|
||
}
|
||
std[0] = Math.sqrt(std[0] / n);
|
||
std[1] = Math.sqrt(std[1] / n);
|
||
std[2] = Math.sqrt(std[2] / n);
|
||
return { mean, std };
|
||
}
|
||
|
||
function applyColorMatch(srcImg, resultCanvas) {
|
||
// 把原图缩到和结果图一样大小,再采样统计
|
||
const w = resultCanvas.width, h = resultCanvas.height;
|
||
const tmpSrc = document.createElement('canvas');
|
||
tmpSrc.width = w; tmpSrc.height = h;
|
||
tmpSrc.getContext('2d').drawImage(srcImg, 0, 0, w, h);
|
||
const srcData = tmpSrc.getContext('2d').getImageData(0, 0, w, h).data;
|
||
const srcStats = computeChannelStats(srcData, w * h);
|
||
|
||
const resCtx = resultCanvas.getContext('2d');
|
||
const resImgData = resCtx.getImageData(0, 0, w, h);
|
||
const px = resImgData.data;
|
||
const resStats = computeChannelStats(px, w * h);
|
||
|
||
for (let i = 0; i < w * h; i++) {
|
||
for (let c = 0; c < 3; c++) {
|
||
const ratio = resStats.std[c] > 0.5 ? srcStats.std[c] / resStats.std[c] : 1;
|
||
let v = (px[i * 4 + c] - resStats.mean[c]) * ratio + srcStats.mean[c];
|
||
px[i * 4 + c] = Math.max(0, Math.min(255, Math.round(v)));
|
||
}
|
||
}
|
||
resCtx.putImageData(resImgData, 0, 0);
|
||
}
|
||
|
||
async function showOutputImageColorMatched(prefix, imgInfo, srcImg) {
|
||
const path = imgInfo.path || imgInfo.url || '';
|
||
const url = path.startsWith('http') ? path : SPACE + '/gradio_api/file=' + path;
|
||
const blob = await (await fetch(url, { headers: HEADERS })).blob();
|
||
|
||
const resultImg = await new Promise((res, rej) => {
|
||
const img = new Image();
|
||
img.onload = () => res(img);
|
||
img.onerror = rej;
|
||
img.src = URL.createObjectURL(blob);
|
||
});
|
||
|
||
const resultCanvas = document.createElement('canvas');
|
||
resultCanvas.width = resultImg.naturalWidth;
|
||
resultCanvas.height = resultImg.naturalHeight;
|
||
resultCanvas.getContext('2d').drawImage(resultImg, 0, 0);
|
||
|
||
applyColorMatch(srcImg, resultCanvas);
|
||
|
||
const matched = await new Promise(r => resultCanvas.toBlob(r, 'image/png'));
|
||
const obj = URL.createObjectURL(matched);
|
||
document.getElementById(prefix + '-out-img').src = obj;
|
||
document.getElementById(prefix + '-dl').href = obj;
|
||
showResult(prefix, true);
|
||
}
|
||
|
||
function showResult(prefix, show) {
|
||
document.getElementById(prefix + '-placeholder').style.display = show ? 'none' : 'flex';
|
||
document.getElementById(prefix + '-output').style.display = show ? 'block' : 'none';
|
||
}
|
||
|
||
function showStatus(id, cls, msg) {
|
||
const el = document.getElementById(id);
|
||
el.className = 'status-box s-' + cls + (cls === 'running' ? ' pulsing' : '');
|
||
el.style.display = 'block';
|
||
el.textContent = msg;
|
||
}
|
||
|
||
// ─── 取消函数 ────────────────────────────────────
|
||
function editRemoveImage() {
|
||
editBgImage = null;
|
||
editDrawOffscreen = null;
|
||
editDrawing = false;
|
||
const canvas = document.getElementById('edit-canvas');
|
||
const wrap = document.getElementById('edit-canvas-wrap');
|
||
canvas.style.display = 'none';
|
||
canvas.width = 0; canvas.height = 0;
|
||
wrap.querySelector('.empty-hint').style.display = '';
|
||
wrap.classList.remove('has-image');
|
||
document.getElementById('edit-brush-bar').style.display = 'none';
|
||
document.getElementById('edit-clear-btn').style.display = 'none';
|
||
document.getElementById('edit-file').value = '';
|
||
}
|
||
|
||
function refClear() {
|
||
refFile = null;
|
||
const zone = document.getElementById('ref-zone');
|
||
zone.classList.remove('filled');
|
||
zone.innerHTML = '📎 点击或拖拽参考图';
|
||
document.getElementById('ref-file').value = '';
|
||
document.getElementById('ref-cancel').style.display = 'none';
|
||
}
|
||
|
||
function clearResult(prefix) {
|
||
const img = document.getElementById(prefix + '-out-img');
|
||
if (img.src) revokeObjectUrlSafe(img.src);
|
||
img.src = '';
|
||
const dl = document.getElementById(prefix + '-dl');
|
||
if (dl.href) revokeObjectUrlSafe(dl.href);
|
||
dl.href = '';
|
||
if (prefix === 'edit') {
|
||
editResultObjectUrl = '';
|
||
editResultMime = 'image/png';
|
||
}
|
||
showResult(prefix, false);
|
||
const status = document.getElementById(prefix + '-status');
|
||
if (status) status.style.display = 'none';
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// 封面合成
|
||
// ════════════════════════════════════════════
|
||
// 每个面板独立配置 dataset repo、根路径、输出分辨率
|
||
const COVER_PANELS = [
|
||
{ id: 'main', label: '主标题', repo: 'jing96963/zhubiao', subfolder: '', outW: 1600, outH: 400 },
|
||
{ id: 'sub', label: '副标题', repo: 'jing96963/fubiao', subfolder: '', outW: 1000, outH: 400 },
|
||
{ id: 'small', label: '小标题', repo: 'jing96963/xiaobiao', subfolder: '', outW: 1000, outH: 400 },
|
||
];
|
||
|
||
let coverBgImg = null;
|
||
let coverNatW = 1920, coverNatH = 1080;
|
||
let coverInited = false;
|
||
let coverBgFileName = ''; // 原封面文件名
|
||
|
||
// 图层顺序(从底到顶),初始按 panel 定义顺序
|
||
let artLayerOrder = COVER_PANELS.map(p => p.id); // ['main', 'sub', 'small']
|
||
|
||
// per-panel state
|
||
const cps = {};
|
||
COVER_PANELS.forEach(({ id, outW, outH }) => {
|
||
cps[id] = {
|
||
files: [], blobCache: {}, selected: null, selBlob: null,
|
||
rmbgBlob: null,
|
||
outW, outH,
|
||
artX: 0.1, artY: 0.1, artW: 0.8, artAspect: 4,
|
||
artRotation: 0,
|
||
artEl: null, placed: false,
|
||
history: [],
|
||
};
|
||
});
|
||
|
||
let artDrag = null;
|
||
let artRotDrag = null; // 旋转拖拽状态
|
||
let artResizeDrag = null; // 缩放拖拽状态
|
||
|
||
// ── 初始化(Tab 首次打开时调用一次)─────────────────
|
||
function coverInit() {
|
||
if (coverInited) return;
|
||
coverInited = true;
|
||
buildCoverPanels();
|
||
COVER_PANELS.forEach(p => coverLoadFolder(p));
|
||
}
|
||
|
||
function buildCoverPanels() {
|
||
const wrap = document.getElementById('cover-panels-wrap');
|
||
wrap.innerHTML = COVER_PANELS.map(({ id, label }) => `
|
||
<div class="card" style="margin-top:0">
|
||
<div class="cover-section-header" onclick="coverToggle('${id}')">
|
||
<div class="card-title" style="margin-bottom:0">${label}</div>
|
||
<span class="badge" id="badge-${id}">未放置</span>
|
||
</div>
|
||
<div id="cover-body-${id}">
|
||
<div class="cover-grid" id="cover-grid-${id}">
|
||
<div style="color:#94a3b8;font-size:12px;padding:8px;grid-column:1/-1">加载中…</div>
|
||
</div>
|
||
<div class="cover-sel-area" id="cover-sel-area-${id}" style="display:none">
|
||
<img id="cover-sel-img-${id}" style="max-height:100px;object-fit:contain">
|
||
<div class="field">
|
||
<label>替换文字(直接输入新内容)</label>
|
||
<textarea id="cover-prompt-${id}" rows="2"
|
||
placeholder='输入要替换成的文字,如:超高马尾性感御姐'></textarea>
|
||
</div>
|
||
<button class="run-btn" id="cover-gen-btn-${id}" style="margin-top:10px"
|
||
onclick="coverGenerate('${id}')">生成</button>
|
||
<div class="status-box" id="cover-status-${id}"></div>
|
||
</div>
|
||
<div class="cover-result-area" id="cover-result-area-${id}" style="display:none">
|
||
<div style="font-size:12px;color:#64748b;margin-bottom:6px">抠图结果预览</div>
|
||
<img id="cover-result-img-${id}">
|
||
<button class="cover-confirm-btn" id="cover-confirm-btn-${id}"
|
||
onclick="coverConfirm('${id}')">✓ 确认并放到画板</button>
|
||
</div>
|
||
<div class="cover-history-area" id="cover-history-${id}" style="display:none">
|
||
<div class="cover-history-header">
|
||
<div style="font-size:12px;color:#64748b;font-weight:600">历史生成记录</div>
|
||
<button onclick="coverClearHistory('${id}')" style="font-size:11px;color:#ef4444;border:none;background:none;cursor:pointer;padding:2px 6px">🗑 清空</button>
|
||
</div>
|
||
<div id="cover-history-list-${id}"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function coverToggle(id) {
|
||
const body = document.getElementById(`cover-body-${id}`);
|
||
body.style.display = body.style.display === 'none' ? '' : 'none';
|
||
}
|
||
|
||
// ── 加载 HF Dataset 文件夹 ────────────────────────
|
||
async function coverLoadFolder({ id, repo, subfolder }) {
|
||
const token = (document.getElementById('global-token').value || '').trim() || DEFAULT_TOKEN;
|
||
const treePath = subfolder ? encodeURIComponent(subfolder) : '';
|
||
const url = `https://huggingface.co/api/datasets/${repo}/tree/main` + (treePath ? '/' + treePath : '');
|
||
const grid = document.getElementById(`cover-grid-${id}`);
|
||
try {
|
||
const r = await fetch(url, { headers: { Authorization: 'Bearer ' + token } });
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
const files = (await r.json()).filter(f =>
|
||
f.type === 'file' && /\.(png|jpg|jpeg|webp)$/i.test(f.path)
|
||
);
|
||
cps[id].files = files;
|
||
cps[id].repo = repo;
|
||
if (!files.length) { grid.innerHTML = '<div style="color:#94a3b8;font-size:12px;padding:8px;grid-column:1/-1">暂无素材</div>'; return; }
|
||
grid.innerHTML = files.map((f, i) =>
|
||
`<div class="cover-grid-item" id="cgi-${id}-${i}" onclick="coverSelectFile('${id}',${i})">
|
||
<img id="cgimg-${id}-${i}" src="" alt="">
|
||
</div>`
|
||
).join('');
|
||
// Lazy load thumbnails in batches
|
||
for (let i = 0; i < files.length; i++) {
|
||
coverFetchThumb(id, i, files[i].path, repo, token);
|
||
if (i % 3 === 2) await new Promise(r => setTimeout(r, 50));
|
||
}
|
||
} catch (e) {
|
||
grid.innerHTML = `<div style="color:#ef4444;font-size:12px;padding:8px;grid-column:1/-1">加载失败: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async function coverFetchThumb(id, idx, path, repo, token) {
|
||
if (cps[id].blobCache[path]) {
|
||
const img = document.getElementById(`cgimg-${id}-${idx}`);
|
||
if (img) img.src = cps[id].blobCache[path];
|
||
return;
|
||
}
|
||
try {
|
||
const base = `https://huggingface.co/datasets/${repo}/resolve/main/`;
|
||
const r = await fetch(base + path, { headers: { Authorization: 'Bearer ' + token } });
|
||
if (!r.ok) return;
|
||
const url = URL.createObjectURL(await r.blob());
|
||
cps[id].blobCache[path] = url;
|
||
const img = document.getElementById(`cgimg-${id}-${idx}`);
|
||
if (img) img.src = url;
|
||
} catch (e) { }
|
||
}
|
||
|
||
async function coverSelectFile(id, idx) {
|
||
const file = cps[id].files[idx];
|
||
document.querySelectorAll(`#cover-grid-${id} .cover-grid-item`).forEach(el => el.classList.remove('selected'));
|
||
document.getElementById(`cgi-${id}-${idx}`).classList.add('selected');
|
||
cps[id].selected = file.path;
|
||
|
||
const token = (document.getElementById('global-token').value || '').trim() || DEFAULT_TOKEN;
|
||
let blobUrl = cps[id].blobCache[file.path];
|
||
if (!blobUrl) {
|
||
const repo = cps[id].repo;
|
||
const r = await fetch(`https://huggingface.co/datasets/${repo}/resolve/main/${file.path}`, { headers: { Authorization: 'Bearer ' + token } });
|
||
blobUrl = URL.createObjectURL(await r.blob());
|
||
cps[id].blobCache[file.path] = blobUrl;
|
||
}
|
||
cps[id].selBlob = blobUrl;
|
||
document.getElementById(`cover-sel-img-${id}`).src = blobUrl;
|
||
document.getElementById(`cover-sel-area-${id}`).style.display = 'block';
|
||
document.getElementById(`cover-result-area-${id}`).style.display = 'none';
|
||
}
|
||
|
||
// ── Canvas resize 工具(letterbox contain,不拉伸)──
|
||
function resizeBlobToSize(blob, w, h) {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
const url = URL.createObjectURL(blob);
|
||
img.onload = () => {
|
||
const c = document.createElement('canvas');
|
||
c.width = w; c.height = h;
|
||
const ctx = c.getContext('2d');
|
||
ctx.fillStyle = '#000000';
|
||
ctx.fillRect(0, 0, w, h);
|
||
// contain:等比缩放,居中,四周补黑边
|
||
const scale = Math.min(w / img.naturalWidth, h / img.naturalHeight);
|
||
const dw = Math.round(img.naturalWidth * scale);
|
||
const dh = Math.round(img.naturalHeight * scale);
|
||
const dx = Math.round((w - dw) / 2);
|
||
const dy = Math.round((h - dh) / 2);
|
||
ctx.drawImage(img, dx, dy, dw, dh);
|
||
URL.revokeObjectURL(url);
|
||
c.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
|
||
};
|
||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('image load failed')); };
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
// ── 客户端抠图(泛洪填充去背景,保留内容色)────────
|
||
function clientRemoveBg(blob, threshold = 20) {
|
||
return new Promise((resolve) => {
|
||
const img = new Image();
|
||
const url = URL.createObjectURL(blob);
|
||
img.onload = () => {
|
||
URL.revokeObjectURL(url);
|
||
const w = img.naturalWidth, h = img.naturalHeight;
|
||
const c = document.createElement('canvas');
|
||
c.width = w; c.height = h;
|
||
const ctx = c.getContext('2d');
|
||
ctx.drawImage(img, 0, 0);
|
||
const imageData = ctx.getImageData(0, 0, w, h);
|
||
const d = imageData.data;
|
||
|
||
// 从 4 角各取 5×5 区域采样背景色均值
|
||
function sampleCorner(sx, sy) {
|
||
let r = 0, g = 0, b = 0, n = 0;
|
||
for (let dy = 0; dy < 5; dy++) {
|
||
for (let dx = 0; dx < 5; dx++) {
|
||
const cx = Math.min(w - 1, sx + dx);
|
||
const cy = Math.min(h - 1, sy + dy);
|
||
const i = (cy * w + cx) * 4;
|
||
r += d[i]; g += d[i + 1]; b += d[i + 2]; n++;
|
||
}
|
||
}
|
||
return [r / n, g / n, b / n];
|
||
}
|
||
const corners = [
|
||
sampleCorner(0, 0), sampleCorner(w - 5, 0),
|
||
sampleCorner(0, h - 5), sampleCorner(w - 5, h - 5),
|
||
];
|
||
const bgR = corners.reduce((s, c) => s + c[0], 0) / 4;
|
||
const bgG = corners.reduce((s, c) => s + c[1], 0) / 4;
|
||
const bgB = corners.reduce((s, c) => s + c[2], 0) / 4;
|
||
|
||
function dist(i) {
|
||
const dr = d[i] - bgR, dg = d[i + 1] - bgG, db = d[i + 2] - bgB;
|
||
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||
}
|
||
|
||
// BFS 泛洪:从四条边出发,仅标记与背景相近且连通的像素
|
||
const visited = new Uint8Array(w * h);
|
||
const queue = new Int32Array(w * h);
|
||
let head = 0, tail = 0;
|
||
|
||
function seed(idx) {
|
||
if (!visited[idx] && dist(idx * 4) < threshold) {
|
||
visited[idx] = 1;
|
||
queue[tail++] = idx;
|
||
}
|
||
}
|
||
for (let x = 0; x < w; x++) { seed(x); seed((h - 1) * w + x); }
|
||
for (let y = 0; y < h; y++) { seed(y * w); seed(y * w + w - 1); }
|
||
|
||
while (head < tail) {
|
||
const idx = queue[head++];
|
||
const px = idx % w, py = (idx / w) | 0;
|
||
const neighbors = [
|
||
py > 0 ? idx - w : -1,
|
||
py < h - 1 ? idx + w : -1,
|
||
px > 0 ? idx - 1 : -1,
|
||
px < w - 1 ? idx + 1 : -1,
|
||
];
|
||
for (const ni of neighbors) {
|
||
if (ni >= 0 && !visited[ni] && dist(ni * 4) < threshold) {
|
||
visited[ni] = 1;
|
||
queue[tail++] = ni;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 将泛洪标记的像素透明度置 0
|
||
for (let i = 0; i < w * h; i++) {
|
||
if (visited[i]) d[i * 4 + 3] = 0;
|
||
}
|
||
ctx.putImageData(imageData, 0, 0);
|
||
c.toBlob(b => resolve(b || blob), 'image/png');
|
||
};
|
||
img.onerror = () => { URL.revokeObjectURL(url); resolve(blob); };
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
// ── 历史记录 ────────────────────────────────────────
|
||
function renderCoverHistory(id) {
|
||
const state = cps[id];
|
||
const area = document.getElementById(`cover-history-${id}`);
|
||
const list = document.getElementById(`cover-history-list-${id}`);
|
||
if (!state.history.length) { area.style.display = 'none'; return; }
|
||
area.style.display = 'block';
|
||
list.innerHTML = state.history.map((item, idx) => `
|
||
<div class="cover-history-item">
|
||
<img class="cover-history-thumb" src="${item.blobUrl}">
|
||
<div style="flex:1;min-width:0;overflow:hidden">
|
||
<div style="font-size:11px;color:#475569;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${item.prompt || '(无描述)'}</div>
|
||
<div style="font-size:10px;color:#94a3b8;margin-top:2px">${new Date(item.time).toLocaleTimeString()}</div>
|
||
</div>
|
||
<button class="cover-history-place-btn" onclick="coverPlaceFromHistory('${id}', ${idx})">放置</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function coverClearHistory(id) {
|
||
cps[id].history = [];
|
||
renderCoverHistory(id);
|
||
}
|
||
|
||
function coverPlaceFromHistory(id, idx) {
|
||
const state = cps[id];
|
||
const item = state.history[idx];
|
||
if (!item) return;
|
||
state.rmbgBlob = item.blobUrl;
|
||
document.getElementById(`cover-result-img-${id}`).src = item.blobUrl;
|
||
document.getElementById(`cover-result-area-${id}`).style.display = 'block';
|
||
coverConfirm(id);
|
||
}
|
||
|
||
// ── 生成(AI 编辑 → 客户端抠图)────────────────────
|
||
async function coverGenerate(id) {
|
||
const state = cps[id];
|
||
if (!state.selBlob) { alert('请先选择素材图'); return; }
|
||
const promptRaw = document.getElementById(`cover-prompt-${id}`).value.trim();
|
||
if (!promptRaw) { alert('请输入要替换的文字内容'); return; }
|
||
// 自动套 ComfyUI 同款格式,单行输出
|
||
const prompt = `将文字改成"${promptRaw}",一行文字,其余保持不变`;
|
||
|
||
const btn = document.getElementById(`cover-gen-btn-${id}`);
|
||
btn.disabled = true;
|
||
document.getElementById(`cover-result-area-${id}`).style.display = 'none';
|
||
showStatus(`cover-status-${id}`, 'running', '⏳ 上传素材…');
|
||
|
||
try {
|
||
// Step 1: AI 文字替换
|
||
const srcResp = await fetch(state.selBlob);
|
||
let srcBlob = await srcResp.blob();
|
||
// 若面板指定了输出尺寸,先 resize 到目标分辨率
|
||
if (state.outW && state.outH) {
|
||
srcBlob = await resizeBlobToSize(srcBlob, state.outW, state.outH);
|
||
}
|
||
const srcPath = await uploadBlob(srcBlob, 'source.png');
|
||
const srcEditor = {
|
||
background: fileData(srcPath, 'source.png', 'image/png'),
|
||
layers: [], composite: fileData(srcPath, 'source.png', 'image/png'),
|
||
};
|
||
showStatus(`cover-status-${id}`, 'running', '⏳ AI 生成中…');
|
||
const editResp = await callInfer('infer', [srcEditor, null, prompt, '', 0, true, 1.0, 4, false, state.outW || 0, state.outH || 0]);
|
||
const statusEl = document.getElementById(`cover-status-${id}`);
|
||
const editResult = await pollResult(editResp.event_id, 'infer', statusEl);
|
||
const editPath = editResult[0].path || editResult[0].url || '';
|
||
const editUrl = editPath.startsWith('http') ? editPath : SPACE + '/gradio_api/file=' + editPath;
|
||
const editBlob = await (await fetch(editUrl, { headers: HEADERS })).blob();
|
||
|
||
// Step 2: 服务端 RMBG-2.0 抠图(语义分割,精确保留内容)
|
||
showStatus(`cover-status-${id}`, 'running', '⏳ RMBG 抠图中…');
|
||
const rmbgUploadPath = await uploadBlob(editBlob, 'edited.png');
|
||
const rmbgResp = await callInfer('rmbg', [fileData(rmbgUploadPath, 'edited.png', 'image/png')]);
|
||
const rmbgResult = await pollResult(rmbgResp.event_id, 'rmbg', statusEl);
|
||
const rmbgImgPath = rmbgResult[0].path || rmbgResult[0].url || '';
|
||
const rmbgFetchUrl = rmbgImgPath.startsWith('http') ? rmbgImgPath : SPACE + '/gradio_api/file=' + rmbgImgPath;
|
||
const rmbgBlob = await (await fetch(rmbgFetchUrl, { headers: HEADERS })).blob();
|
||
state.rmbgBlob = URL.createObjectURL(rmbgBlob);
|
||
|
||
// 记录到历史
|
||
const promptLabel = document.getElementById(`cover-prompt-${id}`).value.trim();
|
||
state.history.unshift({ blobUrl: state.rmbgBlob, prompt: promptLabel, time: Date.now() });
|
||
renderCoverHistory(id);
|
||
|
||
document.getElementById(`cover-result-img-${id}`).src = state.rmbgBlob;
|
||
document.getElementById(`cover-result-area-${id}`).style.display = 'block';
|
||
showStatus(`cover-status-${id}`, 'success', '✅ 完成,请确认放置');
|
||
} catch (e) {
|
||
showStatus(`cover-status-${id}`, 'error', '❌ ' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// ── 确认放到画板 ──────────────────────────────────
|
||
function coverConfirm(id) {
|
||
const state = cps[id];
|
||
if (!state.rmbgBlob) return;
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
state.artAspect = img.naturalWidth / img.naturalHeight;
|
||
const wrap = document.getElementById('cover-artboard-wrap');
|
||
const artW = wrap.clientWidth;
|
||
const artH = wrap.clientHeight || artW * (coverNatH / coverNatW);
|
||
// Default position: centered, width 60% of artboard
|
||
state.artW = 0.6;
|
||
state.artX = 0.2;
|
||
state.artY = 0.5 - (state.artW * artW) / state.artAspect / 2 / artH;
|
||
renderArtItem(id);
|
||
state.placed = true;
|
||
document.getElementById(`badge-${id}`).textContent = '已放置';
|
||
document.getElementById(`badge-${id}`).className = 'badge done';
|
||
document.getElementById(`cover-confirm-btn-${id}`).textContent = '✓ 已放置(点击更新)';
|
||
};
|
||
img.src = state.rmbgBlob;
|
||
}
|
||
|
||
// ── 画板渲染 ──────────────────────────────────────
|
||
function coverLoadBg(file) {
|
||
if (!file) return;
|
||
// 保存原文件名(去掉扩展名,导出时用)
|
||
coverBgFileName = file.name;
|
||
const url = URL.createObjectURL(file);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
coverBgImg = img;
|
||
coverNatW = img.naturalWidth;
|
||
coverNatH = img.naturalHeight;
|
||
const zone = document.getElementById('cover-bg-zone');
|
||
zone.classList.add('filled');
|
||
zone.innerHTML = `<img src="${url}" style="max-height:120px;width:100%;object-fit:contain">`;
|
||
document.getElementById('cover-bg-clear').style.display = 'flex';
|
||
renderArtboardBg();
|
||
};
|
||
img.src = url;
|
||
}
|
||
|
||
function coverClearBg() {
|
||
coverBgImg = null;
|
||
const zone = document.getElementById('cover-bg-zone');
|
||
zone.classList.remove('filled');
|
||
zone.innerHTML = '📷 点击或拖拽上传底图';
|
||
document.getElementById('cover-bg-clear').style.display = 'none';
|
||
document.getElementById('cover-bg-file').value = '';
|
||
const canvas = document.getElementById('cover-bg-canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
}
|
||
|
||
function renderArtboardBg() {
|
||
if (!coverBgImg) return;
|
||
const wrap = document.getElementById('cover-artboard-wrap');
|
||
const canvas = document.getElementById('cover-bg-canvas');
|
||
const displayW = wrap.clientWidth;
|
||
const displayH = Math.round(displayW * coverNatH / coverNatW);
|
||
canvas.width = displayW;
|
||
canvas.height = displayH;
|
||
canvas.style.height = displayH + 'px';
|
||
canvas.getContext('2d').drawImage(coverBgImg, 0, 0, displayW, displayH);
|
||
// Re-position existing art items
|
||
COVER_PANELS.forEach(p => { if (cps[p.id].placed) renderArtItem(p.id); });
|
||
}
|
||
|
||
function renderArtItem(id) {
|
||
const state = cps[id];
|
||
const wrap = document.getElementById('cover-artboard-wrap');
|
||
const artW = wrap.clientWidth;
|
||
const artH = parseInt(document.getElementById('cover-bg-canvas').style.height) || artW * coverNatH / coverNatW;
|
||
|
||
let el = document.getElementById(`art-item-${id}`);
|
||
if (!el) {
|
||
el = document.createElement('div');
|
||
el.id = `art-item-${id}`;
|
||
el.className = 'artboard-item';
|
||
const img = document.createElement('img');
|
||
img.src = state.rmbgBlob;
|
||
img.draggable = false;
|
||
el.appendChild(img);
|
||
|
||
// 旋转手柄(顶部中间圆点)
|
||
const rotHandle = document.createElement('div');
|
||
rotHandle.className = 'art-rotate-handle';
|
||
rotHandle.addEventListener('mousedown', e => { e.stopPropagation(); artStartRotate(e, id); });
|
||
el.appendChild(rotHandle);
|
||
|
||
// 四角缩放手柄
|
||
['nw', 'ne', 'sw', 'se'].forEach(corner => {
|
||
const rh = document.createElement('div');
|
||
rh.className = `art-resize-handle ${corner}`;
|
||
rh.addEventListener('mousedown', e => { e.stopPropagation(); artStartResize(e, id, corner); });
|
||
el.appendChild(rh);
|
||
});
|
||
|
||
el.addEventListener('mousedown', e => {
|
||
if (e.target.classList.contains('art-rotate-handle') || e.target.classList.contains('art-resize-handle')) return;
|
||
artStartDrag(e, id);
|
||
});
|
||
el.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
if (e.shiftKey) {
|
||
cps[id].artRotation += (e.deltaY < 0 ? 5 : -5);
|
||
cps[id].artRotation = ((cps[id].artRotation + 180) % 360) - 180;
|
||
updateArtPos(id);
|
||
} else {
|
||
artScale(id, e.deltaY < 0 ? 1.06 : 0.94);
|
||
}
|
||
}, { passive: false });
|
||
el.addEventListener('click', e => { e.stopPropagation(); artSetActive(id); });
|
||
wrap.appendChild(el);
|
||
state.artEl = el;
|
||
} else {
|
||
el.querySelector('img').src = state.rmbgBlob;
|
||
}
|
||
artSetActive(id);
|
||
updateArtPos(id);
|
||
}
|
||
|
||
function updateArtPos(id) {
|
||
const state = cps[id];
|
||
if (!state.artEl) return;
|
||
const wrap = document.getElementById('cover-artboard-wrap');
|
||
const artW = wrap.clientWidth;
|
||
const artH = parseInt(document.getElementById('cover-bg-canvas').style.height) || artW;
|
||
|
||
const pxW = state.artW * artW;
|
||
const pxH = pxW / state.artAspect;
|
||
const pxX = state.artX * artW;
|
||
const pxY = state.artY * artH;
|
||
|
||
state.artEl.style.left = pxX + 'px';
|
||
state.artEl.style.top = pxY + 'px';
|
||
state.artEl.style.width = pxW + 'px';
|
||
state.artEl.querySelector('img').style.height = pxH + 'px';
|
||
state.artEl.style.transform = `rotate(${state.artRotation}deg)`;
|
||
|
||
// 更新控制栏显示
|
||
updateArtControls(id);
|
||
}
|
||
|
||
function updateArtControls(id) {
|
||
const state = cps[id];
|
||
document.getElementById('art-rotation-slider').value = state.artRotation;
|
||
document.getElementById('art-rotation-val').textContent = Math.round(state.artRotation) + '°';
|
||
const pct = Math.round(state.artW * 100 / 0.6);
|
||
document.getElementById('art-scale-val').textContent = pct + '%';
|
||
// 图层位置
|
||
const idx = artLayerOrder.indexOf(id);
|
||
const label = COVER_PANELS.find(p => p.id === id)?.label || id;
|
||
document.getElementById('art-layer-val').textContent = `${label} (${idx + 1}/${artLayerOrder.length})`;
|
||
}
|
||
|
||
function artSetActive(id) {
|
||
document.querySelectorAll('.artboard-item').forEach(el => el.classList.remove('art-active'));
|
||
if (cps[id].artEl) cps[id].artEl.classList.add('art-active');
|
||
applyLayerZIndex();
|
||
updateArtControls(id);
|
||
}
|
||
|
||
function applyLayerZIndex() {
|
||
artLayerOrder.forEach((lid, i) => {
|
||
if (cps[lid].artEl) cps[lid].artEl.style.zIndex = i + 1;
|
||
});
|
||
// 选中项额外 +50 保证操作时在最上面但不破坏相对顺序
|
||
const activeId = getActiveId();
|
||
// 不再强制 z-index,让层级真实反映
|
||
}
|
||
|
||
function artStartDrag(e, id) {
|
||
e.preventDefault();
|
||
artSetActive(id);
|
||
const wrap = document.getElementById('cover-artboard-wrap');
|
||
artDrag = {
|
||
id,
|
||
startX: e.clientX, startY: e.clientY,
|
||
origX: cps[id].artX * wrap.clientWidth,
|
||
origY: cps[id].artY * (parseInt(document.getElementById('cover-bg-canvas').style.height) || wrap.clientWidth),
|
||
artW: wrap.clientWidth,
|
||
artH: parseInt(document.getElementById('cover-bg-canvas').style.height) || wrap.clientWidth,
|
||
};
|
||
}
|
||
|
||
function artMouseMove(e) {
|
||
// 旋转拖拽
|
||
if (artRotDrag) {
|
||
const { id, centerX, centerY } = artRotDrag;
|
||
const dx = e.clientX - centerX;
|
||
const dy = e.clientY - centerY;
|
||
let angle = Math.atan2(dx, -dy) * 180 / Math.PI; // 0°=上方
|
||
cps[id].artRotation = Math.round(angle);
|
||
updateArtPos(id);
|
||
return;
|
||
}
|
||
// 缩放拖拽
|
||
if (artResizeDrag) {
|
||
const { id, startX, startY, origW } = artResizeDrag;
|
||
const wrap = document.getElementById('cover-artboard-wrap');
|
||
const artWPx = wrap.clientWidth;
|
||
const delta = (e.clientX - startX) + (e.clientY - startY);
|
||
const newW = Math.max(0.04, origW + delta / artWPx);
|
||
const state = cps[id];
|
||
// 从中心缩放
|
||
const artH = parseInt(document.getElementById('cover-bg-canvas').style.height) || artWPx;
|
||
const oldPxW = state.artW * artWPx;
|
||
const oldPxH = oldPxW / state.artAspect;
|
||
const cx = state.artX + state.artW / 2;
|
||
const cy = state.artY + (oldPxH / artH) / 2;
|
||
state.artW = Math.min(2, newW);
|
||
const newPxH = state.artW * artWPx / state.artAspect;
|
||
state.artX = cx - state.artW / 2;
|
||
state.artY = cy - (newPxH / artH) / 2;
|
||
updateArtPos(id);
|
||
return;
|
||
}
|
||
// 移动拖拽
|
||
if (!artDrag) return;
|
||
const { id, startX, startY, origX, origY, artW, artH } = artDrag;
|
||
cps[id].artX = (origX + e.clientX - startX) / artW;
|
||
cps[id].artY = (origY + e.clientY - startY) / artH;
|
||
updateArtPos(id);
|
||
}
|
||
|
||
function artMouseUp() {
|
||
artDrag = null;
|
||
artRotDrag = null;
|
||
artResizeDrag = null;
|
||
}
|
||
|
||
// ── 旋转手柄拖拽 ──
|
||
function artStartRotate(e, id) {
|
||
e.preventDefault();
|
||
artSetActive(id);
|
||
const state = cps[id];
|
||
const el = state.artEl;
|
||
const rect = el.getBoundingClientRect();
|
||
artRotDrag = {
|
||
id,
|
||
centerX: rect.left + rect.width / 2,
|
||
centerY: rect.top + rect.height / 2,
|
||
};
|
||
}
|
||
|
||
// ── 角落缩放拖拽 ──
|
||
function artStartResize(e, id, corner) {
|
||
e.preventDefault();
|
||
artSetActive(id);
|
||
artResizeDrag = {
|
||
id, corner,
|
||
startX: e.clientX, startY: e.clientY,
|
||
origW: cps[id].artW,
|
||
};
|
||
}
|
||
|
||
function artScale(id, factor) {
|
||
const state = cps[id];
|
||
const wrap = document.getElementById('cover-artboard-wrap');
|
||
const artW = wrap.clientWidth;
|
||
const artH = parseInt(document.getElementById('cover-bg-canvas').style.height) || artW;
|
||
// Scale around center
|
||
const oldW = state.artW * artW;
|
||
const oldH = oldW / state.artAspect;
|
||
const cx = state.artX + state.artW / 2;
|
||
const cy = state.artY + (oldH / artH) / 2;
|
||
state.artW = Math.max(0.04, Math.min(2, state.artW * factor));
|
||
const newH = state.artW * artW / state.artAspect;
|
||
state.artX = cx - state.artW / 2;
|
||
state.artY = cy - (newH / artH) / 2;
|
||
updateArtPos(id);
|
||
}
|
||
|
||
// ── 旋转控制 ──
|
||
function getActiveId() {
|
||
for (const { id } of COVER_PANELS) {
|
||
if (cps[id].artEl && cps[id].artEl.classList.contains('art-active')) return id;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function artSetRotation(deg) {
|
||
const id = getActiveId();
|
||
if (!id) return;
|
||
cps[id].artRotation = deg;
|
||
updateArtPos(id);
|
||
}
|
||
|
||
function artRotateStep(delta) {
|
||
const id = getActiveId();
|
||
if (!id) return;
|
||
cps[id].artRotation = ((cps[id].artRotation + delta + 180) % 360) - 180;
|
||
updateArtPos(id);
|
||
}
|
||
|
||
function artScaleActive(factor) {
|
||
const id = getActiveId();
|
||
if (id) artScale(id, factor);
|
||
}
|
||
|
||
function artLayerMove(dir) {
|
||
const id = getActiveId();
|
||
if (!id) return;
|
||
const idx = artLayerOrder.indexOf(id);
|
||
if (idx === -1) return;
|
||
if (dir === 'up' && idx < artLayerOrder.length - 1) {
|
||
artLayerOrder.splice(idx, 1);
|
||
artLayerOrder.splice(idx + 1, 0, id);
|
||
} else if (dir === 'down' && idx > 0) {
|
||
artLayerOrder.splice(idx, 1);
|
||
artLayerOrder.splice(idx - 1, 0, id);
|
||
} else {
|
||
return;
|
||
}
|
||
applyLayerZIndex();
|
||
updateArtControls(id);
|
||
}
|
||
|
||
// ── 导出 ─────────────────────────────────────────
|
||
async function buildCoverExportData() {
|
||
if (!coverBgImg) { alert('请先上传底图'); return; }
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = coverNatW;
|
||
canvas.height = coverNatH;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(coverBgImg, 0, 0, coverNatW, coverNatH);
|
||
|
||
const wrap = document.getElementById('cover-artboard-wrap');
|
||
const dispW = wrap.clientWidth;
|
||
const dispH = parseInt(document.getElementById('cover-bg-canvas').style.height) || dispW;
|
||
const scaleX = coverNatW / dispW;
|
||
const scaleY = coverNatH / dispH;
|
||
|
||
// 按图层顺序(从底到顶)导出
|
||
for (const id of artLayerOrder) {
|
||
const state = cps[id];
|
||
if (!state.rmbgBlob || !state.placed) continue;
|
||
const img = await new Promise(res => {
|
||
const i = new Image(); i.onload = () => res(i); i.src = state.rmbgBlob;
|
||
});
|
||
const pxW = state.artW * dispW;
|
||
const pxH = pxW / state.artAspect;
|
||
const drawX = state.artX * dispW * scaleX;
|
||
const drawY = state.artY * dispH * scaleY;
|
||
const drawW = pxW * scaleX;
|
||
const drawH = pxH * scaleY;
|
||
|
||
// 带旋转绘制
|
||
const centerX = drawX + drawW / 2;
|
||
const centerY = drawY + drawH / 2;
|
||
ctx.save();
|
||
ctx.translate(centerX, centerY);
|
||
ctx.rotate(state.artRotation * Math.PI / 180);
|
||
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
|
||
ctx.restore();
|
||
}
|
||
|
||
const exportName = coverBgFileName
|
||
? coverBgFileName.replace(/\.[^.]+$/, '.jpg')
|
||
: 'cover_export.jpg';
|
||
|
||
const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.95));
|
||
return { blob, exportName };
|
||
}
|
||
|
||
async function exportCover() {
|
||
const data = await buildCoverExportData();
|
||
if (!data) return;
|
||
const { blob, exportName } = data;
|
||
rememberResultObject('cover', blob);
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = exportName;
|
||
a.click();
|
||
}
|
||
|
||
async function coverApply() {
|
||
const data = await buildCoverExportData();
|
||
if (!data) return;
|
||
const { blob } = data;
|
||
const objectUrl = rememberResultObject('cover', blob);
|
||
postResultToParent(objectUrl, coverResultMime, 'cover-export.jpg');
|
||
}
|
||
|
||
const initialInput = new URLSearchParams(window.location.search).get('input');
|
||
if (initialInput) {
|
||
setSelectedFileFromUrl(initialInput).catch((e) => {
|
||
showStatus('edit-status', 'error', '❌ ' + (e && e.message ? e.message : String(e)));
|
||
});
|
||
}
|
||
|
||
window.addEventListener('message', async (event) => {
|
||
const data = event && event.data;
|
||
if (!data || data.type !== 'AI_IMAGE_EDIT_SET_INPUT') return;
|
||
try {
|
||
await setSelectedFileFromUrl(data.imageUrl);
|
||
} catch (e) {
|
||
showStatus('edit-status', 'error', '❌ ' + (e && e.message ? e.message : String(e)));
|
||
}
|
||
});
|
||
|
||
document.getElementById('edit-apply').addEventListener('click', function () {
|
||
if (!editResultObjectUrl || !editResultObjectUrl.startsWith('blob:')) {
|
||
alert('请先完成 AI 编辑');
|
||
return;
|
||
}
|
||
postResultToParent(editResultObjectUrl, editResultMime, 'output.png');
|
||
});
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|