Files
2026-10-08 17:48:09 +08:00

2914 lines
101 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>