AirShelf/电商AI平台/pipeline.html
iye 086d92991e
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 7s
统一 Airshelf 界面组件与图标
2026-05-27 12:29:41 +08:00

4873 lines
283 KiB
HTML
Raw 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-CN">
<head>
<meta charset="utf-8">
<title id="page-title">流水线 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=2026052607">
<style>
/* ─── Project header ─── */
.proj-head { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 22px; align-items: flex-start; }
.proj-head h1 { font-size: 20px; font-weight: 700; letter-spacing: -.012em; }
/* ─── 顶部胶囊式 Stage 状态 · 注入到 .topbar 中部 ─── */
.topbar { position: relative; } /* 锚定 pill */
.stage-pill {
position: absolute; left: 50%; top: 50%;
transform: translate(-50%, -50%);
display: inline-flex; align-items: center; gap: 0;
padding: 6px 16px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-pill);
z-index: 3;
}
.stage-pill .sp-dot {
position: relative;
display: inline-flex; align-items: center; gap: 6px;
padding: 2px 8px;
text-decoration: none;
cursor: pointer;
border-radius: var(--r-sm);
transition: background var(--t-base);
}
.stage-pill .sp-dot:hover { background: var(--background-lighter); }
/* 圆点本体 · 默认(待开始):浅灰实心 · 对齐 .prog span 默认 */
.stage-pill .sp-dot .d {
width: 10px; height: 10px; border-radius: 50%;
background: var(--black-alpha-8);
border: 1.5px solid transparent;
transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base);
}
/* 文字标签 · 始终显示 */
.stage-pill .sp-dot .l {
font-size: 12px; color: var(--black-alpha-56);
font-weight: 500; letter-spacing: .01em;
white-space: nowrap;
transition: color var(--t-base);
}
.stage-pill .sp-dot:hover .l { color: var(--accent-black); }
/* done · 森林绿 · 对齐 .prog span.done */
.stage-pill .sp-dot.done .d {
background: var(--accent-forest);
border-color: var(--accent-forest);
}
.stage-pill .sp-dot.done .l { color: var(--accent-black); }
/* active · 主橙 + 光晕 + 脉动 · 对齐 .prog span.cur(单橙锚点) */
.stage-pill .sp-dot.active .d {
background: var(--heat);
border-color: var(--heat);
box-shadow: 0 0 0 3px var(--heat-12);
animation: prog-pulse 1.4s ease-in-out infinite;
}
.stage-pill .sp-dot.active .l { color: var(--heat); font-weight: 600; }
/* fail · crimson · 对齐 .prog span.fail */
.stage-pill .sp-dot.fail .d {
background: var(--accent-crimson);
border-color: var(--accent-crimson);
}
.stage-pill .sp-dot.fail .l { color: var(--accent-crimson); }
/* 连接线 · 对齐 .prog 色卡 */
.stage-pill .sp-line {
width: 14px; height: 1.5px;
background: var(--black-alpha-8);
transition: background var(--t-base);
}
.stage-pill .sp-line.done { background: var(--accent-forest); }
/* anchor 节点本身只承载初始模板,挂入 topbar 后变 .stage-pill */
.stage-pill-anchor[hidden] { display: none; }
/* ─── Stage panes ─── */
.stage { display: none; }
.stage.active { display: block; }
/* Common pane */
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); }
.pane-h { display: flex; align-items: center; gap: 8px; padding: 14px 18px; border-bottom: 1px solid var(--border-faint); }
.pane-h strong { font-size: 14px; font-weight: 600; }
/* Stage foot */
.stage-foot { display: flex; justify-content: space-between; align-items: center; padding: 18px 0 0; margin-top: 18px; border-top: 1px solid var(--border-faint); }
.stage-foot .info { font-size: 12.5px; color: var(--black-alpha-56); }
.stage-foot .info .mono { font-family: var(--font-mono); color: var(--black-alpha-48); font-size: 11.5px; letter-spacing: .02em; }
.stage-foot .hstack { gap: 10px; align-items: center; }
.stage-foot .btn {
height: 40px;
min-height: 40px;
padding: 0 18px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
font-size: 13.5px;
line-height: 1;
white-space: nowrap;
}
.stage-foot .btn-primary { padding: 0 20px; font-weight: 600; }
.stage-foot .btn svg { width: 14px; height: 14px; flex: 0 0 14px; }
/* ─── 全高度布局 · 除 Stage 2 外,操作模块 hug content、内容区域 fill content ─── */
/* JS 在 activateStage(1/3/4/5) 时给 .content 加 .content--fh,Stage 2 留常规文档流 */
.content.content--fh {
display: flex; flex-direction: column;
overflow: hidden; /* 内部 .stage 接管滚动 */
padding: 24px 28px 20px; /* 默认上下 padding · 让 stage-foot 真正贴近视口底部 */
}
/* flat 模式 · 像 model-photo 那样的双区扁平布局 · 取消卡片,全宽贴边 */
.content.content--fh-flat {
padding: 0;
}
/* flat 模式下 · 取消 .pane 卡片描边/圆角(Stage 1 两侧面板) */
.content--fh-flat .stage.active > .stage-script .pane {
background: var(--surface);
border: 0; border-radius: 0;
}
/* 左/右 pane 白底贴边 · 仅内部元素对齐到 topbar 28px 版心 */
/* Stage 1 · 镜头脚本 + 脚本助手 */
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.shot-list > .pane-h,
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.shot-list > .shots-body {
padding-left: 28px;
}
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.chat-pane > .pane-h,
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.chat-pane > .chat-body,
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.chat-pane > .chat-input {
padding-left: 28px;
padding-right: 28px;
}
/* Stage 1 · flat 模式去除分割线:两侧 pane-h 底线 · gutter 竖线 · chat-input 顶线 */
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.shot-list > .pane-h,
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.chat-pane > .pane-h {
border-bottom: 0;
}
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.chat-pane > .chat-input {
border-top: 0;
}
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .stage-script-gutter::after {
background: transparent;
}
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .stage-script-gutter:hover::after,
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .stage-script-gutter.dragging::after {
background: var(--heat);
}
/* Stage 2/3 的内部对齐规则放到对应 flat 块里(避免被后面规则吃掉) */
/* Stage 5 · editor-preview 左 · editor-props 右 · timeline 两侧 */
.content--fh-flat .stage[data-stage-pane="5"].active > .editor > .editor-preview {
padding-left: 28px;
}
.content--fh-flat .stage[data-stage-pane="5"].active > .editor > .editor-props {
padding-right: 28px;
}
.content--fh-flat .stage[data-stage-pane="5"].active > .editor > .timeline {
padding-left: 28px;
padding-right: 28px;
}
/* 镜头脚本 | 拖拽分隔条 | 脚本助手 三列布局 · 分隔条可拖动 */
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script {
gap: 0;
grid-template-columns: minmax(0, 1fr) 6px var(--chat-w, 360px);
}
/* 拖拽 gutter · 默认 1px 灰线,hover/拖中加重 · 鼠标 col-resize */
.stage-script-gutter {
position: relative;
background: transparent;
cursor: col-resize;
transition: background var(--t-base);
}
.stage-script-gutter::after {
content: '';
position: absolute; top: 0; bottom: 0; left: 50%;
width: 1px; transform: translateX(-50%);
background: var(--border-faint);
transition: background var(--t-base), width var(--t-base);
}
.stage-script-gutter:hover::after,
.stage-script-gutter.dragging::after {
background: var(--heat);
width: 2px;
}
.stage-script-gutter.dragging { background: var(--heat-12); }
/* flat 模式 · stage-foot 改为全宽 flat 底栏:白底 + border-top,不再像卡片下沿 */
.content--fh-flat .stage.active > .stage-foot {
margin-top: 0;
padding: 14px 28px;
background: var(--surface);
border-top: 1px solid var(--border-faint);
}
/* ─── Stage 3 flat · 故事板双区:左 canvas | 右 side,用 border-right 分隔 ─── */
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard {
gap: 0;
}
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas {
border: 0; border-radius: 0;
background: var(--surface);
padding: 18px 14px 18px 28px;
align-items: center;
}
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas > .sb-main-img {
width: 100%;
}
/* sb-side · 撑满网格行高 · 内 .pane fill content + 内部可滚 */
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-side {
display: flex; flex-direction: column; min-height: 0;
}
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-side > .pane {
flex: 1 1 0; min-height: 0; overflow-y: auto;
border: 0; border-radius: 0;
background: var(--surface);
padding: 18px 28px;
}
/* 解除 sb-scenes-col 560 上限,跟随父级填满 */
.content--fh-flat .stage[data-stage-pane="3"].active .sb-scenes-col { max-height: none; }
/* ─── Stage 2 flat · 左 200 资产侧栏 | 右 内容,用 border-right 分隔 ─── */
.content--fh-flat .stage[data-stage-pane="2"].active > .stage-assets {
gap: 0;
height: 100%;
}
.content--fh-flat .stage[data-stage-pane="2"].active > .stage-assets > .asset-side {
position: static; align-self: stretch;
padding: 18px 16px;
background: var(--background-base);
overflow-y: auto;
}
.content--fh-flat .stage[data-stage-pane="2"].active > .stage-assets > .asset-main {
padding: 18px 28px;
overflow-y: auto;
background: var(--background-base);
}
/* ─── Stage 4 flat · 顶部 queue-bar 改 toolbar · 视频卡片网格区 ─── */
.content--fh-flat .stage[data-stage-pane="4"].active > .queue-bar {
border: 0; border-radius: 0;
border-bottom: 1px solid var(--border-faint);
margin: 0;
padding: 14px 28px;
}
.content--fh-flat .stage[data-stage-pane="4"].active > .video-grid {
padding: 18px 28px;
background: var(--background-base);
}
/* ─── Stage 5 flat · 编辑器外壳去卡片 ─── */
.content--fh-flat .stage[data-stage-pane="5"].active > .editor {
border: 0; border-radius: 0;
}
.content--fh .stage.active {
display: flex; flex-direction: column;
flex: 1 1 auto; min-height: 0;
}
/* 内容主体(stage-foot 之上的最后一个块)· fill content */
.content--fh .stage[data-stage-pane="1"].active > .stage-script,
.content--fh .stage[data-stage-pane="2"].active > .stage-assets,
.content--fh .stage[data-stage-pane="3"].active > .stage-storyboard,
.content--fh .stage[data-stage-pane="4"].active > .video-grid,
.content--fh .stage[data-stage-pane="5"].active > .editor {
flex: 1 1 0; min-height: 0;
}
/* Stage 1 / 2 主体不整体滚,把滚动交给左右栏各自的 body */
.content--fh .stage[data-stage-pane="1"].active > .stage-script,
.content--fh .stage[data-stage-pane="2"].active > .stage-assets { overflow: hidden; }
/* 其他 stage 主体仍可整体滚动 */
.content--fh .stage[data-stage-pane="3"].active > .stage-storyboard,
.content--fh .stage[data-stage-pane="4"].active > .video-grid,
.content--fh .stage[data-stage-pane="5"].active > .editor { overflow-y: auto; }
/* stage-foot · hug content:高度只由按钮决定,不拉伸、不压缩 */
.content--fh .stage.active > .stage-foot { flex: 0 0 auto; }
/* Stage 1 脚本网格:取消固定 min-height,允许 flex 接管 */
.content--fh .stage[data-stage-pane="1"].active > .stage-script { min-height: 0; }
/* Stage 1 内部 panes:左右栏各自独立滚动 */
.content--fh .stage[data-stage-pane="1"].active > .stage-script > .shot-list,
.content--fh .stage[data-stage-pane="1"].active > .stage-script > .chat-pane {
min-height: 0; min-width: 0;
}
.content--fh .stage[data-stage-pane="1"].active .shots-body,
.content--fh .stage[data-stage-pane="1"].active .chat-body {
max-height: none;
flex: 1 1 0; min-height: 0; overflow-y: auto;
}
/* Stage 5 编辑器:解除固定 580px 高,让 flex 接管 */
.content--fh .stage[data-stage-pane="5"].active > .editor { height: auto; }
/* === STAGE 1 · 脚本(镜头脚本 : 脚本助手 = 7 : 3,助手在 3:2 基础上再缩 1/4) === */
.stage-script { display: grid; grid-template-columns: 7fr 3fr; gap: 16px; min-height: 560px; }
.chat-pane { display: flex; flex-direction: column; }
.chat-body { padding: 16px 18px; flex: 1; overflow-y: auto; max-height: 460px; display: flex; flex-direction: column; gap: 14px; }
.msg .bubble { max-width: 90%; padding: 10px 14px; font-size: 13px; line-height: 1.6; border: 1px solid var(--border-faint); border-radius: var(--r-md); }
.msg.ai .bubble { background: var(--surface); }
.msg.user { display: flex; flex-direction: column; align-items: flex-end; }
.msg.user .bubble { background: var(--heat-12); color: var(--accent-black); border-color: var(--heat-20); }
.msg .time { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 4px; letter-spacing: .02em; }
.msg .actions { display: flex; gap: 6px; margin-top: 6px; }
.ai-avatar { width: 26px; height: 26px; flex-shrink: 0; background: var(--heat); color: var(--accent-white); display: grid; place-items: center; font-size: 11px; font-weight: 700; border: 1px solid var(--heat); border-radius: 50%; }
.del { text-decoration: line-through; color: var(--black-alpha-48); }
.ins { background: var(--forest-bg); color: var(--accent-forest); padding: 0 3px; }
.chat-input { padding: 14px 18px 18px; border-top: 1px solid var(--border-faint); }
.chat-input-card {
background: var(--background-base);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 12px 14px 10px;
transition: border-color var(--t-base), box-shadow var(--t-base);
}
.chat-input-card:focus-within { border-color: var(--accent-black); box-shadow: 0 0 0 3px rgba(0,0,0,.04); }
.chat-input-area {
width: 100%; border: none; outline: none; background: transparent;
font-family: var(--font-sans); font-size: 13px; color: var(--accent-black);
line-height: 1.55; resize: none; padding: 0; min-height: 42px;
}
.chat-input-area::placeholder { color: var(--black-alpha-40); }
.chat-input-foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.chat-input-foot .hint { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-40); letter-spacing: .02em; }
.chat-input-foot .spacer { flex: 1; }
.chat-icon-btn {
width: 28px; height: 28px; display: grid; place-items: center;
background: transparent; border: 1px solid var(--border-faint);
border-radius: 50%; color: var(--black-alpha-56); cursor: pointer;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.chat-icon-btn:hover { border-color: var(--accent-black); color: var(--accent-black); }
.chat-send-btn {
width: 32px; height: 32px; display: grid; place-items: center;
background: var(--accent-black); border: 1px solid var(--accent-black);
border-radius: 50%; color: var(--accent-white); cursor: pointer;
transition: background var(--t-base), border-color var(--t-base), transform var(--t-base);
}
.chat-send-btn:hover { background: var(--heat); border-color: var(--heat); }
.chat-send-btn:active { transform: scale(.95); }
.chat-send-btn:disabled { background: var(--black-alpha-12); border-color: var(--black-alpha-12); color: var(--black-alpha-40); cursor: not-allowed; transform: none; }
.chat-attach-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.chat-attach-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 6px 3px 8px; background: var(--surface);
border: 1px solid var(--border-faint); border-radius: var(--r-sm);
font-family: var(--font-mono); font-size: 11px; color: var(--accent-black);
}
.chat-attach-chip .x { width: 14px; height: 14px; display: grid; place-items: center; background: transparent; border: none; color: var(--black-alpha-48); cursor: pointer; border-radius: 50%; }
.chat-attach-chip .x:hover { background: var(--black-alpha-08); color: var(--accent-black); }
.shot-list { display: flex; flex-direction: column; }
.shots-body { padding: 12px 16px; flex: 1; overflow-y: auto; max-height: 540px; display: flex; flex-direction: column; gap: 0; }
.shot-card { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px 14px; transition: border-color var(--t-base), background var(--t-base); }
.shot-card.highlight { border-color: var(--heat); background: var(--heat-12); }
.shot-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.shot-num { width: 22px; height: 22px; background: var(--accent-black); color: var(--accent-white); display: grid; place-items: center; font-family: var(--font-mono); font-size: 11px; font-weight: 700; border-radius: var(--r-sm); }
.shot-time { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); padding: 2px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); }
.shot-row { display: grid; grid-template-columns: 36px 1fr; gap: 8px; padding: 4px 0; }
.shot-k { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); padding-top: 2px; letter-spacing: .04em; }
.shot-v { font-size: 12.5px; color: var(--accent-black); line-height: 1.55; outline: none; border-radius: var(--r-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-base); }
.shot-v[contenteditable="true"]:hover { background: var(--heat-12); cursor: text; }
.shot-v[contenteditable="true"]:focus { background: var(--surface); box-shadow: inset 0 0 0 1px var(--heat); }
.shot-v[data-empty="true"]::before { content: attr(data-placeholder); color: var(--black-alpha-32); font-style: italic; }
.icon-mini-btn { width: 24px; height: 24px; display: grid; place-items: center; color: var(--black-alpha-48); background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); cursor: pointer; font-size: 14px; }
.icon-mini-btn:hover { color: var(--heat); border-color: var(--heat); }
/* 镜头卡片间 hover 加分镜插槽 */
/* 镜头卡片间 hover 加分镜插槽 · 卡片移开一行 + 渐显按钮 */
.shot-insert-gap {
height: 10px;
position: relative;
display: flex; align-items: center; justify-content: center;
padding: 0;
transition: height .24s cubic-bezier(.18,.72,.28,1), padding .24s cubic-bezier(.18,.72,.28,1);
}
.shot-insert-gap:hover {
height: 72px;
padding: 14px 0;
}
.shot-insert-gap .add-shot-btn {
opacity: 0;
transform: translateY(4px) scale(.96);
height: 28px; padding: 0 14px;
background: var(--surface);
color: var(--heat);
border: 1px dashed var(--heat-40);
border-radius: var(--r-md);
font-size: 12.5px; font-family: inherit; font-weight: 500;
cursor: pointer;
transition: opacity .2s ease .04s, transform .24s cubic-bezier(.18,.72,.28,1) .04s, background var(--t-base), border-color var(--t-base), color var(--t-base);
display: inline-flex; align-items: center; gap: 6px;
pointer-events: none;
white-space: nowrap;
}
.shot-insert-gap .add-shot-btn svg { width: 12px; height: 12px; }
.shot-insert-gap:hover .add-shot-btn {
opacity: 1; transform: translateY(0) scale(1);
pointer-events: auto;
}
.shot-insert-gap .add-shot-btn:hover {
background: var(--heat-12);
border-style: solid;
border-color: var(--heat);
color: var(--heat);
}
/* 镜头脚本顶栏 · 自动从脚本抓取的人物/场景标签 · 可编辑/删除/添加 */
.script-tags { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 14px; margin-left: 6px; }
.script-tags .tag-group { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.script-tags .tg-lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; flex-shrink: 0; }
.script-tags .script-tag { display: inline-flex; align-items: center; gap: 2px; padding: 2px 2px 2px 10px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: 999px; font-size: 11.5px; color: var(--accent-black); transition: border-color var(--t-base), background var(--t-base); }
.script-tags .script-tag:hover { border-color: var(--heat-40); background: var(--heat-12); }
.script-tags .script-tag .t { outline: none; padding: 0 4px; border-radius: 3px; min-width: 8px; cursor: text; }
.script-tags .script-tag .t:focus { background: var(--surface); box-shadow: inset 0 0 0 1px var(--heat); }
.script-tags .script-tag .x { width: 16px; height: 16px; display: grid; place-items: center; background: transparent; border: 0; color: var(--black-alpha-40); border-radius: 50%; cursor: pointer; font-size: 13px; line-height: 1; transition: background var(--t-base), color var(--t-base); }
.script-tags .script-tag .x:hover { background: var(--black-alpha-08); color: var(--accent-crimson); }
.script-tags .tag-add { width: 20px; height: 20px; display: grid; place-items: center; background: transparent; border: 1px dashed var(--black-alpha-24); border-radius: 50%; color: var(--black-alpha-48); cursor: pointer; font-size: 13px; line-height: 1; padding: 0; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
.script-tags .tag-add:hover { border-style: solid; border-color: var(--heat); color: var(--heat); background: var(--heat-12); }
/* 镜头脚本空缺省态 */
.shots-empty { padding: 36px 24px; margin: auto; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 12px; color: var(--black-alpha-48); }
.shots-empty .empty-ico { width: 56px; height: 56px; border: 1px dashed var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; color: var(--black-alpha-32); }
.shots-empty .empty-title { font-size: 14px; font-weight: 500; color: var(--accent-black); }
.shots-empty .empty-hint { font-size: 12px; color: var(--black-alpha-56); line-height: 1.55; max-width: 280px; font-family: var(--font-mono); letter-spacing: .02em; }
/* 对话空态三胶囊 */
.chat-empty { padding: 28px 18px 14px; margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.chat-empty .ce-title { font-size: 13.5px; color: var(--accent-black); font-weight: 500; }
.chat-empty .ce-hint { font-size: 11.5px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; }
.chat-modes { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.chat-mode { height: 30px; padding: 0 14px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: 999px; font-size: 12.5px; color: var(--accent-black); display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-family: inherit; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.chat-mode:hover { background: var(--heat-12); border-color: var(--heat); color: var(--heat); }
.chat-mode.primary { background: var(--heat-12); border-color: var(--heat); color: var(--heat); }
.chat-mode svg { width: 13px; height: 13px; }
/* AI 思考态 typing indicator */
.ai-thinking .dots { display: inline-flex; gap: 3px; }
.ai-thinking .dots span { width: 6px; height: 6px; background: var(--black-alpha-32); border-radius: 50%; animation: thinking 1.2s ease-in-out infinite; }
.ai-thinking .dots span:nth-child(2) { animation-delay: .2s; }
.ai-thinking .dots span:nth-child(3) { animation-delay: .4s; }
@keyframes thinking { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
/* 视口锁定 · 只让主内容区滚动 (sidebar + topbar 固定,不随页面滚) */
html, body { height: 100%; overflow: hidden; max-width: 100vw; }
.app { height: 100vh; max-height: 100vh; overflow: hidden; }
.app > .sidebar { height: 100vh; overflow-y: auto; }
.app > main { height: 100vh; max-height: 100vh; overflow: hidden; display: flex; flex-direction: column; min-width: 0; }
.app > main > .topbar { flex-shrink: 0; }
.app > main > .content { flex: 1 1 0; min-height: 0; min-width: 0; overflow-y: auto; overflow-x: hidden; }
/* === STAGE 2 · 基础资产 === */
.stage-assets { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 24px; }
.stage-assets > div { min-width: 0; }
.asset-side { position: sticky; top: 16px; align-self: start; }
.asset-sec { min-width: 0; }
.asset-strip-wrap { min-width: 0; }
.asset-side .ttab { padding: 10px 12px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 8px; border: 1px solid transparent; border-radius: var(--r-md); }
.asset-side .ttab:hover { background: var(--background-lighter); }
.asset-side .ttab.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
.asset-side .ttab .num { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); margin-left: auto; }
.asset-side .ttab.active .num { color: var(--heat); }
.asset-side .info { font-size: 12px; color: var(--black-alpha-48); padding: 14px 12px; line-height: 1.6; margin-top: 14px; border-top: 1px solid var(--border-faint); }
.asset-side .info strong { color: var(--black-alpha-56); display: block; }
.asset-side .info .mono { font-family: var(--font-mono); }
.asset-sec { scroll-margin-top: 16px; }
.asset-sec + .asset-sec { margin-top: 32px; }
.asset-sec .sec-h { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.asset-sec .sec-h h3 { font-size: 15px; font-weight: 600; }
/* .pill-tip 主样式定义在下方 (heat 主色) */
/* 预设库横滑行(卡片尺寸与主区 .asset-card-2 一致) */
.asset-strip-wrap { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-faint); }
.asset-strip-wrap .strip-h { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-56); letter-spacing: .04em; text-transform: uppercase; }
.asset-strip { display: flex; gap: 14px; overflow-x: auto; overflow-y: hidden; padding: 2px 2px 14px; scrollbar-width: thin; }
.asset-strip::-webkit-scrollbar { height: 8px; }
.asset-strip::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 4px; }
.asset-strip .asset-card-2 { flex: 0 0 240px; min-width: 240px; max-width: 240px; }
/* 「去 XX 库」CTA 胶囊 · 主操作色,更显眼 */
.asset-sec .sec-h .pill-tip,
.asset-strip-wrap .strip-h .pill-tip {
display: inline-flex; align-items: center; gap: 6px;
height: 28px; padding: 0 14px;
background: var(--heat-12);
border: 1px solid var(--heat-20);
border-radius: 999px;
font-size: 12px; color: var(--heat); font-weight: 500;
cursor: pointer; font-family: inherit;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.asset-sec .sec-h .pill-tip:hover,
.asset-strip-wrap .strip-h .pill-tip:hover {
background: var(--heat); color: var(--accent-white); border-color: var(--heat);
box-shadow: var(--shadow-cta);
}
.asset-sec .sec-h .pill-tip svg,
.asset-strip-wrap .strip-h .pill-tip svg { width: 12px; height: 12px; }
.asset-grid-2 { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
/* 商品行:左侧商品卡 + 右侧三视图预览(三视图是单张 16:9 图,不是 3 张) */
.prod-row { display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; }
/* 三视图卡固定 360 高;商品卡同高,宽度按 3:5 比例反推(≈216px),内部元素 flex 自适应 */
.prod-row > .asset-card-2 {
flex: 0 0 auto;
width: auto; max-width: none; min-width: 0;
height: 360px;
aspect-ratio: 3 / 5;
}
.prod-preview { flex: 0 0 360px; height: 360px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px; display: none; flex-direction: column; gap: 10px; }
.prod-preview.show { display: flex; }
.prod-preview-h { display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-56); letter-spacing: .04em; text-transform: uppercase; }
.prod-preview-img { aspect-ratio: 16/9; }
.prod-preview-foot { display: flex; align-items: center; gap: 8px; min-height: 30px; }
/* 三视图历史版本缩略图 strip */
.prod-preview-history { display: none; flex-direction: column; gap: 6px; }
.prod-preview-history.show { display: flex; }
.prod-preview-history .h-lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
.prod-preview-history .h-lbl .ct { color: var(--accent-black); font-weight: 600; }
.prod-preview-history .h-row { display: flex; gap: 6px; overflow-x: auto; padding: 2px; scrollbar-width: thin; }
.prod-preview-history .h-row::-webkit-scrollbar { height: 4px; }
.prod-preview-history .h-row::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 2px; }
.prod-preview-history .h-thumb {
flex: 0 0 auto;
width: 72px; aspect-ratio: 16/9;
background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm);
position: relative; cursor: pointer; transition: border-color var(--t-base), transform var(--t-base);
display: grid; place-items: center; overflow: hidden;
}
.prod-preview-history .h-thumb:hover { border-color: var(--heat-40); }
/* 已采用版本:主橙描边 + 「已采用」徽标 */
.prod-preview-history .h-thumb.adopted { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }
/* 仅预览(未采用):黑色描边,无徽标 */
.prod-preview-history .h-thumb.previewing { border-color: var(--accent-black); border-width: 2px; }
.prod-preview-history .h-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }
.prod-preview-history .h-thumb.adopted .v { color: var(--heat); font-weight: 600; }
.prod-preview-history .h-thumb.previewing .v { color: var(--accent-black); font-weight: 600; }
.prod-preview-history .h-thumb .badge { position: absolute; top: 2px; left: 2px; font-family: var(--font-mono); font-size: 8.5px; padding: 0 4px; line-height: 12px; background: var(--heat); color: var(--accent-white); border-radius: 2px; letter-spacing: .02em; display: none; }
.prod-preview-history .h-thumb.adopted .badge { display: block; }
/* 「已采用」状态 · 浅橙 + 主橙文字,与已采用徽标视觉呼应 */
#prod-preview-adopt:disabled,
#prod-preview-adopt:disabled:hover {
color: var(--heat);
border-color: var(--heat-40);
background: var(--heat-12);
cursor: not-allowed;
opacity: 1;
}
/* 主图可点击放大 */
.prod-preview-img.is-zoomable { cursor: zoom-in; transition: border-color var(--t-base); position: relative; }
.prod-preview-img.is-zoomable:hover { border-color: var(--heat-40); }
.prod-preview-img.is-zoomable::after {
content: '';
position: absolute; top: 8px; right: 8px;
width: 22px; height: 22px;
background: rgba(21,20,15,.72) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='M21 21l-4.3-4.3M8 11h6M11 8v6'/></svg>") center/14px no-repeat;
border-radius: var(--r-sm);
opacity: 0; transition: opacity var(--t-base);
pointer-events: none;
}
.prod-preview-img.is-zoomable:hover::after { opacity: 1; }
/* 三视图放大查看 lightbox */
#tri-lightbox-bg { z-index: 80; }
#tri-lightbox-bg .tri-lightbox {
position: relative;
width: min(1100px, 92vw);
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 18px 20px 20px;
display: flex; flex-direction: column; gap: 12px;
box-shadow: 0 24px 64px rgba(0,0,0,.24);
}
.tri-lightbox-head {
display: flex; align-items: center; gap: 8px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: .04em; text-transform: uppercase;
color: var(--black-alpha-56);
padding-right: 32px;
}
.tri-lightbox-head .lb-ver { color: var(--heat); font-weight: 600; }
.tri-lightbox-head .lb-tag {
margin-left: 6px;
padding: 2px 6px;
background: var(--heat-12); color: var(--heat);
border-radius: 3px;
font-size: 10px;
}
.tri-lightbox-close {
position: absolute;
top: 12px; right: 12px;
width: 28px; height: 28px;
display: grid; place-items: center;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-56);
cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
z-index: 2;
}
.tri-lightbox-close:hover { background: var(--black-alpha-08); color: var(--accent-black); border-color: var(--black-alpha-12); }
.tri-lightbox-close svg { width: 14px; height: 14px; }
.tri-lightbox-img { aspect-ratio: 16/9; width: 100%; }
.tri-lightbox-foot { display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); }
.tri-lightbox-foot .spc { flex: 1; }
.tri-lightbox-foot kbd {
display: inline-block;
padding: 1px 5px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-bottom-width: 2px;
border-radius: 3px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--black-alpha-72);
}
.asset-card-2 { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), box-shadow var(--t-base); overflow: hidden; display: flex; flex-direction: column; }
.asset-card-2:hover { border-color: var(--heat-40); box-shadow: 0 1px 3px rgba(0,0,0,.04); }
.asset-card-2 .thumb-2 { aspect-ratio: 1; }
.asset-card-2 .body-2 { padding: 12px 14px; }
.asset-card-2 .body-2 .btn:disabled,
.asset-card-2 .body-2 .btn.disabled {
background: transparent;
border-color: transparent;
color: var(--black-alpha-32);
box-shadow: none;
cursor: not-allowed;
opacity: .72;
transform: none;
}
.asset-card-2 .body-2 .btn:disabled:hover,
.asset-card-2 .body-2 .btn.disabled:hover {
background: transparent;
border-color: transparent;
color: var(--black-alpha-32);
box-shadow: none;
transform: none;
}
.asset-card-2 .body-2 .btn-apply { background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); }
.asset-card-2 .body-2 .btn-apply:hover { box-shadow: var(--shadow-cta-hover); }
/* stage2 商品卡 · 与商品库 .product-card 视觉一致 */
.asset-card-2.prod-lib-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
/* 商品图 · 占据卡片剩余高度(fill);宽度 stretch 到卡片宽 */
.asset-card-2.prod-lib-card .prod-thumb { flex: 1 1 0; min-height: 0; position: relative; aspect-ratio: auto; }
.asset-card-2.prod-lib-card .prod-body { padding: 14px 14px 12px; flex: 0 0 auto; }
.asset-card-2.prod-lib-card .prod-name {
font-size: 14px; font-weight: 600;
color: var(--accent-black);
line-height: 1.3;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.asset-card-2.prod-lib-card .prod-cat {
display: inline-flex; align-items: center;
margin-top: 8px;
padding: 2px 8px;
background: var(--background-lighter);
color: var(--black-alpha-72);
border-radius: var(--r-sm);
font-size: 11.5px;
}
.asset-card-2.prod-lib-card .prod-date {
font-family: var(--font-mono);
font-size: 11px;
color: var(--black-alpha-48);
margin-top: 10px;
letter-spacing: .02em;
}
.asset-card-2.prod-lib-card .prod-footer {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
column-gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border-faint);
font-size: 11.5px;
color: var(--black-alpha-56);
background: var(--background-base);
}
.asset-card-2.prod-lib-card .prod-footer .stat {
display: inline-flex; align-items: center; justify-content: center; gap: 5px;
padding: 3px 8px;
border-radius: var(--r-sm);
font-family: var(--font-mono);
letter-spacing: .02em;
white-space: nowrap;
justify-self: center;
}
.asset-card-2.prod-lib-card .prod-footer .stat svg { width: 13px; height: 13px; color: var(--black-alpha-48); flex-shrink: 0; }
.asset-card-2.prod-lib-card .prod-footer .stat b { color: var(--accent-black); font-weight: 600; }
.asset-card-2.prod-lib-card .prod-footer .sep { color: var(--black-alpha-24); font-family: var(--font-mono); flex-shrink: 0; }
.asset-card-2.prod-lib-card .prod-action {
padding: 10px 12px;
border-top: 1px solid var(--border-faint);
background: var(--surface);
}
.asset-card-2.prod-lib-card .prod-action[hidden] { display: none; }
.asset-card-2.prod-lib-card .prod-action .btn-aigen {
width: 100%;
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
height: 34px; padding: 0 14px;
background: var(--heat);
color: var(--accent-white);
border: 1px solid var(--heat);
border-radius: var(--r-sm);
font-size: 13px; font-weight: 500;
cursor: pointer;
font-family: inherit;
box-shadow:
inset 0 -2px 4px rgba(250, 93, 25, 0.20),
0 1px 1px rgba(250, 93, 25, 0.12),
0 2px 4px rgba(250, 93, 25, 0.10);
transition: background var(--t-base), box-shadow var(--t-base), transform var(--t-base);
}
.asset-card-2.prod-lib-card .prod-action .btn-aigen:hover {
background: #FB6E2E;
box-shadow:
inset 0 -2px 4px rgba(250, 93, 25, 0.24),
0 2px 4px rgba(250, 93, 25, 0.20),
0 4px 12px rgba(250, 93, 25, 0.18);
transform: translateY(-1px);
}
.asset-card-2.prod-lib-card .prod-action .btn-aigen:active { transform: translateY(0); }
.asset-card-2.prod-lib-card .prod-action .btn-aigen:disabled {
opacity: .65; cursor: not-allowed; transform: none;
box-shadow: inset 0 -2px 4px rgba(250, 93, 25, 0.20);
}
.asset-card-2.prod-lib-card .prod-action .btn-aigen .ai-spark {
width: 14px; height: 14px;
flex-shrink: 0;
}
/* 通用资产详情 modal · 参考布局 v2 */
.asset-modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1010; display: none; align-items: center; justify-content: center; padding: 40px; }
.asset-modal-bg.show { display: flex; }
.asset-modal { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: min(1040px, 100%); max-height: calc(100vh - 80px); overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 16px 48px rgba(0,0,0,.18); }
.asset-modal-h { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--border-faint); }
.asset-modal-h h2 { font-size: 15px; font-weight: 600; }
.asset-modal-h .ad-tag { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
.asset-modal-h .x { width: 30px; height: 30px; display: grid; place-items: center; background: transparent; border: 0; cursor: pointer; color: var(--black-alpha-56); border-radius: var(--r-sm); margin-left: auto; }
.asset-modal-h .x:hover { background: var(--black-alpha-8); color: var(--accent-black); }
.asset-modal-body { padding: 20px 24px 24px; overflow-y: auto; flex: 1; }
.asset-detail-grid { display: grid; grid-template-columns: 340px 1fr; gap: 24px; }
/* 左栏:大立绘 + 缩略 */
.asset-detail-lead { display: flex; flex-direction: column; gap: 10px; }
.asset-detail-lead .ad-lead-wrap { position: relative; }
.asset-detail-lead .placeholder.ad-lead-img { aspect-ratio: 3/4; border-radius: var(--r-md); }
/* 查看大图 icon · 悬浮容器才显示 · 32×32 icon-only */
.ad-zoom-btn {
position: absolute; right: 8px; bottom: 8px;
width: 32px; height: 32px; padding: 0;
background: rgba(21,20,15,.7); color: #fff;
border: 0; border-radius: var(--r-sm);
display: grid; place-items: center;
cursor: pointer; backdrop-filter: blur(4px);
opacity: 0;
transition: opacity var(--t-base), background var(--t-base);
z-index: 3;
}
.ad-zoom-btn:hover { background: rgba(21,20,15,.92); }
.ad-zoom-btn svg { width: 14px; height: 14px; }
.asset-detail-lead .ad-lead-wrap:hover .ad-zoom-btn,
.asset-detail-tri-row .placeholder:hover .ad-zoom-btn { opacity: 1; }
.asset-detail-tri-row .placeholder { position: relative; }
.asset-detail-lead .ad-thumbs {
display: flex; gap: 8px;
}
.asset-detail-lead .ad-thumbs .thumb {
flex: 0 0 64px;
aspect-ratio: 3/4;
border-radius: var(--r-sm);
border: 1px solid var(--border-faint);
cursor: pointer; overflow: hidden;
transition: border-color var(--t-base);
}
.asset-detail-lead .ad-thumbs .thumb:hover { border-color: var(--heat-40); }
.asset-detail-lead .ad-thumbs .thumb.active { border-color: var(--heat); border-width: 2px; }
/* 右栏 section 通用 */
.asset-detail-right .ad-section + .ad-section { margin-top: 18px; }
.asset-detail-section-h {
display: flex; align-items: center; gap: 8px;
font-size: 13px; font-weight: 600; color: var(--accent-black);
margin-bottom: 10px;
}
.asset-detail-section-h .ic {
width: 14px; height: 14px;
color: var(--heat); flex-shrink: 0;
display: grid; place-items: center;
}
.asset-detail-section-h .ic svg { width: 14px; height: 14px; }
.asset-detail-section-h .ad-ratio-chip {
margin-left: auto;
font-family: var(--font-mono); font-size: 10.5px;
padding: 2px 8px; border-radius: var(--r-sm);
background: var(--background-lighter);
border: 1px solid var(--border-faint);
color: var(--black-alpha-56); letter-spacing: .02em;
}
.asset-detail-section-h .ad-icon-btn {
width: 28px; height: 28px;
display: grid; place-items: center;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-56); cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.asset-detail-section-h .ad-icon-btn:hover { color: var(--heat); border-color: var(--heat-40); }
.asset-detail-section-h .ad-icon-btn svg { width: 12px; height: 12px; }
/* 三视图 — 始终单张 16:9 大图 (不分 3 张) */
.asset-detail-tri-row { margin-top: 0; }
.asset-detail-tri-row .placeholder { aspect-ratio: 16 / 9; border-radius: var(--r-md); }
/* 三视图 · 用户上传 历史版本 strip */
.md-view-versions { display: flex; gap: 6px; overflow-x: auto; padding: 2px; }
.md-view-versions .v-thumb { flex: 0 0 auto; width: 72px; aspect-ratio: 16/9; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); position: relative; cursor: pointer; display: grid; place-items: center; overflow: hidden; transition: border-color var(--t-base); }
.md-view-versions .v-thumb:hover { border-color: var(--heat-40); }
.md-view-versions .v-thumb.active { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }
.md-view-versions .v-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }
.md-view-versions .v-thumb.active .v { color: var(--heat); font-weight: 600; }
.asset-detail-tri-row .placeholder.missing { display: grid; place-items: center; border: 1px dashed var(--border-faint); background: var(--background-lighter); color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 12px; padding: 12px; text-align: center; cursor: pointer; transition: border-color var(--t-base), color var(--t-base); gap: 8px; }
.asset-detail-tri-row .placeholder.missing:hover { border-color: var(--heat); color: var(--heat); }
/* 简介文字 + 标签 */
.ad-intro {
font-size: 13px; line-height: 1.65;
color: var(--black-alpha-72);
margin: 0 0 12px;
}
.ad-tags {
display: flex; flex-wrap: wrap; gap: 8px;
}
.ad-tags .ad-tag-chip {
height: 26px; padding: 0 12px;
display: inline-flex; align-items: center;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-size: 12px; color: var(--accent-black);
}
.ad-tags .ad-tag-add {
width: 26px; height: 26px;
display: grid; place-items: center;
background: var(--background-lighter);
border: 1px dashed var(--black-alpha-24);
border-radius: var(--r-sm);
color: var(--black-alpha-56); cursor: pointer;
transition: border-color var(--t-base), color var(--t-base);
}
.ad-tags .ad-tag-add:hover { border-color: var(--heat); color: var(--heat); }
.ad-tags .ad-tag-add svg { width: 12px; height: 12px; }
/* 属性表 · 3 列 × N 行 */
.ad-props {
margin-top: 18px;
display: grid;
grid-template-columns: repeat(3, 1fr);
column-gap: 24px;
row-gap: 0;
border-top: 1px solid var(--border-faint);
padding-top: 16px;
}
.ad-props .ad-prop {
display: flex; align-items: baseline;
padding: 10px 0;
border-bottom: 1px solid var(--border-faint);
font-size: 12.5px;
min-height: 38px;
}
.ad-props .ad-prop:nth-last-child(-n+3) { border-bottom: 0; }
.ad-props .ad-prop .k {
flex: 0 0 64px;
color: var(--black-alpha-56);
font-family: var(--font-mono); font-size: 11px;
}
.ad-props .ad-prop .v {
color: var(--accent-black);
font-weight: 500;
word-break: break-all;
}
.asset-detail-tip { margin-top: 10px; padding: 10px 12px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-sm); font-size: 12px; color: var(--accent-black); display: flex; align-items: center; gap: 8px; line-height: 1.5; }
.asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; }
.asset-detail-tip .ai-gen-btn { margin-left: auto; height: 26px; padding: 0 10px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-sm); font-size: 11.5px; cursor: pointer; font-family: inherit; flex-shrink: 0; }
/* footer · 左侧统计 + 右侧按钮 */
.asset-modal-f { padding: 14px 20px; border-top: 1px solid var(--border-faint); display: flex; align-items: center; gap: 8px; }
.asset-modal-f .ad-foot-stats { display: flex; gap: 6px; margin-right: auto; }
.asset-modal-f .ad-stat-btn {
height: 32px; padding: 0 12px;
display: inline-flex; align-items: center; gap: 6px;
background: transparent;
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-72);
font-size: 12.5px; font-family: inherit;
cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.asset-modal-f .ad-stat-btn:hover { background: var(--background-lighter); color: var(--accent-black); border-color: var(--black-alpha-24); }
.asset-modal-f .ad-stat-btn svg { width: 13px; height: 13px; }
.asset-modal-f .ad-stat-btn b { color: var(--accent-black); font-weight: 600; }
/* 演员库 / 场景库 全屏弹窗(沿用 model-photo .ml-modal 结构) */
.ml-modal-bg { position: fixed; inset: 0; background: var(--surface); z-index: 1000; display: none; }
.ml-modal-bg.show { display: flex; }
.ml-modal { margin: 0; flex: 1; background: var(--surface); border-radius: 0; overflow: hidden; display: flex; flex-direction: column; }
.ml-modal-h { display: flex; align-items: center; padding: 14px 28px; border-bottom: 1px solid var(--border-faint); flex-shrink: 0; }
.ml-modal-h h2 { font-size: 16px; font-weight: 600; }
.ml-modal-h .ct { margin-left: 10px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
.ml-modal-h .x { margin-left: auto; width: 32px; height: 32px; display: grid; place-items: center; background: transparent; border: 0; border-radius: var(--r-sm); cursor: pointer; color: var(--black-alpha-56); transition: background var(--t-base), color var(--t-base); }
.ml-modal-h .x:hover { background: var(--black-alpha-8); color: var(--accent-black); }
.ml-modal-h .x svg { width: 16px; height: 16px; }
.ml-modal-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 200px 1fr; }
.ml-side { border-right: 1px solid var(--border-faint); padding: 18px 0; overflow-y: auto; }
.ml-side .ml-side-h { padding: 0 20px 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; }
.ml-side .ml-side-item { display: flex; align-items: center; gap: 8px; padding: 9px 20px; cursor: pointer; color: var(--black-alpha-72); font-size: 13px; border-left: 3px solid transparent; transition: background var(--t-base), color var(--t-base); }
.ml-side .ml-side-item:hover { background: var(--black-alpha-4); }
.ml-side .ml-side-item.active { background: var(--heat-12); color: var(--accent-black); border-left-color: var(--heat); font-weight: 600; }
.ml-side .ml-side-item .ct { margin-left: auto; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); }
.ml-main { overflow-y: auto; padding: 0; display: flex; flex-direction: column; min-width: 0; position: relative; }
.ml-toolbar { padding: 14px 28px; border-bottom: 1px solid var(--border-faint); display: flex; align-items: center; gap: 18px; flex-shrink: 0; flex-wrap: wrap; }
.ml-toolbar .btn-up { height: 32px; padding: 0 14px; display: inline-flex; align-items: center; gap: 6px; background: var(--surface); border: 1px solid var(--black-alpha-12); border-radius: var(--r-sm); color: var(--accent-black); font-family: inherit; font-size: 12.5px; cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }
.ml-toolbar .btn-up:hover { background: var(--background-lighter); border-color: var(--black-alpha-24); }
.ml-toolbar .btn-up svg { width: 14px; height: 14px; }
.ml-toolbar .chip-group { display: inline-flex; align-items: center; gap: 6px; }
.ml-toolbar .chip-group .lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; margin-right: 4px; }
.ml-toolbar .chip { height: 26px; padding: 0 12px; border-radius: 999px; background: transparent; border: 1px solid var(--black-alpha-12); color: var(--black-alpha-72); font-size: 12px; cursor: pointer; font-family: inherit; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.ml-toolbar .chip:hover { color: var(--accent-black); }
.ml-toolbar .chip.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-40); font-weight: 600; }
.ml-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 20px 28px 28px; }
/* 卡片 · 视觉对齐 model-photo .model-card (padding 8 / gap 6 / 无 foot 行) */
.ml-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px; }
.ml-card {
position: relative;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 8px;
cursor: pointer;
display: flex; flex-direction: column; gap: 6px;
transition: background var(--t-base), border-color var(--t-base);
}
.ml-card:hover { background: var(--surface); }
.ml-card .placeholder { aspect-ratio: 3/4; border-radius: var(--r-sm); }
.ml-card .ml-card-nm { font-size: 12.5px; font-weight: 500; color: var(--accent-black); }
.ml-card .ml-card-sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
/* 「添加演员/场景」入口卡 · 与 model-photo 模特库视觉一致 */
.ml-card.ml-upload-card { border: 1.5px dashed var(--black-alpha-24); background: var(--surface); display: flex; flex-direction: column; gap: 8px; transition: border-color var(--t-base), background var(--t-base); }
.ml-card.ml-upload-card:hover { border-color: var(--heat); background: var(--heat-12); box-shadow: none; }
.ml-card.ml-upload-card:focus-visible { outline: 2px solid var(--heat); outline-offset: 2px; }
.ml-card.ml-upload-card .up-thumb { aspect-ratio: 3/4; border-radius: var(--r-sm); background: transparent; display: grid; place-items: center; }
.ml-card.ml-upload-card .up-plus { width: 44px; height: 44px; border-radius: 50%; background: var(--surface); border: 1px solid var(--black-alpha-12); color: var(--black-alpha-56); display: grid; place-items: center; transition: background var(--t-base), color var(--t-base), border-color var(--t-base), transform var(--t-base); }
.ml-card.ml-upload-card:hover .up-plus { background: var(--heat); border-color: var(--heat); color: var(--accent-white); transform: scale(1.06); }
.ml-card.ml-upload-card .up-plus svg { width: 22px; height: 22px; }
.ml-card.ml-upload-card .ml-card-nm { color: var(--accent-black); }
.ml-card.ml-upload-card:hover .ml-card-nm { color: var(--heat); }
/* ════════ 添加演员 / 场景 · 工作台画布 ════════ */
.ml-canvas {
position: absolute; inset: 0; z-index: 10;
background: var(--background-base);
display: flex; flex-direction: column;
opacity: 0; visibility: hidden;
transform: scale(.94); transform-origin: 32px 80px;
transition: opacity .28s ease, transform .32s cubic-bezier(.18,.72,.28,1), visibility .32s;
pointer-events: none;
}
.ml-canvas.show { opacity: 1; visibility: visible; transform: scale(1); pointer-events: auto; }
.ml-canvas-h { display: flex; align-items: center; gap: 12px; padding: 14px 28px; border-bottom: 1px solid var(--border-faint); flex-shrink: 0; background: var(--surface); }
.ml-canvas-h .back-btn { display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 10px; background: transparent; border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-72); font-family: inherit; font-size: 12px; cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
.ml-canvas-h .back-btn:hover { border-color: var(--heat-40); color: var(--heat); background: var(--heat-12); }
.ml-canvas-h .back-btn svg { width: 12px; height: 12px; }
.ml-canvas-h h3 { font-size: 15px; font-weight: 600; color: var(--accent-black); margin: 0; }
.ml-canvas-h .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
.ml-canvas-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 2fr 1fr; overflow: hidden; }
.mc-ai { position: relative; display: flex; flex-direction: column; min-height: 0; background: var(--background-base); }
.mc-up { position: relative; display: flex; flex-direction: column; min-height: 0; background: var(--surface); border-left: 1px solid var(--border-faint); }
.mc-stream { flex: 1; min-height: 0; overflow-y: auto; padding: 28px 28px 220px; background: var(--background-base); }
.mc-stream-inner { width: 100%; margin: 0 auto; display: flex; flex-direction: column; gap: 28px; }
.mc-empty { flex: 1; min-height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; padding: 40px; color: var(--black-alpha-56); text-align: center; }
.mc-empty .badge { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; color: var(--black-alpha-48); text-transform: uppercase; }
.mc-empty h2 { font-size: 22px; font-weight: 600; color: var(--accent-black); letter-spacing: -.015em; margin: 0; }
.mc-empty p { font-size: 13px; max-width: 460px; line-height: 1.6; margin: 0; }
.mc-empty .ic { width: 64px; height: 64px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; color: var(--heat); }
.mc-empty .ic svg { width: 28px; height: 28px; }
.mc-empty .examples { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; max-width: 720px; }
.mc-empty .examples .ex { padding: 6px 12px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-pill); font-size: 12px; color: var(--black-alpha-72); cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
.mc-empty .examples .ex:hover { border-color: var(--heat-20); color: var(--heat); background: var(--heat-12); }
.mc-msg { display: flex; flex-direction: column; gap: 14px; }
.mc-msg-prompt { display: flex; align-items: flex-start; gap: 12px; }
.mc-msg-prompt .quote { flex-shrink: 0; width: 28px; height: 28px; border-radius: var(--r-sm); background: var(--surface); border: 1px solid var(--border-faint); color: var(--heat); display: grid; place-items: center; }
.mc-msg-prompt .quote svg { width: 13px; height: 13px; }
.mc-msg-prompt .pt { flex: 1; min-width: 0; padding-top: 4px; }
.mc-msg-prompt .pt-text { font-size: 14px; color: var(--accent-black); line-height: 1.55; word-break: break-word; }
.mc-msg-prompt .pt-tags { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; align-items: center; }
.mc-msg-prompt .pt-tags .meta-chip { padding: 2px 8px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); }
.mc-msg-prompt .pt-tags .sep { color: var(--black-alpha-24); }
.mc-msg-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
@media (max-width: 1280px) { .mc-msg-grid { grid-template-columns: repeat(3, 1fr); } }
.mc-cell { position: relative; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; aspect-ratio: 3/4; cursor: pointer; }
.mc-cell.selected { border-color: var(--heat); box-shadow: 0 0 0 2px var(--heat-12); }
.mc-cell .ph-frame { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-32); letter-spacing: .02em; background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px); }
.mc-cell.gen .ph-frame { animation: mc-pulse 1.4s ease-in-out infinite; }
@keyframes mc-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
.mc-cell:hover { border-color: var(--black-alpha-32); }
.mc-cell .pick-badge { position: absolute; top: 6px; left: 6px; background: var(--heat); color: var(--accent-white); padding: 2px 7px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .04em; display: none; }
.mc-cell.selected .pick-badge { display: block; }
.mc-cell .cell-ops { position: absolute; top: 6px; right: 6px; display: flex; gap: 4px; opacity: 0; transition: opacity var(--t-base); z-index: 2; }
.mc-cell:hover .cell-ops { opacity: 1; }
.mc-cell .cell-ops button { width: 26px; height: 26px; background: rgba(255,255,255,.92); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--accent-black); cursor: pointer; display: grid; place-items: center; backdrop-filter: blur(4px); transition: border-color var(--t-base), color var(--t-base); }
.mc-cell .cell-ops button:hover { border-color: var(--heat); color: var(--heat); }
.mc-cell .cell-ops button svg { width: 12px; height: 12px; }
.mc-msg-ops { display: flex; gap: 8px; }
.mc-msg-ops button { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); font-size: 12.5px; color: var(--accent-black); font-family: inherit; cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
.mc-msg-ops button:hover { border-color: var(--heat-20); color: var(--heat); background: var(--heat-12); }
.mc-msg-ops button svg { width: 13px; height: 13px; }
.mc-input-wrap { position: absolute; left: 0; right: 0; bottom: 0; padding: 14px 28px 22px; background: linear-gradient(to bottom, transparent 0, var(--background-base) 24px); z-index: 5; }
.mc-input { max-width: 720px; margin: 0 auto; background: var(--surface); border: 1px solid var(--border-faint); border-radius: 18px; padding: 12px 14px 10px; display: flex; flex-direction: column; gap: 8px; box-shadow: 0 6px 24px rgba(0,0,0,.06); transition: border-color var(--t-base); }
.mc-input:focus-within { border-color: var(--heat-40); }
.mc-input-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.mc-input-top .add-btn { flex-shrink: 0; width: 64px; height: 64px; background: var(--background-lighter); border: 1px dashed var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; color: var(--black-alpha-56); cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
.mc-input-top .add-btn:hover { border-color: var(--heat-40); color: var(--heat); background: var(--heat-12); }
.mc-input-top .add-btn svg { width: 22px; height: 22px; }
.mc-input-refs { display: contents; }
.mc-input-ref { position: relative; width: 64px; height: 64px; border-radius: var(--r-md); overflow: hidden; background: var(--background-lighter); border: 1px solid var(--border-faint); flex-shrink: 0; }
.mc-input-ref img { width: 100%; height: 100%; object-fit: cover; }
.mc-input-ref .x { position: absolute; top: 3px; right: 3px; width: 18px; height: 18px; background: rgba(0,0,0,.7); color: var(--accent-white); border: 0; border-radius: 50%; display: grid; place-items: center; cursor: pointer; }
.mc-input-ref .x svg { width: 10px; height: 10px; }
.mc-input textarea#mc-input-text { width: 100%; border: 0; outline: 0; resize: none; background: transparent; font-family: inherit; font-size: 14px; line-height: 1.5; color: var(--accent-black); min-height: 44px; max-height: 220px; padding: 4px 2px; }
.mc-input textarea#mc-input-text::placeholder { color: var(--black-alpha-48); }
.mc-input-bottom { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.mc-input-bottom .param { position: relative; display: inline-flex; align-items: center; gap: 4px; height: 26px; padding: 0 9px; background: var(--background-lighter); border: 1px solid transparent; border-radius: var(--r-pill); font-size: 11.5px; color: var(--black-alpha-72); cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
.mc-input-bottom .param:hover { background: var(--surface); border-color: var(--border-faint); }
.mc-input-bottom .param .lbl-mono { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; margin-right: 1px; }
.mc-input-bottom .param svg { width: 10px; height: 10px; opacity: .6; }
.mc-input-bottom .right-meta { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.mc-input-bottom .right-meta .val { color: var(--accent-black); }
.mc-input .send-btn { flex-shrink: 0; width: 32px; height: 32px; background: var(--heat); color: var(--accent-white); border: 0; border-radius: var(--r-md); cursor: pointer; display: grid; place-items: center; transition: opacity var(--t-base), filter var(--t-base); margin-left: 8px; }
.mc-input .send-btn:hover { filter: brightness(1.05); }
.mc-input .send-btn:disabled { opacity: .4; cursor: not-allowed; }
.mc-input .send-btn svg { width: 15px; height: 15px; }
.mc-up-tabs { display: flex; border-bottom: 1px solid var(--border-faint); flex-shrink: 0; background: var(--surface); }
.mc-up-tab { flex: 1; height: 44px; background: transparent; border: 0; border-bottom: 2px solid transparent; font-family: inherit; font-size: 13px; font-weight: 500; color: var(--black-alpha-56); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.mc-up-tab:hover { color: var(--accent-black); background: var(--background-lighter); }
.mc-up-tab.active { color: var(--heat); border-bottom-color: var(--heat); font-weight: 600; background: var(--surface); }
.mc-up-body { flex: 1; min-height: 0; padding: 18px 20px 14px; display: flex; flex-direction: column; gap: 18px; overflow-y: auto; }
.mc-up-section { display: flex; flex-direction: column; gap: 8px; }
.mc-up-sec-h { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
.mc-up-name { width: 100%; height: 36px; padding: 0 12px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); font-family: inherit; font-size: 13.5px; color: var(--accent-black); outline: none; transition: border-color var(--t-base), background var(--t-base); }
.mc-up-name:focus { border-color: var(--heat-40); background: var(--surface); }
.mc-up-name::placeholder { color: var(--black-alpha-40); }
.mc-portrait-ai .empty { aspect-ratio: 3/4; max-height: 220px; border: 1.5px dashed var(--black-alpha-24); border-radius: var(--r-md); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; background: var(--background-lighter); text-align: center; padding: 14px; }
.mc-portrait-ai .empty[hidden] { display: none; }
.mc-portrait-ai .picked[hidden] { display: none; }
.mc-portrait-ai .empty .ic { width: 38px; height: 38px; border-radius: 50%; background: var(--surface); border: 1px solid var(--border-faint); display: grid; place-items: center; color: var(--black-alpha-48); }
.mc-portrait-ai .empty .ic svg { width: 16px; height: 16px; }
.mc-portrait-ai .empty .desc { font-size: 12.5px; color: var(--black-alpha-72); line-height: 1.55; }
.mc-portrait-ai .empty .mono { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }
.mc-portrait-ai .picked { position: relative; aspect-ratio: 3/4; max-height: 280px; background: var(--background-lighter); border: 1.5px solid var(--heat); border-radius: var(--r-md); overflow: hidden; }
.mc-portrait-ai .picked .ph-frame { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-32); letter-spacing: .02em; background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px); }
.mc-portrait-ai .picked .ops { position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; }
.mc-portrait-ai .picked .ops button { width: 26px; height: 26px; background: rgba(255,255,255,.92); border: 1px solid var(--border-faint); border-radius: var(--r-sm); display: grid; place-items: center; color: var(--accent-black); cursor: pointer; transition: border-color var(--t-base), color var(--t-base); }
.mc-portrait-ai .picked .ops button:hover { border-color: var(--heat); color: var(--heat); }
.mc-portrait-ai .picked .ops button svg { width: 12px; height: 12px; }
.mc-portrait-ai .picked .badge { position: absolute; top: 8px; left: 8px; background: var(--heat); color: var(--accent-white); padding: 2px 7px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .04em; }
.mc-portrait-local .drop { border: 1.5px dashed var(--black-alpha-24); border-radius: var(--r-md); padding: 20px 14px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; cursor: pointer; background: var(--background-lighter); transition: border-color var(--t-base), background var(--t-base); text-align: center; }
.mc-portrait-local .drop:hover, .mc-portrait-local .drop.dragover { border-color: var(--heat); background: var(--heat-12); }
.mc-portrait-local .drop .ic { width: 32px; height: 32px; background: var(--heat); color: var(--accent-white); border-radius: 50%; display: grid; place-items: center; }
.mc-portrait-local .drop .ic svg { width: 14px; height: 14px; }
.mc-portrait-local .drop .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); }
.mc-portrait-local .drop .d { font-size: 11px; color: var(--black-alpha-48); }
.mc-portrait-local .list-h { display: flex; align-items: center; gap: 4px; margin-top: 6px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }
.mc-portrait-local .list-h .ct { color: var(--accent-black); font-weight: 600; }
.mc-portrait-local .list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.mc-portrait-local .list:empty { display: none; }
.mc-portrait-local .thumb { position: relative; aspect-ratio: 3/4; border-radius: var(--r-sm); overflow: hidden; background: var(--background-lighter); border: 1px solid var(--border-faint); }
.mc-portrait-local .thumb img { width: 100%; height: 100%; object-fit: cover; }
.mc-portrait-local .thumb .x { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; background: rgba(0,0,0,.7); color: var(--accent-white); border: 0; border-radius: 50%; display: grid; place-items: center; cursor: pointer; }
.mc-portrait-local .thumb .x svg { width: 10px; height: 10px; }
.mc-up[data-kind="scene"] .mc-triview { display: none; }
.mc-triview .result-wrap { display: flex; flex-direction: column; gap: 8px; }
.mc-triview .result { position: relative; aspect-ratio: 16/9; background: var(--background-lighter); border: 1.5px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; transition: border-color var(--t-base); }
.mc-triview.has-result .result { border-color: var(--heat); }
.mc-triview .result .ph-frame { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-32); letter-spacing: .02em; background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px); pointer-events: none; }
.mc-triview .result.gen .ph-frame { animation: mc-pulse 1.4s ease-in-out infinite; }
.mc-triview .overlay-gen-btn { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2; display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 18px; background: var(--heat); color: var(--accent-white); border: 0; border-radius: var(--r-pill); font-family: inherit; font-size: 13px; font-weight: 500; cursor: pointer; box-shadow: 0 4px 12px rgba(250, 93, 25, .28); transition: filter var(--t-base), opacity var(--t-base), transform var(--t-base), box-shadow var(--t-base); }
.mc-triview .overlay-gen-btn:hover:not(:disabled) { filter: brightness(1.06); transform: translate(-50%, -50%) scale(1.03); box-shadow: 0 6px 16px rgba(250, 93, 25, .36); }
.mc-triview .overlay-gen-btn:disabled { background: var(--black-alpha-24); color: var(--surface); cursor: not-allowed; box-shadow: none; }
.mc-triview .overlay-gen-btn svg { width: 13px; height: 13px; }
.mc-triview .overlay-hint { position: absolute; left: 0; right: 0; bottom: 10px; text-align: center; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; pointer-events: none; z-index: 2; }
.mc-triview .result-ops { display: flex; gap: 6px; align-items: center; }
.mc-triview .result-ops .cost { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.mc-triview .result-ops button { display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 10px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); font-family: inherit; font-size: 11.5px; color: var(--accent-black); cursor: pointer; transition: border-color var(--t-base), color var(--t-base); }
.mc-triview .result-ops button:hover { border-color: var(--heat); color: var(--heat); }
.mc-triview .result-ops button svg { width: 11px; height: 11px; }
.mc-triview .history { display: flex; flex-direction: column; gap: 6px; }
.mc-triview .history .h-lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
.mc-triview .history .h-lbl .ct { color: var(--accent-black); font-weight: 600; }
.mc-triview .history .h-row { display: flex; gap: 6px; overflow-x: auto; padding: 2px; }
.mc-triview .history .h-thumb { flex: 0 0 auto; width: 72px; aspect-ratio: 16/9; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); position: relative; cursor: pointer; display: grid; place-items: center; overflow: hidden; transition: border-color var(--t-base); }
.mc-triview .history .h-thumb:hover { border-color: var(--heat-40); }
.mc-triview .history .h-thumb.active { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }
.mc-triview .history .h-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }
.mc-triview .history .h-thumb.active .v { color: var(--heat); font-weight: 600; }
.mc-triview .history .h-thumb .badge { position: absolute; top: 2px; left: 2px; font-family: var(--font-mono); font-size: 8.5px; padding: 0 4px; line-height: 12px; background: var(--heat); color: var(--accent-white); border-radius: 2px; letter-spacing: .02em; display: none; }
.mc-triview .history .h-thumb.active .badge { display: block; }
.mc-up-foot { padding: 12px 20px 16px; border-top: 1px solid var(--border-faint); display: flex; align-items: center; gap: 10px; flex-shrink: 0; flex-wrap: wrap; }
.mc-up-foot .stat { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mc-up-foot .stat b { color: var(--accent-black); font-weight: 600; }
.mc-up-foot .stat.ok { color: var(--heat); }
.mc-up-foot .commit-btn { display: inline-flex; align-items: center; gap: 5px; height: 32px; padding: 0 14px; background: var(--heat); color: var(--accent-white); border: 0; border-radius: var(--r-sm); font-family: inherit; font-size: 12.5px; cursor: pointer; transition: filter var(--t-base), opacity var(--t-base); }
.mc-up-foot .commit-btn:hover { filter: brightness(1.05); }
.mc-up-foot .commit-btn:disabled { opacity: .4; cursor: not-allowed; filter: none; }
.mc-up-foot .commit-btn svg { width: 12px; height: 12px; }
/* 离开工作台 · 二次确认 */
.mc-leave-bg { position: fixed; inset: 0; background: rgba(21,20,15,.42); backdrop-filter: blur(8px); z-index: 1300; display: none; align-items: center; justify-content: center; padding: 40px; }
.mc-leave-bg.show { display: flex; }
.mc-leave { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: 420px; max-width: 100%; box-shadow: 0 16px 48px rgba(0,0,0,.18); overflow: hidden; }
.mc-leave .lv-h { display: flex; align-items: center; gap: 10px; padding: 14px 20px 10px; }
.mc-leave .lv-h .ic { width: 28px; height: 28px; display: grid; place-items: center; border-radius: var(--r-sm); background: var(--crimson-bg); color: var(--accent-crimson); flex-shrink: 0; }
.mc-leave .lv-h .ic svg { width: 16px; height: 16px; }
.mc-leave .lv-h h3 { font-size: 15px; font-weight: 600; color: var(--accent-black); margin: 0; }
.mc-leave .lv-h .mono { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }
.mc-leave .lv-b { padding: 4px 20px 18px; font-size: 13px; line-height: 1.65; color: var(--black-alpha-72); }
.mc-leave .lv-b b { color: var(--accent-black); font-weight: 600; }
.mc-leave .lv-f { display: flex; align-items: center; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border-faint); background: var(--background-lighter); }
.mc-leave .lv-f .spacer { flex: 1; }
.mc-leave .lv-f .btn { height: 34px; padding: 0 14px; font-size: 13px; }
.mc-leave .btn-danger { background: var(--accent-crimson); color: var(--accent-white); border-color: var(--accent-crimson); font-weight: 600; }
.mc-leave .btn-danger:hover { background: var(--accent-crimson); border-color: var(--accent-crimson); filter: brightness(.95); }
/* ─── 添加来源 · 选择 modal (AI 生成 / 本地上传) ─── */
.ml-up-choice-bg { position: fixed; inset: 0; z-index: 1200; background: rgba(21, 20, 15, .42); display: none; place-items: center; padding: 16px; }
.ml-up-choice-bg.show { display: grid; }
.ml-up-choice { width: min(560px, 92vw); background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); box-shadow: 0 16px 48px rgba(21, 20, 15, .18); overflow: hidden; position: relative; }
.ml-up-choice .uc-h { display: flex; align-items: center; gap: 12px; padding: 18px 22px 14px; border-bottom: 1px solid var(--border-faint); }
.ml-up-choice .uc-h .ic-m { width: 36px; height: 36px; border-radius: var(--r-md); background: var(--heat-12); color: var(--heat); display: grid; place-items: center; flex-shrink: 0; }
.ml-up-choice .uc-h .ic-m svg { width: 18px; height: 18px; }
.ml-up-choice .uc-h .ti { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.ml-up-choice .uc-h .ti strong { font-size: 15px; color: var(--accent-black); font-weight: 600; }
.ml-up-choice .uc-h .ti .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.ml-up-choice .uc-h .uc-x { margin-left: auto; width: 28px; height: 28px; background: transparent; border: 0; border-radius: var(--r-sm); color: var(--black-alpha-56); cursor: pointer; display: grid; place-items: center; }
.ml-up-choice .uc-h .uc-x:hover { background: var(--background-lighter); color: var(--accent-black); }
.ml-up-choice .uc-h .uc-x svg { width: 14px; height: 14px; }
.ml-up-choice .uc-body { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 20px 22px 22px; }
.ml-up-choice .uc-option { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 18px 16px; text-align: left; cursor: pointer; font-family: inherit; display: flex; flex-direction: column; gap: 10px; transition: border-color var(--t-base), background var(--t-base); }
.ml-up-choice .uc-option:hover { border-color: var(--heat); background: var(--heat-12); }
.ml-up-choice .uc-option .opt-ic { width: 40px; height: 40px; border-radius: var(--r-md); background: var(--background-lighter); color: var(--heat); border: 1px solid var(--heat-20); display: grid; place-items: center; transition: background var(--t-base), color var(--t-base); }
.ml-up-choice .uc-option:hover .opt-ic { background: var(--heat); color: var(--accent-white); border-color: var(--heat); }
.ml-up-choice .uc-option .opt-ic svg { width: 18px; height: 18px; }
.ml-up-choice .uc-option .opt-t { font-size: 14px; font-weight: 600; color: var(--accent-black); }
.ml-up-choice .uc-option .opt-d { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-56); letter-spacing: .02em; line-height: 1.55; }
.ml-up-choice .uc-option .opt-tag { margin-top: auto; align-self: flex-start; font-family: var(--font-mono); font-size: 10.5px; padding: 2px 8px; border-radius: var(--r-sm); background: var(--background-lighter); color: var(--black-alpha-72); letter-spacing: .04em; }
.ml-up-choice .uc-option:hover .opt-tag { background: var(--surface); color: var(--heat); }
/* 新增人物 modal · 立绘 + 三视图 上传区 */
.upload-zone { aspect-ratio: 3/4; background: var(--background-lighter); border: 1px dashed var(--border-faint); border-radius: var(--r-md); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; cursor: pointer; transition: border-color var(--t-base), background var(--t-base); padding: 16px; text-align: center; color: var(--black-alpha-56); font-size: 12px; }
.upload-zone:hover { border-color: var(--heat); background: var(--heat-12); color: var(--heat); }
.upload-zone.lead { aspect-ratio: 3/4; }
.upload-zone svg { width: 20px; height: 20px; }
.upload-zone-tri { aspect-ratio: 1; padding: 8px; font-size: 10.5px; }
.prompt-box { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; font-size: 12px; color: var(--black-alpha-56); margin-top: 8px; line-height: 1.55; font-family: var(--font-mono); letter-spacing: .01em; transition: border-color var(--t-base), background var(--t-base); }
.prompt-box[contenteditable="true"] { cursor: text; outline: none; }
.prompt-box[contenteditable="true"]:hover { border-color: var(--heat-20); }
.prompt-box[contenteditable="true"]:focus { border-color: var(--heat); background: var(--surface); color: var(--accent-black); box-shadow: 0 0 0 3px var(--heat-12); }
.fail-icon { width: 28px; height: 28px; background: var(--accent-crimson); color: var(--accent-white); display: grid; place-items: center; font-weight: 700; font-size: 16px; border-radius: 50%; }
/* === STAGE 3 · 故事板(略缩图竖向侧栏 + 主图区)=== */
.stage-storyboard { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 16px; align-items: stretch; }
.sb-canvas { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px; display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 14px; }
.sb-scenes-col { display: flex; flex-direction: column; gap: 10px; overflow-y: auto; overflow-x: hidden; max-height: 560px; padding-right: 6px; scrollbar-width: thin; }
.sb-scenes-col::-webkit-scrollbar { width: 6px; }
.sb-scenes-col::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 4px; }
.sb-scene-thumb { flex: 0 0 auto; cursor: pointer; display: flex; flex-direction: column; gap: 6px; padding: 6px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); transition: border-color var(--t-base), background var(--t-base); }
.sb-scene-thumb:hover { background: var(--background-lighter); }
.sb-scene-thumb.selected { border-color: var(--heat); background: var(--heat-12); }
.sb-scene-thumb .placeholder { aspect-ratio: 1; }
.sb-scene-thumb .nm { font-size: 11.5px; font-weight: 500; color: var(--accent-black); }
.sb-scene-thumb .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); }
.sb-main-img { aspect-ratio: 16/9; min-height: 0; }
.sb-rerun-note {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
margin-bottom: 14px;
background: rgba(180,83,9,.08);
border: 1px solid rgba(180,83,9,.20);
border-radius: var(--r-md);
color: #7C3A05;
line-height: 1.55;
}
.sb-rerun-note .warn-ic {
width: 22px;
height: 22px;
border-radius: var(--r-sm);
background: rgba(180,83,9,.12);
color: #B45309;
display: grid;
place-items: center;
flex: 0 0 22px;
}
.sb-rerun-note .warn-ic svg {
width: 14px;
height: 14px;
}
.sb-rerun-note .note-copy {
min-width: 0;
font-size: 11.5px;
}
.sb-rerun-note strong { color: #B45309; }
.sb-rerun-note a { color: #B45309; text-decoration: underline; text-underline-offset: 2px; }
.sb-stage-actions { display: flex; gap: 8px; margin-top: 14px; margin-bottom: 12px; }
/* 故事板历史版本 */
.sb-history { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-faint); }
.sb-history-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 10px; }
.sb-history-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
.sb-history-row::-webkit-scrollbar { height: 6px; }
.sb-history-row::-webkit-scrollbar-thumb { background: var(--border-faint); }
.sb-history-thumb { flex: 0 0 80px; min-width: 80px; display: flex; flex-direction: column; gap: 4px; padding: 4px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; transition: border-color var(--t-base); }
.sb-history-thumb:hover { border-color: var(--heat); }
.sb-history-thumb.current { border-color: var(--heat); background: var(--heat-12); }
.sb-history-thumb .placeholder { aspect-ratio: 1; }
.sb-history-thumb .ts { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); text-align: center; }
.sb-history-thumb.current .ts { color: var(--heat); font-weight: 600; }
.pill-cta { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 14px; border-radius: 999px; font-size: 12.5px; cursor: pointer; font-family: inherit; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.pill-cta.heat { background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); }
.pill-cta.heat:hover { box-shadow: var(--shadow-cta-hover); }
.pill-cta.ghost { background: var(--surface); color: var(--accent-black); border: 1px solid var(--border-faint); }
.pill-cta.ghost:hover { background: var(--background-lighter); border-color: var(--heat-20); color: var(--heat); }
.pill-cta svg { width: 13px; height: 13px; }
/* === STAGE 3 / 4 跳过条 === */
.skip-row { display: flex; justify-content: flex-end; margin-bottom: 12px; }
.sb-side .pane { padding: 18px; }
.prompt-edit { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px 14px; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.7; color: var(--accent-black); white-space: pre-wrap; min-height: 200px; outline: none; letter-spacing: .01em; cursor: text; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); }
.prompt-edit:hover { border-color: var(--heat-20); }
.prompt-edit:focus { border-color: var(--heat); background: var(--surface); box-shadow: 0 0 0 3px var(--heat-12); }
.asset-tag { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-pill); font-size: 11.5px; }
.asset-tag .dotc { width: 14px; height: 14px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: 50%; }
/* === STAGE 4 · 视频片段 === */
.queue-bar { display: flex; align-items: center; gap: 16px; padding: 14px 18px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); margin-bottom: 18px; }
.queue-bar .bar-wrap { flex: 1; height: 6px; background: var(--background-lighter); overflow: hidden; }
.queue-bar .bar-wrap > span { display: block; height: 100%; background: var(--heat); }
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; align-content: start; align-items: start; }
.video-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base); }
.video-card:hover { border-color: var(--heat-40); }
.video-thumb { aspect-ratio: 9/16; max-height: 320px; position: relative; border-radius: var(--r-md) var(--r-md) 0 0; }
.video-thumb .play { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(0,0,0,0.05); cursor: pointer; opacity: 0; transition: opacity .15s; }
.video-thumb:hover .play { opacity: 1; }
.video-thumb .btn-play { width: 36px; height: 36px; background: rgba(0,0,0,.7); color: var(--accent-white); border-radius: 50%; display: grid; place-items: center; }
.video-card .body { padding: 10px 12px; }
/* 视频详情 modal 大视频 + 历史版本 */
.vd-main-wrap { display: flex; gap: 18px; align-items: flex-start; }
.vd-main { flex: 0 0 280px; aspect-ratio: 9/16; max-height: 460px; }
.vd-main .placeholder { aspect-ratio: 9/16; height: 100%; }
.vd-info { flex: 1; min-width: 0; }
.vd-prompt-field { margin-top: 16px; }
.vd-prompt-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 6px; }
.vd-prompt-head .label { font-family: var(--font-mono); font-size: 11px; font-weight: 500; color: var(--black-alpha-56); letter-spacing: .04em; }
.vd-prompt-head .hint { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.vd-prompt-edit { min-height: 120px; }
.vd-prompt-edit:empty::before { content: attr(data-placeholder); color: var(--black-alpha-40); }
.vd-history { margin-top: 16px; }
.vd-history-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
.vd-history-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
.vd-history-thumb { flex: 0 0 64px; min-width: 64px; display: flex; flex-direction: column; gap: 4px; padding: 4px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; transition: border-color var(--t-base); position: relative; }
.vd-history-thumb:hover { border-color: var(--heat); }
.vd-history-thumb.current { border-color: var(--heat); background: var(--heat-12); }
.vd-history-thumb.adopted::after { content: ''; position: absolute; top: 2px; right: 2px; width: 14px; height: 14px; background: var(--heat); border-radius: 50%; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 9px 9px; }
.vd-history-thumb .placeholder { aspect-ratio: 9/16; }
.vd-history-thumb .ts { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); text-align: center; }
.vd-modal-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.vd-modal-actions .pill-cta { min-width: 96px; justify-content: center; }
/* === STAGE 5 · 编辑器 === */
.editor { display: grid; grid-template-columns: 1fr 280px; grid-template-rows: 1fr auto; gap: 0; height: 580px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); }
.editor-preview { padding: 16px; border-right: 1px solid var(--border-faint); border-bottom: 1px solid var(--border-faint); display: flex; flex-direction: column; gap: 12px; }
.editor-preview .canvas { flex: 1 1 0; min-height: 0; aspect-ratio: 9/16; margin: 0 auto; background:
repeating-linear-gradient(135deg, rgba(0,0,0,0.03) 0 1px, transparent 1px 12px),
var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
display: grid; place-items: center;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 12px; }
.editor-preview .controls { display: flex; align-items: center; gap: 8px; justify-content: center; }
.ctl-btn { width: 36px; height: 36px; border: 1px solid var(--border-faint); background: var(--surface); color: var(--black-alpha-56); border-radius: var(--r-md); display: grid; place-items: center; cursor: pointer; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.ctl-btn:hover { color: var(--heat); border-color: var(--heat-40); background: var(--heat-12); }
.editor-props { padding: 16px; border-bottom: 1px solid var(--border-faint); overflow-y: auto; }
.props-tabs { display: flex; gap: 0; margin-bottom: 14px; border-bottom: 1px solid var(--border-faint); }
.props-tabs > div { padding: 8px 12px; font-size: 12.5px; color: var(--black-alpha-56); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.props-tabs > div.active { color: var(--heat); border-bottom-color: var(--heat); font-weight: 600; }
.style-swatch { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.swatch-card { padding: 10px; border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; }
.swatch-card:hover { background: var(--background-lighter); }
.swatch-card.selected { border-color: var(--heat); background: var(--heat-12); }
.swatch-card .demo { font-size: 12px; padding: 6px 8px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); margin-bottom: 4px; text-align: center; }
.swatch-card .demo.b { background: var(--accent-black); color: var(--accent-white); font-family: serif; }
.swatch-card .demo.c { color: var(--heat); -webkit-text-stroke: 0.5px var(--accent-black); }
.swatch-card .demo.d { background: var(--accent-honey); color: var(--accent-black); font-weight: 700; }
.swatch-card .nm { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; }
.props-row { display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border-faint); font-size: 12.5px; }
.props-row:last-child { border-bottom: 0; }
.props-row .k { color: var(--black-alpha-48); flex: 1; font-family: var(--font-mono); font-size: 11px; letter-spacing: .02em; }
.input-mini { width: 90px; padding: 0 10px; height: 28px; font-size: 12px; border-radius: var(--r-md); background: var(--surface); border: 1px solid var(--black-alpha-12); }
/* ── 时间轴 · 剪映风格(Restraint 浅色规范) ── */
.timeline { grid-column: 1 / -1; padding: 14px 16px; background: var(--background-base); }
/* 工具栏 */
.tl-toolbar { display: flex; align-items: center; gap: 4px; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-faint); }
.tl-toolbar .tl-action { display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 10px; background: transparent; border: 1px solid transparent; border-radius: var(--r-sm); color: var(--black-alpha-72); font-size: 12px; font-family: inherit; cursor: pointer; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.tl-toolbar .tl-action:hover { background: var(--surface); border-color: var(--border-faint); color: var(--accent-black); }
.tl-toolbar .tl-action.danger:hover { color: var(--accent-crimson); border-color: var(--accent-crimson); }
.tl-toolbar .tl-action svg { width: 13px; height: 13px; }
.tl-toolbar .tl-sep { width: 1px; height: 16px; background: var(--border-faint); margin: 0 4px; }
.tl-toolbar .tl-zoom { display: inline-flex; align-items: center; gap: 8px; }
.tl-toolbar .tl-zoom .lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }
.tl-toolbar .tl-zoom input[type="range"] { width: 120px; accent-color: var(--heat); }
/* 时间刻度 · 主/次刻度 */
.tl-ruler { display: grid; grid-template-columns: 80px 1fr; align-items: end; padding: 0; margin-bottom: 4px; }
.tl-ruler .l { font-family: var(--font-mono); color: var(--black-alpha-48); padding: 0 4px 4px; font-size: 10.5px; letter-spacing: .04em; align-self: end; }
.tl-ruler .rule-track { position: relative; height: 22px; border-bottom: 1px solid var(--border-faint); }
.tl-ruler .rule-track .tick { position: absolute; bottom: 0; width: 1px; background: var(--black-alpha-24); }
.tl-ruler .rule-track .tick.major { height: 8px; background: var(--black-alpha-48); }
.tl-ruler .rule-track .tick.minor { height: 4px; }
.tl-ruler .rule-track .t { position: absolute; bottom: 10px; transform: translateX(-50%); font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; white-space: nowrap; }
/* 轨道行 */
.tl-track { display: grid; grid-template-columns: 80px 1fr; align-items: center; padding: 3px 0; }
.tl-track .label { display: flex; align-items: center; gap: 6px; padding-left: 4px; font-size: 11.5px; color: var(--black-alpha-72); font-weight: 500; }
.tl-track .label .ico { width: 18px; height: 18px; display: grid; place-items: center; border-radius: var(--r-sm); flex-shrink: 0; }
.tl-track .label .ico svg { width: 12px; height: 12px; }
.tl-track .label.video .ico { background: var(--heat-12); color: var(--heat); }
.tl-track .label.subtitle .ico { background: var(--forest-bg); color: var(--accent-forest); }
.tl-track .label.bgm .ico { background: rgba(144, 97, 255, .10); color: var(--accent-amethyst); }
/* 轨道 lane · 绝对定位容器 + 1s 网格线 */
.tl-track .lane {
position: relative;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
}
.tl-track.video-track .lane { height: 46px; }
.tl-track.subtitle-track .lane { height: 28px; }
.tl-track.bgm-track .lane { height: 34px; }
.tl-track .lane::before {
content: ""; position: absolute; inset: 0;
background-image: repeating-linear-gradient(to right,
var(--border-faint) 0, var(--border-faint) 1px,
transparent 1px, transparent calc(100% / 15));
pointer-events: none; opacity: .55;
border-radius: inherit;
}
/* 片段公共 · 绝对定位 · left/width 由 data 驱动 */
.clip {
position: absolute; top: 3px; bottom: 3px;
display: flex; align-items: center; gap: 6px;
padding: 0 8px; font-size: 11px;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer; overflow: hidden; white-space: nowrap; user-select: none;
box-sizing: border-box;
}
.clip:hover { filter: brightness(1.04); }
.clip .num { font-family: var(--font-mono); font-weight: 700; opacity: .85; flex-shrink: 0; }
.clip .lbl { overflow: hidden; text-overflow: ellipsis; }
/* 视频片段 · 内嵌胶卷帧条(预览缩略) */
.clip.video {
background: var(--heat-12); border-color: var(--heat-40); color: var(--heat);
}
.clip.video .frames {
position: absolute; inset: 0;
display: flex; gap: 0;
pointer-events: none; z-index: 0;
border-radius: inherit;
overflow: hidden;
}
.clip.video .frames .fr {
flex: 1; min-width: 0;
background:
repeating-linear-gradient(45deg,
transparent 0, transparent 4px,
rgba(38,38,38,.06) 4px, rgba(38,38,38,.06) 5px),
rgba(250,93,25,.10);
}
.clip.video .frames .fr + .fr { border-left: 1px solid rgba(255,255,255,.55); }
.clip.video:hover .frames .fr { background-color: rgba(250,93,25,.18); }
.clip.video .num, .clip.video .lbl { position: relative; z-index: 1; }
.clip.video.selected {
background: var(--heat); color: var(--accent-white); border-color: var(--heat);
box-shadow: var(--shadow-cta);
z-index: 3;
}
.clip.video.selected .frames .fr {
background:
repeating-linear-gradient(45deg,
transparent 0, transparent 4px,
rgba(255,255,255,.22) 4px, rgba(255,255,255,.22) 5px),
rgba(255,255,255,.06);
}
.clip.video.selected .frames .fr + .fr { border-left-color: rgba(255,255,255,.28); }
/* 字幕片段 · 薄条 + 引号符号 */
.clip.subtitle {
background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest);
font-size: 11px;
}
.clip.subtitle .lbl::before {
content: "“"; font-family: serif; font-size: 14px; opacity: .55; margin-right: 2px;
}
.clip.subtitle:hover { background: rgba(31, 138, 81, .14); }
.clip.subtitle.selected {
background: var(--accent-forest); color: var(--accent-white); border-color: var(--accent-forest);
box-shadow: 0 2px 6px rgba(31, 138, 81, .35);
z-index: 3;
}
.clip.subtitle.selected .lbl::before { opacity: .8; }
/* BGM 片段 · 内嵌波形 */
.clip.bgm {
background: rgba(144,97,255,.10); border-color: rgba(144,97,255,.30);
color: var(--accent-amethyst);
}
.clip.bgm .wave {
position: absolute; inset: 6px 10px; pointer-events: none;
opacity: .5; display: block; z-index: 0;
}
.clip.bgm .wave svg { width: 100%; height: 100%; display: block; }
.clip.bgm:hover { background: rgba(144,97,255,.16); }
.clip.bgm.selected {
background: var(--accent-amethyst); color: var(--accent-white); border-color: var(--accent-amethyst);
box-shadow: 0 2px 6px rgba(144, 97, 255, .35);
z-index: 3;
}
.clip.bgm.selected .wave { opacity: .75; }
.clip.bgm.selected .wave svg rect { fill: rgba(255,255,255,.9); }
.clip.bgm .lbl, .clip.bgm .num { position: relative; z-index: 1; }
/* 通用 trim 把手 · 三轨皆可剪 */
.clip.selected .trim-l,
.clip.selected .trim-r {
position: absolute; top: 3px; bottom: 3px; width: 4px;
background: rgba(255,255,255,.92); border-radius: 2px;
z-index: 5;
cursor: ew-resize; pointer-events: auto;
}
.clip.selected .trim-l { left: 2px; }
.clip.selected .trim-r { right: 2px; }
.clip.selected .trim-l::before,
.clip.selected .trim-r::before {
content: ""; position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 2px; height: 10px;
background: rgba(38,38,38,.4); border-radius: 1px;
}
/* 拖拽时片段移动光标 */
.clip { cursor: pointer; }
.clip.selected { cursor: grab; }
.clip.selected:active { cursor: grabbing; }
/* Playhead · 顶到时间尺、贯穿三条轨 · 可拖拽 */
.playhead {
position: absolute; top: -90px; bottom: -44px;
width: 1.5px; background: var(--heat);
z-index: 10;
pointer-events: none;
}
.playhead::before {
content: ''; position: absolute; top: -4px; left: 50%;
transform: translateX(-50%) rotate(45deg);
width: 10px; height: 10px; background: var(--heat);
box-shadow: 0 0 0 1.5px var(--surface);
border-radius: 1px;
pointer-events: none;
}
.playhead .ph-grab {
position: absolute; top: -10px; left: 50%; transform: translateX(-50%);
width: 18px; height: 18px;
cursor: ew-resize; pointer-events: auto;
border-radius: 50%;
}
.playhead.is-dragging { background: var(--heat); }
.timeline.is-dragging-playhead { cursor: ew-resize; user-select: none; }
/* Ruler 可点击 seek */
.tl-ruler .rule-track { cursor: pointer; }
.tl-ruler .rule-track:hover .tick.major { background: var(--heat-40); }
/* Lane 也可点击 seek (空白处) */
.tl-track .lane { cursor: pointer; }
/* 播放/暂停按钮 active 态 · ctl-btn.is-playing 显示暂停 icon */
.ctl-btn { transition: color var(--t-base); }
.ctl-btn.is-playing { color: var(--heat); }
/* 工具栏按钮 disabled */
.tl-action:disabled { opacity: .4; cursor: not-allowed; }
.tl-action:disabled:hover { background: transparent; border-color: transparent; color: var(--black-alpha-72); }
</style>
</head>
<body>
<div id="page">
<!-- Project header 移除 · 改为 topbar 中部 stage-pill(Shell.render 后注入)-->
<!-- Stage stepper 移除 · 由 topbar 中部 5 个圆点替代,JS 仍读 .sp-dot[data-stage] 维持单一源 -->
<div class="stage-pill-anchor" id="stage-pill-anchor" hidden>
<a class="sp-dot" data-stage="1" href="#stage-1"><span class="d"></span><span class="l">脚本</span></a>
<span class="sp-line"></span>
<a class="sp-dot" data-stage="2" href="#stage-2"><span class="d"></span><span class="l">基础资产</span></a>
<span class="sp-line"></span>
<a class="sp-dot" data-stage="3" href="#stage-3"><span class="d"></span><span class="l">故事板</span></a>
<span class="sp-line"></span>
<a class="sp-dot" data-stage="4" href="#stage-4"><span class="d"></span><span class="l">视频</span></a>
<span class="sp-line"></span>
<a class="sp-dot" data-stage="5" href="#stage-5"><span class="d"></span><span class="l">拼接导出</span></a>
</div>
<!-- ============= STAGE 1 · 脚本 ============= -->
<section class="stage active" data-stage-pane="1">
<div class="stage-script">
<div class="pane shot-list">
<div class="pane-h">
<strong>镜头脚本</strong>
<span class="muted-2 mono" id="shots-meta" style="font-size:11px;">· 空 · 待生成</span>
<div class="script-tags" id="script-tags">
<div class="tag-group" data-kind="char">
<span class="tg-lbl">// 人物</span>
<button class="tag-add" type="button" aria-label="添加人物">+</button>
</div>
<div class="tag-group" data-kind="scene">
<span class="tg-lbl">// 场景</span>
<button class="tag-add" type="button" aria-label="添加场景">+</button>
</div>
</div>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" id="chat-regen-btn">↻ 整体重写</button>
</div>
<div class="shots-body" id="shots-body">
<!-- JS 注入空态/镜头卡片 -->
</div>
</div>
<div class="stage-script-gutter" id="stage-script-gutter" role="separator" aria-orientation="vertical" aria-label="拖动调整脚本助手宽度"></div>
<div class="pane chat-pane">
<div class="pane-h">
<div class="ai-avatar">AI</div>
<strong>脚本助手</strong>
<span class="muted-2 mono" style="font-size:11px;">· GPT-4o</span>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" id="chat-clear-btn">清空对话</button>
</div>
<div class="chat-body" id="chat-body">
<!-- JS 注入空态/对话内容 -->
</div>
<div class="chat-input">
<div class="chat-input-card">
<div class="chat-attach-row" id="chat-attach-row" hidden></div>
<textarea class="chat-input-area" id="chat-textarea" placeholder='聊聊你的脚本想法,或输入 "@" 引用镜头……' rows="2"></textarea>
<div class="chat-input-foot">
<button class="chat-icon-btn" id="chat-upload-btn" title="上传脚本附件" aria-label="上传脚本附件">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
</button>
<span class="spacer"></span>
<button class="chat-send-btn" id="chat-send-btn" title="发送" aria-label="发送">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</button>
</div>
</div>
<input type="file" id="chat-upload-input" hidden accept=".txt,.md,.docx,.doc,.pdf,.srt,.json">
</div>
</div>
</div>
<div class="stage-foot">
<div class="info"><span class="mono">[ LLM 用量 ~2.4k tokens · ¥0.04 · 失败不扣 · 通过后扣 ]</span></div>
<div class="hstack">
<button class="btn" onclick="Shell.toast('重新生成', 'POST /script/regen')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9"/><path d="M21 4v5h-5"/><path d="M20 12a8 8 0 0 1-14 5.5L3 15"/><path d="M3 20v-5h5"/></svg> 重新生成全部</button>
<button class="btn btn-primary btn-lg" onclick="location.hash='#stage-2'">确认脚本,进入下一步 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg></button>
</div>
</div>
</section>
<!-- ============= STAGE 2 · 基础资产 ============= -->
<section class="stage" data-stage-pane="2">
<div class="stage-assets">
<div class="asset-side">
<div class="ttab active" data-jump="asset-sec-products"><span>商品</span><span class="num">3 张</span></div>
<div class="ttab" data-jump="asset-sec-characters"><span>人物</span><span class="num">2/2</span></div>
<div class="ttab" data-jump="asset-sec-scenes"><span>场景</span><span class="num">3/3</span></div>
<div class="info">
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。
<br><br>
<strong class="mono">// 人物 +¥0.20/张</strong>
<strong class="mono">// 场景 +¥0.15/张</strong>
<span style="color:var(--black-alpha-48);">商品图无成本(直接复用商品库)</span>
</div>
</div>
<div class="asset-main">
<!-- ===== 商品(项目内只有 1 个商品,从 URL ?product= 取)===== -->
<section class="asset-sec" id="asset-sec-products">
<div class="sec-h">
<h3>商品 · <span id="asset-prod-name">透真补水面膜</span></h3>
<span class="spacer"></span>
</div>
<div class="prod-row">
<div class="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id="prod-main" id="asset-prod-card">
<div class="placeholder prod-thumb">
<span class="tri-missing-badge" id="asset-prod-tri-badge" tabindex="0" role="button" aria-label="缺三视图,查看说明">
<span class="ico" aria-hidden="true"></span>
<span class="lbl-mono">缺三视图</span>
<span class="tri-missing-pop" role="tooltip">
<span class="pop-h">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 9v4M12 17h.01"/><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg>
MISSING TRI-VIEW
</span>
<span class="pop-body">该商品还未生成 <b>正 / 侧 / 背</b> 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。</span>
<span class="pop-tip">建议:点右下 <b>AI 生成三视图</b> 先补齐三视图,再发起后续生成。</span>
</span>
</span>
<span class="ph-frame" id="asset-prod-thumb-label">透真补水面膜 · 主图</span>
</div>
<div class="prod-body">
<div class="prod-name" id="asset-prod-card-name">透真补水面膜</div>
<div class="prod-cat">美妆个护</div>
<div class="prod-date">2026-05-15 创建</div>
</div>
<div class="prod-action" id="asset-prod-action">
<button class="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn">
<svg class="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z"/>
<path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z"/>
</svg>
AI 生成三视图
</button>
</div>
</div>
<div class="prod-preview" id="asset-prod-preview">
<div class="prod-preview-h">// 三视图预览 · <span id="prod-preview-status">生成中</span></div>
<div class="placeholder prod-preview-img" id="prod-preview-img"></div>
<div class="prod-preview-foot" id="prod-preview-foot"></div>
<div class="prod-preview-history" id="prod-preview-history">
<div class="h-lbl">// 历史版本 · <span class="ct" id="prod-preview-history-count">0</span></div>
<div class="h-row" id="prod-preview-history-row"></div>
</div>
</div>
</div>
</section>
<!-- ===== 人物 ===== -->
<section class="asset-sec" id="asset-sec-characters">
<div class="sec-h">
<h3>人物 · 2 个</h3>
<span class="spacer"></span>
<button class="btn btn-sm" id="asset-add-character" hidden>+ 新增人物</button>
</div>
<div class="asset-grid-2">
<div class="asset-card-2" data-asset-kind="character" data-asset-id="ch-linxi">
<div class="placeholder thumb-2"><span class="ph-frame">林夕 · 都市白领</span></div>
<div class="body-2">
<div class="hstack"><strong style="font-size:13.5px;">主角 · 林夕</strong><span class="spacer"></span></div>
<div class="prompt-box" contenteditable="true" spellcheck="false" data-stop>25-30 岁都市白领,长发,穿宽松米色家居服,温柔但带点疲倦感。</div>
<div class="hstack" style="margin-top:10px;">
<button class="btn btn-ghost btn-sm" data-stop data-rerun>重跑</button>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" data-stop data-replace>替换</button>
</div>
</div>
</div>
<div class="asset-card-2" data-asset-kind="character" data-asset-id="ch-anan">
<div class="placeholder thumb-2">
<div style="display:flex; flex-direction:column; gap:8px; align-items:center;">
<div class="spinner"></div>
<span class="ph-frame">生成中 · 约 8s</span>
</div>
</div>
<div class="body-2">
<div class="hstack"><strong style="font-size:13.5px;">朋友/同事 · 阿楠</strong><span class="spacer"></span></div>
<div class="prompt-box" contenteditable="true" spellcheck="false" data-stop>25-30 岁同龄女性,短发,穿白色衬衫,妆容精致皮肤好,作为对比。</div>
<div class="hstack" style="margin-top:10px;">
<button class="btn btn-ghost btn-sm" data-stop data-rerun disabled>重跑</button>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" data-stop data-replace disabled>替换</button>
</div>
</div>
</div>
</div>
</section>
<!-- ===== 场景 ===== -->
<section class="asset-sec" id="asset-sec-scenes">
<div class="sec-h">
<h3>场景 · 3 个</h3>
<span class="spacer"></span>
<button class="btn btn-sm" id="asset-add-scene" hidden>+ 新增场景</button>
</div>
<div class="asset-grid-2">
<div class="asset-card-2" data-asset-kind="scene" data-asset-id="sc-desk">
<div class="placeholder thumb-2"><span class="ph-frame">深夜办公桌</span></div>
<div class="body-2">
<div class="hstack"><strong style="font-size:13.5px;">深夜办公桌</strong><span class="spacer"></span></div>
<div class="prompt-box" contenteditable="true" spellcheck="false" data-stop>深夜居家办公环境,木质书桌,台灯暖光,电脑屏幕亮着。</div>
<div class="hstack" style="margin-top:10px;">
<button class="btn btn-ghost btn-sm" data-stop data-rerun>重跑</button>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" data-stop data-replace>替换</button>
</div>
</div>
</div>
<div class="asset-card-2" data-asset-kind="scene" data-asset-id="sc-bed">
<div class="placeholder thumb-2"><span class="ph-frame">床头特写</span></div>
<div class="body-2">
<div class="hstack"><strong style="font-size:13.5px;">卧室床头</strong><span class="spacer"></span></div>
<div class="prompt-box" contenteditable="true" spellcheck="false" data-stop>米白色床品,木质床头柜,闹钟显示晚间时间。</div>
<div class="hstack" style="margin-top:10px;">
<button class="btn btn-ghost btn-sm" data-stop data-rerun>重跑</button>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" data-stop data-replace>替换</button>
</div>
</div>
</div>
<div class="asset-card-2" data-asset-kind="scene" data-asset-id="sc-subway">
<div class="placeholder thumb-2">
<div style="display:flex; flex-direction:column; gap:6px; align-items:center;">
<div class="fail-icon">!</div>
<span class="ph-frame">生成失败</span>
</div>
</div>
<div class="body-2">
<div class="hstack"><strong style="font-size:13.5px;">通勤地铁</strong><span class="spacer"></span><span class="pill err"><span class="dot"></span>失败</span></div>
<div class="prompt-box" contenteditable="true" spellcheck="false" data-stop>早高峰地铁车厢,光线偏冷,年轻通勤族,氛围紧张。</div>
<div class="hstack" style="margin-top:10px;">
<button class="btn btn-ghost btn-sm" data-stop data-rerun>重跑</button>
<span class="spacer"></span>
<button class="btn btn-ghost btn-sm" data-stop data-replace>替换</button>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
<div class="stage-foot">
<div class="info"><span class="mono">[ 已确认 ¥0.85 · 待生成 ¥0.20 · 失败 ¥0(不扣) ]</span></div>
<div class="hstack">
<button class="btn" onclick="location.hash='#stage-1'"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> 返回脚本</button>
<button class="btn btn-primary btn-lg" onclick="location.hash='#stage-3'">确认资产,进入故事板 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg></button>
</div>
</div>
</section>
<!-- ============= STAGE 3 · 故事板(按场分) ============= -->
<section class="stage" data-stage-pane="3">
<div class="stage-storyboard">
<div class="sb-canvas">
<div class="sb-scenes-col" id="sb-scenes-row">
<!-- JS 注入 略缩图 (竖向) -->
</div>
<div class="placeholder sb-main-img" id="sb-main-img"><span class="ph-frame">未选择</span></div>
</div>
<div class="sb-side">
<div class="pane" style="padding:18px;">
<div class="hstack" style="margin-bottom:10px;">
<strong style="font-size:14px;">故事板 · <span id="sb-side-scene">场 1</span></strong>
<span class="spacer"></span>
<span class="pill ok"><span class="dot"></span>已生成</span>
</div>
<div class="muted-2" style="font-size:12px; line-height:1.55; margin-bottom:10px;">
整张故事板由 image-2 一次性输出,包含画面 + 镜头说明。
</div>
<div class="sb-rerun-note">
<span class="warn-ic" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4M12 17h.01"/></svg>
</span>
<div class="note-copy">
<strong>仅支持整张重跑</strong> · 不能局部改某一镜。如需调单镜,先在 <a href="#stage-1">Stage 1 脚本</a> 改镜头描述,再回此处整张重跑。
</div>
</div>
<div class="muted mono" style="font-size:11px; font-weight:500; margin-bottom:6px; letter-spacing:.04em;">// 本场提示词</div>
<div class="prompt-edit" contenteditable="true" id="sb-prompt-edit"></div>
<div class="sb-stage-actions">
<button class="pill-cta heat" id="sb-rerun-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9"/><path d="M21 4v5h-5"/><path d="M20 12a8 8 0 0 1-14 5.5L3 15"/><path d="M3 20v-5h5"/></svg>
整张重跑
</button>
<span class="spacer"></span>
<span class="muted-2 mono" style="font-size:11px; align-self: center;">~¥0.45/场</span>
</div>
<div class="sb-history">
<div class="sb-history-h">// 历史版本(<span id="sb-history-ct">0</span>)</div>
<div class="sb-history-row" id="sb-history-row">
<div style="font-size: 11.5px; color: var(--black-alpha-48); padding: 12px 4px;">// 暂无历史版本</div>
</div>
</div>
<div class="divider" style="margin-top: 16px;"></div>
<div class="muted mono" style="font-size:11px; font-weight:500; margin-bottom:8px; letter-spacing:.04em;">// 绑定的资产</div>
<div style="display:flex; gap:6px; flex-wrap:wrap;" id="sb-bound-assets">
<span class="asset-tag"><span class="dotc"></span>林夕(人物)</span>
<span class="asset-tag"><span class="dotc"></span>深夜办公桌(场景)</span>
</div>
</div>
</div>
</div>
<div class="stage-foot">
<div class="info"><span class="mono">[ image-2 单场 ¥0.45 · 累计 ¥1.35 · 整张重跑,失败不扣 ]</span></div>
<div class="hstack">
<button class="btn" onclick="location.hash='#stage-2'"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> 返回资产</button>
<button class="btn btn-primary btn-lg" onclick="location.hash='#stage-4'">确认故事板,开始生成视频 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg></button>
</div>
</div>
</section>
<!-- ============= STAGE 4 · 视频(按场分,15s/场) ============= -->
<section class="stage" data-stage-pane="4">
<div class="queue-bar">
<div>
<div style="font-size:14px; font-weight:600;">视频生成 · 3 / 3 完成</div>
<div class="muted-2 mono" style="font-size:11px; margin-top:3px; letter-spacing:.02em;">// 每场 Seedance 约 <span id="seedance-avg">15</span> 秒 · 已完成所有场次</div>
</div>
<div class="bar-wrap"><span style="width:100%"></span></div>
<span class="muted mono" style="font-size:12px;">100%</span>
<button class="btn btn-sm" onclick="Quota.preflight({stage:'Stage 4 视频片段 · 全部重跑', est: 1.35, force: true, demo:'block'})">↻ 全部重跑</button>
<input type="file" id="stage4-upload-input" accept="video/*" multiple hidden>
<button class="btn btn-sm" type="button" onclick="document.getElementById('stage4-upload-input').click()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
上传视频
</button>
</div>
<div class="video-grid" id="video-grid">
<div class="video-card" data-video-id="v1" data-duration="15">
<div class="placeholder video-thumb">
<span class="ph-frame">场 1 · 0-15s</span>
<div class="play"><div class="btn-play"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></div></div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">场 1 · 深夜办公桌</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">15s · 1080×1920 · ¥0.45</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm" data-vstop>↻ 重跑</button>
<button class="btn btn-ghost btn-sm" data-vstop>⤓ 下载</button>
</div>
</div>
</div>
<div class="video-card" data-video-id="v2" data-duration="12">
<div class="placeholder video-thumb">
<span class="ph-frame">场 2 · 15-27s</span>
<div class="play"><div class="btn-play"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></div></div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">场 2 · 面膜包装/特写</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">12s · 1080×1920 · ¥0.45</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm" data-vstop>↻ 重跑</button>
<button class="btn btn-ghost btn-sm" data-vstop>⤓ 下载</button>
</div>
</div>
</div>
<div class="video-card" data-video-id="v3" data-duration="13">
<div class="placeholder video-thumb">
<span class="ph-frame">场 3 · 27-40s</span>
<div class="play"><div class="btn-play"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></div></div>
</div>
<div class="body">
<div class="hstack"><strong style="font-size:13px;">场 3 · 化妆台/产品定格</strong><span class="spacer"></span><span class="pill ok"><span class="dot"></span>完成</span></div>
<div class="muted-2 mono" style="font-size:11px; margin-top:4px;">13s · 1080×1920 · ¥0.45</div>
<div class="hstack" style="margin-top:8px;">
<button class="btn btn-ghost btn-sm" data-vstop>↻ 重跑</button>
<button class="btn btn-ghost btn-sm" data-vstop>⤓ 下载</button>
</div>
</div>
</div>
</div>
<div class="stage-foot">
<div class="info"><span class="mono">[ 已完成 3 场 · 累计 ¥1.35 · 总时长 <span id="seedance-total">40</span>s · 失败不扣 · 通过后扣 ]</span></div>
<div class="hstack">
<button class="btn" onclick="location.hash='#stage-3'"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> 返回故事板</button>
<button class="btn btn-primary btn-lg" onclick="location.hash='#stage-5'">确认视频,进入拼接 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg></button>
</div>
</div>
</section>
<!-- ===== Stage 4 · 视频详情 modal ===== -->
<div class="asset-modal-bg" id="video-detail-modal">
<div class="asset-modal" style="width: min(880px, 100%);">
<div class="asset-modal-h">
<h2 id="vd-title">视频详情</h2>
<span style="font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48);" id="vd-sub">// 场 1 · 15s</span>
<button class="x" type="button" aria-label="关闭"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M6 18L18 6"/></svg></button>
</div>
<div class="asset-modal-body">
<div class="vd-main-wrap">
<div class="vd-main">
<div class="placeholder" id="vd-main-img"><span class="ph-frame">大视频预览</span></div>
</div>
<div class="vd-info">
<div class="asset-detail-section-h">// 基础信息</div>
<div class="asset-detail-info" id="vd-info"></div>
<div class="vd-history" style="margin-top:18px;">
<div class="vd-history-h">// 历史版本 · <span id="vd-history-ct">3</span></div>
<div class="vd-history-row" id="vd-history-row"></div>
</div>
<div class="vd-prompt-field">
<div class="vd-prompt-head">
<span class="label">// 视频提示词</span>
</div>
<div class="prompt-edit vd-prompt-edit" contenteditable="true" role="textbox" aria-label="视频提示词" spellcheck="false" id="vd-prompt-edit" data-placeholder="// 输入本场 Seedance 提示词"></div>
</div>
</div>
</div>
</div>
<div class="asset-modal-f vd-modal-f">
<button class="btn btn-ghost" type="button" data-modal-close>关闭</button>
<div class="vd-modal-actions">
<button class="pill-cta ghost" type="button" id="vd-regen-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>
重跑本场
</button>
<button class="pill-cta heat" type="button" id="vd-adopt-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>
采用此版
</button>
</div>
</div>
</div>
</div>
<!-- ============= STAGE 5 · 拼接编辑器 ============= -->
<section class="stage" data-stage-pane="5">
<div class="editor">
<div class="editor-preview">
<div class="canvas" id="ed-canvas"><span id="ed-canvas-label">9:16 预览 · 1080×1920</span></div>
<div class="controls">
<button class="ctl-btn" id="ed-prev-btn" title="上一帧 (←)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3v10l4-5zM9 3v10l4-5z" fill="currentColor"/></svg></button>
<button class="ctl-btn" id="ed-play-btn" title="播放 / 暂停 (空格)"><svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M5 4l7 4-7 4z" fill="currentColor"/></svg></button>
<button class="ctl-btn" id="ed-next-btn" title="下一帧 (→)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M13 3v10l-4-5zM7 3v10l-4-5z" fill="currentColor"/></svg></button>
<span class="muted mono" style="font-size:12px; margin-left:8px;"><span id="ed-cur-time">00:00.00</span> / <span id="ed-total-time">00:15.00</span></span>
</div>
</div>
<div class="editor-props">
<div class="props-tabs">
<div class="active">字幕</div>
<div>转场</div>
<div>BGM</div>
</div>
<div class="muted mono" style="font-size:11px; font-weight:500; margin-bottom:8px; letter-spacing:.04em;">// 字幕样式</div>
<div class="style-swatch">
<div class="swatch-card selected"><div class="demo">真实分享</div><div class="nm">朴素白底</div></div>
<div class="swatch-card"><div class="demo b">真实分享</div><div class="nm">影视黑底</div></div>
<div class="swatch-card"><div class="demo c">真实分享</div><div class="nm">手写描边</div></div>
<div class="swatch-card"><div class="demo d">真实分享</div><div class="nm">综艺暖黄</div></div>
</div>
<div class="divider"></div>
<div class="muted mono" style="font-size:11px; font-weight:500; margin-bottom:8px; letter-spacing:.04em;">// 当前选中(<span id="ed-inspect-name">未选</span>)</div>
<div class="props-row"><span class="k">起始</span><input class="input-mini" id="ed-inspect-start" value="—"></div>
<div class="props-row"><span class="k">时长</span><input class="input-mini" id="ed-inspect-dur" value="—"></div>
<div class="props-row"><span class="k">音量</span><input class="input-mini" value="100"></div>
<div class="props-row"><span class="k">速度</span><input class="input-mini" value="1.0x"></div>
<div class="props-row"><span class="k">入场</span><span class="mono" style="font-size:11.5px;">交叉淡化</span></div>
<div class="divider"></div>
<div class="muted mono" style="font-size:11px; font-weight:500; margin-bottom:8px; letter-spacing:.04em;">// BGM</div>
<div class="props-row" style="border-bottom:0;">
<span style="font-size:12px; flex:1;">温柔治愈钢琴 · 0:42</span>
<button class="btn btn-ghost btn-sm">替换</button>
</div>
</div>
<div class="timeline" id="ed-timeline">
<div class="tl-toolbar">
<button class="tl-action" id="ed-undo-btn" title="撤销">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
</button>
<button class="tl-action" id="ed-redo-btn" title="重做">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 15-6.7L21 13"/></svg>
</button>
<span class="tl-sep"></span>
<button class="tl-action" id="ed-split-btn" title="在播放头处分割选中片段">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4L8.12 15.88"/><path d="M14.47 14.48L20 20"/><path d="M8.12 8.12L12 12"/></svg>
分割
</button>
<button class="tl-action" id="ed-copy-btn" title="复制选中片段">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
复制
</button>
<button class="tl-action danger" id="ed-del-btn" title="删除选中片段 (Delete)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1.5 14a2 2 0 0 1-2 1.8H8.5a2 2 0 0 1-2-1.8L5 6"/></svg>
删除
</button>
<span class="spacer"></span>
<div class="tl-zoom">
<span class="lbl">// zoom</span>
<input type="range" min="50" max="200" value="100" id="ed-zoom-input">
</div>
</div>
<div class="tl-ruler">
<div class="l">// time</div>
<div class="rule-track" id="ed-ruler">
<span class="tick major" style="left:0%"><span class="t">0s</span></span>
<span class="tick minor" style="left:6.67%"></span>
<span class="tick major" style="left:13.33%"><span class="t">2s</span></span>
<span class="tick minor" style="left:20%"></span>
<span class="tick major" style="left:26.67%"><span class="t">4s</span></span>
<span class="tick minor" style="left:33.33%"></span>
<span class="tick major" style="left:40%"><span class="t">6s</span></span>
<span class="tick minor" style="left:46.67%"></span>
<span class="tick major" style="left:53.33%"><span class="t">8s</span></span>
<span class="tick minor" style="left:60%"></span>
<span class="tick major" style="left:66.67%"><span class="t">10s</span></span>
<span class="tick minor" style="left:73.33%"></span>
<span class="tick major" style="left:80%"><span class="t">12s</span></span>
<span class="tick minor" style="left:86.67%"></span>
<span class="tick major" style="left:93.33%"><span class="t">14s</span></span>
<span class="tick major" style="left:100%"><span class="t">15s</span></span>
</div>
</div>
<div class="tl-track video-track">
<div class="label video">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18"/><path d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5"/></svg></span>
视频
</div>
<div class="lane" id="ed-lane-video" data-track="video">
<div class="clip video" data-track="video" data-label="深夜办公桌" data-dur="2" data-max="2"><span class="frames"><span class="fr"></span><span class="fr"></span><span class="fr"></span></span><span class="num">1</span><span class="lbl">深夜办公桌</span></div>
<div class="clip video" data-track="video" data-label="面膜包装" data-dur="3" data-max="3"><span class="frames"><span class="fr"></span><span class="fr"></span><span class="fr"></span><span class="fr"></span></span><span class="num">2</span><span class="lbl">面膜包装</span></div>
<div class="clip video" data-track="video" data-label="精华液微距" data-dur="3" data-max="3"><span class="frames"><span class="fr"></span><span class="fr"></span><span class="fr"></span><span class="fr"></span></span><span class="num">3</span><span class="lbl">精华液微距</span></div>
<div class="clip video" data-track="video" data-label="敷面膜平躺" data-dur="3" data-max="3"><span class="frames"><span class="fr"></span><span class="fr"></span><span class="fr"></span><span class="fr"></span></span><span class="num">4</span><span class="lbl">敷面膜平躺</span></div>
<div class="clip video" data-track="video" data-label="化妆台" data-dur="2" data-max="2"><span class="frames"><span class="fr"></span><span class="fr"></span><span class="fr"></span></span><span class="num">5</span><span class="lbl">化妆台</span></div>
<div class="clip video" data-track="video" data-label="产品定格" data-dur="2" data-max="2"><span class="frames"><span class="fr"></span><span class="fr"></span><span class="fr"></span></span><span class="num">6</span><span class="lbl">产品定格</span></div>
</div>
</div>
<div class="tl-track subtitle-track">
<div class="label subtitle">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg></span>
字幕
</div>
<div class="lane" id="ed-lane-subtitle" data-track="subtitle">
<div class="clip subtitle" data-track="subtitle" data-label="加班三天 脸已经不能看了…" data-dur="2" data-max="2"><span class="lbl">加班三天 脸已经不能看了…</span></div>
<div class="clip subtitle" data-track="subtitle" data-label="还好我有这个 透真玻尿酸面膜" data-dur="3" data-max="3"><span class="lbl">还好我有这个 透真玻尿酸面膜</span></div>
<div class="clip subtitle" data-track="subtitle" data-label="30g 精华 一片顶三片" data-dur="3" data-max="3"><span class="lbl">30g 精华 一片顶三片</span></div>
<div class="clip subtitle" data-track="subtitle" data-label="敷完起来脸是软的" data-dur="3" data-max="3"><span class="lbl">敷完起来脸是软的</span></div>
<div class="clip subtitle" data-track="subtitle" data-label="化妆都能看出来" data-dur="2" data-max="2"><span class="lbl">化妆都能看出来</span></div>
<div class="clip subtitle" data-track="subtitle" data-label="5 片 ¥39.9 囤起来" data-dur="2" data-max="2"><span class="lbl">5 片 ¥39.9 囤起来</span></div>
<div class="playhead" id="ed-playhead" style="left:0%;"><span class="ph-grab"></span></div>
</div>
</div>
<div class="tl-track bgm-track">
<div class="label bgm">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></span>
BGM
</div>
<div class="lane">
<div class="clip bgm" data-track="bgm" data-label="温柔治愈钢琴" data-dur="15" data-max="15">
<span class="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor"><rect x="0" y="8" width="2" height="4"/><rect x="4" y="6" width="2" height="8"/><rect x="8" y="3" width="2" height="14"/><rect x="12" y="7" width="2" height="6"/><rect x="16" y="4" width="2" height="12"/><rect x="20" y="2" width="2" height="16"/><rect x="24" y="6" width="2" height="8"/><rect x="28" y="8" width="2" height="4"/><rect x="32" y="5" width="2" height="10"/><rect x="36" y="3" width="2" height="14"/><rect x="40" y="7" width="2" height="6"/><rect x="44" y="4" width="2" height="12"/><rect x="48" y="6" width="2" height="8"/><rect x="52" y="2" width="2" height="16"/><rect x="56" y="5" width="2" height="10"/><rect x="60" y="7" width="2" height="6"/><rect x="64" y="3" width="2" height="14"/><rect x="68" y="6" width="2" height="8"/><rect x="72" y="8" width="2" height="4"/><rect x="76" y="4" width="2" height="12"/><rect x="80" y="2" width="2" height="16"/><rect x="84" y="5" width="2" height="10"/><rect x="88" y="7" width="2" height="6"/><rect x="92" y="3" width="2" height="14"/><rect x="96" y="6" width="2" height="8"/><rect x="100" y="4" width="2" height="12"/><rect x="104" y="8" width="2" height="4"/><rect x="108" y="5" width="2" height="10"/><rect x="112" y="2" width="2" height="16"/><rect x="116" y="7" width="2" height="6"/><rect x="120" y="3" width="2" height="14"/><rect x="124" y="6" width="2" height="8"/><rect x="128" y="4" width="2" height="12"/><rect x="132" y="8" width="2" height="4"/><rect x="136" y="5" width="2" height="10"/><rect x="140" y="3" width="2" height="14"/><rect x="144" y="6" width="2" height="8"/><rect x="148" y="2" width="2" height="16"/><rect x="152" y="7" width="2" height="6"/><rect x="156" y="4" width="2" height="12"/><rect x="160" y="5" width="2" height="10"/><rect x="164" y="3" width="2" height="14"/><rect x="168" y="6" width="2" height="8"/><rect x="172" y="8" width="2" height="4"/><rect x="176" y="4" width="2" height="12"/><rect x="180" y="2" width="2" height="16"/><rect x="184" y="5" width="2" height="10"/><rect x="188" y="7" width="2" height="6"/><rect x="192" y="3" width="2" height="14"/><rect x="196" y="6" width="2" height="8"/><rect x="200" y="8" width="2" height="4"/><rect x="204" y="4" width="2" height="12"/><rect x="208" y="2" width="2" height="16"/><rect x="212" y="6" width="2" height="8"/><rect x="216" y="5" width="2" height="10"/><rect x="220" y="3" width="2" height="14"/><rect x="224" y="7" width="2" height="6"/><rect x="228" y="4" width="2" height="12"/><rect x="232" y="8" width="2" height="4"/><rect x="236" y="6" width="2" height="8"/><rect x="240" y="2" width="2" height="16"/><rect x="244" y="5" width="2" height="10"/><rect x="248" y="3" width="2" height="14"/><rect x="252" y="7" width="2" height="6"/><rect x="256" y="6" width="2" height="8"/><rect x="260" y="4" width="2" height="12"/><rect x="264" y="8" width="2" height="4"/><rect x="268" y="5" width="2" height="10"/><rect x="272" y="2" width="2" height="16"/><rect x="276" y="7" width="2" height="6"/><rect x="280" y="3" width="2" height="14"/><rect x="284" y="6" width="2" height="8"/><rect x="288" y="4" width="2" height="12"/><rect x="292" y="8" width="2" height="4"/><rect x="296" y="5" width="2" height="10"/><rect x="300" y="3" width="2" height="14"/><rect x="304" y="6" width="2" height="8"/><rect x="308" y="2" width="2" height="16"/><rect x="312" y="7" width="2" height="6"/><rect x="316" y="4" width="2" height="12"/><rect x="320" y="5" width="2" height="10"/><rect x="324" y="3" width="2" height="14"/><rect x="328" y="6" width="2" height="8"/><rect x="332" y="8" width="2" height="4"/><rect x="336" y="4" width="2" height="12"/><rect x="340" y="2" width="2" height="16"/><rect x="344" y="5" width="2" height="10"/><rect x="348" y="7" width="2" height="6"/><rect x="352" y="3" width="2" height="14"/><rect x="356" y="6" width="2" height="8"/><rect x="360" y="8" width="2" height="4"/><rect x="364" y="4" width="2" height="12"/><rect x="368" y="2" width="2" height="16"/><rect x="372" y="6" width="2" height="8"/><rect x="376" y="5" width="2" height="10"/><rect x="380" y="3" width="2" height="14"/><rect x="384" y="7" width="2" height="6"/><rect x="388" y="4" width="2" height="12"/><rect x="392" y="8" width="2" height="4"/><rect x="396" y="6" width="2" height="8"/><rect x="400" y="2" width="2" height="16"/><rect x="404" y="5" width="2" height="10"/><rect x="408" y="3" width="2" height="14"/><rect x="412" y="7" width="2" height="6"/><rect x="416" y="6" width="2" height="8"/><rect x="420" y="4" width="2" height="12"/><rect x="424" y="8" width="2" height="4"/><rect x="428" y="5" width="2" height="10"/><rect x="432" y="2" width="2" height="16"/><rect x="436" y="7" width="2" height="6"/><rect x="440" y="3" width="2" height="14"/><rect x="444" y="6" width="2" height="8"/><rect x="448" y="4" width="2" height="12"/><rect x="452" y="8" width="2" height="4"/><rect x="456" y="5" width="2" height="10"/><rect x="460" y="3" width="2" height="14"/><rect x="464" y="6" width="2" height="8"/><rect x="468" y="2" width="2" height="16"/><rect x="472" y="7" width="2" height="6"/><rect x="476" y="4" width="2" height="12"/><rect x="480" y="5" width="2" height="10"/><rect x="484" y="3" width="2" height="14"/><rect x="488" y="6" width="2" height="8"/><rect x="492" y="8" width="2" height="4"/><rect x="496" y="4" width="2" height="12"/><rect x="500" y="2" width="2" height="16"/><rect x="504" y="5" width="2" height="10"/><rect x="508" y="7" width="2" height="6"/><rect x="512" y="3" width="2" height="14"/><rect x="516" y="6" width="2" height="8"/><rect x="520" y="8" width="2" height="4"/><rect x="524" y="4" width="2" height="12"/><rect x="528" y="2" width="2" height="16"/><rect x="532" y="6" width="2" height="8"/><rect x="536" y="5" width="2" height="10"/><rect x="540" y="3" width="2" height="14"/><rect x="544" y="7" width="2" height="6"/><rect x="548" y="4" width="2" height="12"/><rect x="552" y="8" width="2" height="4"/><rect x="556" y="6" width="2" height="8"/><rect x="560" y="2" width="2" height="16"/><rect x="564" y="5" width="2" height="10"/><rect x="568" y="3" width="2" height="14"/><rect x="572" y="7" width="2" height="6"/><rect x="576" y="6" width="2" height="8"/><rect x="580" y="4" width="2" height="12"/><rect x="584" y="8" width="2" height="4"/><rect x="588" y="5" width="2" height="10"/><rect x="592" y="3" width="2" height="14"/><rect x="596" y="6" width="2" height="8"/></svg></span>
<span class="lbl">温柔治愈钢琴 · 0:42(循环 1 次,淡入淡出)</span>
</div>
</div>
</div>
</div>
</div>
<div class="stage-foot">
<div class="info"><span class="mono">[ 合成预估 ~30s · 拼接 / 导出全程 0 token · 已结算 ¥1.39 ]</span></div>
<div class="hstack">
<button class="btn" onclick="location.hash='#stage-4'"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> 返回片段</button>
<button class="btn" onclick="Shell.toast('已保存草稿', '/projects/p3/draft')">保存草稿</button>
<button class="btn btn-primary btn-lg" onclick="Shell.toast('开始导出', 'POST /export · 1080P 9:16')">导出 MP4 · 1080P 9:16 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16"/></svg></button>
</div>
</div>
</section>
<!-- ===== Stage 2 通用 · 资产详情 modal (参考布局 v2) ===== -->
<div class="asset-modal-bg" id="asset-detail-modal">
<div class="asset-modal">
<div class="asset-modal-h">
<h2 id="asset-detail-title">资产详情</h2>
<span class="ad-tag" id="asset-detail-kind">/ kind</span>
<button class="x" type="button" aria-label="关闭"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M6 18L18 6"/></svg></button>
</div>
<div class="asset-modal-body">
<div class="asset-detail-grid">
<!-- 左栏 · 大立绘 + 缩略图 -->
<div class="asset-detail-lead">
<div class="ad-lead-wrap">
<div class="placeholder ad-lead-img" id="asset-detail-lead-img"><span class="ph-frame">立绘</span></div>
<button class="ad-zoom-btn" type="button" id="asset-detail-zoom-btn" aria-label="查看大图" title="查看大图">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5"/></svg>
</button>
</div>
<div class="ad-thumbs" id="asset-detail-thumbs"></div>
</div>
<!-- 右栏 · 三视图 + 简介 + 属性 -->
<div class="asset-detail-right">
<!-- 三视图 -->
<div class="ad-section" id="asset-detail-tri-section">
<div class="asset-detail-section-h">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span>
<span class="t">三视图</span>
<span class="ad-ratio-chip" id="asset-detail-ratio">16:9</span>
<button class="ad-icon-btn" type="button" title="下载">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
</button>
</div>
<div class="asset-detail-tri-row" id="asset-detail-tri">
<div class="placeholder"><span class="ph-frame">正 / 侧 / 背 · 三视图</span></div>
</div>
<div class="asset-detail-tip" id="asset-detail-tip" style="display:none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></svg>
<span>暂无三视图,建议用 AI 生成以保证多角度一致性</span>
<button class="ai-gen-btn" type="button">AI 生成三视图</button>
</div>
</div>
<!-- 简介 -->
<div class="ad-section">
<div class="asset-detail-section-h">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg></span>
<span class="t">简介</span>
</div>
<p class="ad-intro" id="asset-detail-intro"></p>
<div class="ad-tags" id="asset-detail-tags"></div>
</div>
<!-- 属性表 -->
<div class="ad-props" id="asset-detail-props"></div>
</div>
</div>
</div>
<div class="asset-modal-f">
<div class="ad-foot-stats">
<button class="ad-stat-btn" type="button" id="asset-detail-download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
下载
</button>
</div>
<button class="btn btn-primary" type="button" id="asset-detail-apply-btn">使用该资产</button>
</div>
</div>
</div>
<!-- ===== Stage 2 · 新增人物 modal ===== -->
<div class="asset-modal-bg" id="new-character-modal">
<div class="asset-modal" style="width: min(680px, 100%);">
<div class="asset-modal-h">
<h2>新增人物</h2>
<span style="font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48);">// 立绘必填 + 三视图(可 AI 生成)</span>
<button class="x" type="button" aria-label="关闭"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M6 18L18 6"/></svg></button>
</div>
<div class="asset-modal-body">
<div class="field" style="margin-bottom: 14px;">
<label class="field-label" style="display:block; font-size: 12.5px; color: var(--black-alpha-56); margin-bottom: 6px;">人物名称</label>
<input class="input" type="text" placeholder="例:林夕 · 都市白领" style="width:100%; height:36px; padding:0 12px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); font-size:13px; font-family: inherit;">
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 14px;">
<div>
<div class="asset-detail-section-h">// 立绘<span style="color: var(--heat); margin-left:2px;">*</span></div>
<div class="upload-zone lead" id="nc-upload-lead">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
<span>点击上传立绘</span>
<span style="font-family: var(--font-mono); font-size: 10.5px; opacity: .7;">PNG / JPG · ≤10MB</span>
</div>
</div>
<div>
<div class="asset-detail-section-h">// 三视图<span style="color: var(--black-alpha-48); font-weight:400; margin-left: 4px; text-transform: none; letter-spacing: 0;">(可选 · 16:9 单图)</span></div>
<div>
<div class="upload-zone upload-zone-tri" style="aspect-ratio: 16/9;"><span>正 / 侧 / 背 · 三视图</span></div>
</div>
<div class="asset-detail-tip" id="nc-tri-tip" style="margin-top: 10px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></svg>
<span>没有三视图?上传立绘后用 AI 自动生成</span>
<button class="ai-gen-btn" type="button">AI 生成</button>
</div>
</div>
</div>
</div>
<div class="asset-modal-f">
<button class="btn btn-ghost" type="button" data-modal-close>取消</button>
<button class="btn btn-primary" type="button" id="nc-save-btn">保存人物</button>
</div>
</div>
</div>
<!-- ===== Stage 2 · 演员库 / 场景库 全屏弹窗(共享 · kind 切换内容)===== -->
<div class="ml-modal-bg" id="ml-modal-bg">
<div class="ml-modal">
<div class="ml-modal-h">
<h2 id="ml-modal-title">演员库</h2>
<span class="ct" id="ml-modal-ct">// 共 0 个</span>
<button class="x" type="button" id="ml-close-btn" aria-label="关闭">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M6 18L18 6"/></svg>
</button>
</div>
<div class="ml-modal-body">
<aside class="ml-side" id="ml-side">
<!-- JS 注入 来源 -->
</aside>
<div class="ml-main">
<div class="ml-toolbar" id="ml-toolbar">
<!-- JS 注入 chips + 上传按钮 -->
</div>
<div class="ml-scroll">
<div class="ml-grid" id="ml-grid">
<!-- JS 注入 卡片 -->
</div>
</div>
<!-- 添加演员 / 场景 · 工作台画布 -->
<div class="ml-canvas" id="ml-canvas" aria-hidden="true">
<div class="ml-canvas-h">
<button class="back-btn" type="button" id="ml-canvas-back" aria-label="返回">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回
</button>
<h3 id="ml-canvas-title">添加演员</h3>
<span class="mono" id="ml-canvas-mono">// 添加演员 · 工作台</span>
<span style="flex:1;"></span>
</div>
<div class="ml-canvas-body">
<section class="mc-ai">
<div class="mc-stream" id="mc-stream">
<div class="mc-stream-inner" id="mc-stream-inner">
<div class="mc-empty">
<div class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 2z"/><path d="M19 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z"/></svg>
</div>
<span class="badge">// AI · STUDIO</span>
<h2 id="mc-empty-title">用 AI 生成一位新演员</h2>
<p id="mc-empty-desc">描述外形 + 风格 + 服装,AI 会同时生成立绘 + 正/侧/背三视图,加入演员库。</p>
<div class="examples" id="mc-empty-examples"></div>
</div>
</div>
</div>
<div class="mc-input-wrap">
<div class="mc-input">
<div class="mc-input-top">
<div class="mc-input-refs" id="mc-input-refs"></div>
<button class="add-btn" type="button" id="mc-add-btn" title="上传参考图">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
</button>
<input type="file" id="mc-ai-ref-input" accept="image/*" multiple hidden>
</div>
<textarea id="mc-input-text" rows="1" placeholder="描述外形、风格、服饰…"></textarea>
<div class="mc-input-bottom">
<div class="param"><span class="lbl-mono">比例</span><span>3:4</span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></div>
<div class="param"><span class="lbl-mono">风格</span><span>默认</span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></div>
<div class="param"><span class="lbl-mono">张数</span><span>4</span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></div>
<span class="right-meta">预估 <span class="val">¥0.80</span> · 余额 <span class="val">¥327.40</span></span>
<button class="send-btn" type="button" id="mc-send-btn" disabled title="生成">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>
</div>
</section>
<aside class="mc-up" id="mc-up" data-kind="actor">
<div class="mc-up-tabs">
<button class="mc-up-tab active" type="button" data-tab="ai">AI 生成</button>
<button class="mc-up-tab" type="button" data-tab="local">本地上传</button>
</div>
<div class="mc-up-body">
<div class="mc-up-section">
<div class="mc-up-sec-h" id="mc-up-name-label">// 演员姓名</div>
<input class="mc-up-name" type="text" id="mc-up-name" placeholder="给演员起个名字…" maxlength="20">
</div>
<div class="mc-up-section">
<div class="mc-up-sec-h" id="mc-up-portrait-label">// 演员立绘</div>
<div class="mc-portrait-ai" data-show="ai">
<div class="empty" id="mc-portrait-ai-empty">
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="4"/><path d="M3 21c0-3.5 3-6 6-6s6 2.5 6 6"/></svg></div>
<div class="desc" id="mc-portrait-ai-empty-desc">在左侧 AI 生成后<br>点击想要的立绘添加到这里</div>
<div class="mono">// 待选中</div>
</div>
<div class="picked" id="mc-portrait-ai-picked" hidden>
<span class="badge">已选用</span>
<div class="ph-frame" id="mc-portrait-ai-label">立绘</div>
<div class="ops">
<button type="button" id="mc-portrait-ai-clear" title="移除">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
</div>
</div>
<div class="mc-portrait-local" data-show="local" hidden>
<div class="drop" id="mc-portrait-local-drop" tabindex="0" role="button" aria-label="点击或拖入立绘">
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg></div>
<div class="t" id="mc-portrait-local-t">点击或拖入立绘</div>
<div class="d">支持多张 JPG / PNG / WEBP · ≤ 10MB / 张</div>
</div>
<div class="list-h">
<span>// 已上传</span>
<span class="ct" id="mc-portrait-local-count">0</span>
<span></span>
</div>
<div class="list" id="mc-portrait-local-list"></div>
<input type="file" id="mc-portrait-local-input" accept="image/*" multiple hidden>
</div>
</div>
<div class="mc-up-section mc-triview" id="mc-triview-sec">
<div class="mc-up-sec-h">// 三视图</div>
<div class="result-wrap">
<div class="result" id="mc-triview-result">
<div class="ph-frame" id="mc-triview-frame">三视图(正/侧/背)</div>
<button class="overlay-gen-btn" type="button" id="mc-triview-gen-btn" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z"/></svg>
生成三视图
</button>
<div class="overlay-hint" id="mc-triview-hint">// 先选中左侧 AI 立绘</div>
</div>
<div class="result-ops" id="mc-triview-ops" hidden>
<button type="button" id="mc-triview-rerun"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg> 重跑</button>
<span class="cost">~¥0.30 / 次</span>
</div>
<div class="history" id="mc-triview-history" hidden>
<div class="h-lbl">// 历史版本 · <span class="ct" id="mc-triview-history-count">0</span></div>
<div class="h-row" id="mc-triview-history-row"></div>
</div>
</div>
</div>
</div>
<div class="mc-up-foot">
<span class="stat" id="mc-up-stat">// 待完成</span>
<button type="button" class="commit-btn" id="mc-up-commit" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>
<span id="mc-up-commit-label">加入演员库</span>
</button>
</div>
</aside>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ===== 离开工作台 · 二次确认弹窗 ===== -->
<div class="mc-leave-bg" id="mc-leave-bg" aria-hidden="true">
<div class="mc-leave" role="dialog" aria-modal="true" aria-labelledby="mc-leave-title">
<div class="lv-h">
<span class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 9v4M12 17h.01M10.3 3.86l-8.18 14.18A2 2 0 0 0 3.84 21h16.32a2 2 0 0 0 1.72-2.96L13.7 3.86a2 2 0 0 0-3.4 0z"/></svg>
</span>
<h3 id="mc-leave-title">退出工作台?</h3>
<span class="mono">// UNSAVED</span>
</div>
<div class="lv-b" id="mc-leave-body">
工作台已有内容,退出后<b>不会保存</b>。可继续编辑并点「加入」来保留进度。
</div>
<div class="lv-f">
<span class="spacer"></span>
<button class="btn" type="button" id="mc-leave-cancel">继续编辑</button>
<button class="btn btn-danger" type="button" id="mc-leave-confirm">不保存,退出</button>
</div>
</div>
</div>
<!-- ===== 添加演员 / 场景 · 选择来源 modal ===== -->
<div class="ml-up-choice-bg" id="ml-up-choice-bg">
<div class="ml-up-choice" role="dialog" aria-label="添加来源">
<div class="uc-h">
<div class="ic-m">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="4"/><path d="M3 21c0-3.5 3-6 6-6s6 2.5 6 6"/><path d="M19 8v6M22 11h-6"/></svg>
</div>
<div class="ti">
<strong id="ml-up-title">添加</strong>
<span class="mono">// 选择来源 · AI 生成或本地上传</span>
</div>
<button class="uc-x" type="button" id="ml-up-x" aria-label="关闭">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="uc-body">
<button type="button" class="uc-option" id="ml-up-ai">
<span class="opt-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 2z"/><path d="M19 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z"/></svg>
</span>
<div class="opt-t">AI 生成</div>
<div class="opt-d" id="ml-up-ai-desc">描述外形 + 风格,AI 自动生成新形象与三视图</div>
<span class="opt-tag">[ AI · STUDIO ]</span>
</button>
<button type="button" class="uc-option" id="ml-up-local">
<span class="opt-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</span>
<div class="opt-t">本地上传</div>
<div class="opt-d" id="ml-up-local-desc">上传真人 / 既有素材,后续可生成三视图统一镜头</div>
<span class="opt-tag">[ UPLOAD ]</span>
</button>
</div>
</div>
</div>
<input type="file" id="ml-up-file" accept="image/*" multiple hidden>
<!-- ===== 额度预检 modal · PRD §10.3 四层预检 ===== -->
<div class="modal-bg" id="quota-bg" onclick="if(event.target===this)Shell.closeModal('quota-bg')">
<div class="modal" id="quota-modal" style="width: min(440px, 92vw);">
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
<div class="modal-h">
<div class="ic-m" id="quota-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<div class="ti" id="quota-title">额度预检通过<span id="quota-sub">// 4 层检查 · 全部通过</span></div>
</div>
<div class="modal-b">
<div id="quota-stage-row" style="font-size:13px; color:var(--black-alpha-72); margin-bottom: 14px;"></div>
<div style="display:flex; flex-direction:column; gap:8px;" id="quota-checks">
<!-- JS 注入 4 行检查 -->
</div>
<div id="quota-block-tip" style="display:none; margin-top:14px; padding:10px 12px; background: rgba(235,52,36,.08); border: 1px solid rgba(235,52,36,.24); border-radius: var(--r-sm); font-size:12.5px; color: var(--accent-crimson); line-height:1.5;">
<strong>任务已拦截</strong> · 余额或额度不足以覆盖本次预估。请联系超管充值,或将团队月限额调高。
</div>
</div>
<div class="modal-f">
<button class="btn" type="button" onclick="Shell.closeModal('quota-bg')" id="quota-cancel">关闭</button>
<button class="btn btn-primary" type="button" id="quota-confirm" style="display:none;">确认扣费 · 开始任务</button>
<a class="btn btn-primary" id="quota-topup" href="account.html" style="display:none;">前往充值</a>
</div>
</div>
</div>
</div>
<script src="assets/icons.js?v=2026052608"></script>
<script src="assets/shell.js?v=2026052607"></script>
<script>
/* ─── 商品名贯穿全流程(从 ?product= 读取,无参数时回退到 mock 默认值)─── */
const URL_PRODUCT_NAME = (function () {
try { return decodeURIComponent(new URLSearchParams(location.search).get('product') || ''); }
catch (e) { return ''; }
})();
const URL_PROJECT_VER = (function () {
try { return new URLSearchParams(location.search).get('v') || 'v3'; }
catch (e) { return 'v3'; }
})();
const CURRENT_PRODUCT_NAME = URL_PRODUCT_NAME || '透真补水面膜';
// 项目名 = 「{product 简称} · 痛点种草 · v3」 — 简称取末 4 字符作为视觉收敛
function shortProductName(name) {
if (name.length <= 5) return name;
// 尝试匹配常见品类后缀,否则取末 4 字
const suffixes = ['面膜', '防晒', '口红', '耳机', '速食面', '咖啡', '瑜伽裤', '保温杯'];
for (const s of suffixes) { if (name.endsWith(s)) return name.slice(-Math.max(s.length + 2, 4)); }
return name.slice(-4);
}
const PROJECT_TITLE = shortProductName(CURRENT_PRODUCT_NAME) + ' · 痛点种草 · ' + URL_PROJECT_VER;
Shell.render({
active: 'projects',
crumbs: [{ label: '工作台', href: 'index.html' }, { label: '视频项目', href: 'projects.html' }, { label: PROJECT_TITLE }]
});
/* 渲染贯穿商品名 / 项目名 */
document.getElementById('page-title').textContent = PROJECT_TITLE + ' · 流水线 · Airshelf';
/* ─── 把 stage-pill anchor 注入 .topbar 中部(圆点全状态都靠 .sp-dot 实时同步)─── */
(function _injectStagePill() {
const anchor = document.getElementById('stage-pill-anchor');
const topbar = document.querySelector('.topbar');
if (!anchor || !topbar) return;
anchor.classList.add('stage-pill');
anchor.classList.remove('stage-pill-anchor');
anchor.removeAttribute('hidden');
anchor.id = 'stage-pill';
topbar.appendChild(anchor); // position: absolute · 自动锚定 topbar 中部
})();
/* ─── Stage 1 拖拽分隔条 · 控制脚本助手宽度 · clamp 在 [280, 600] ─── */
(function _setupStageScriptGutter() {
const gutter = document.getElementById('stage-script-gutter');
const grid = document.querySelector('.stage-script');
if (!gutter || !grid) return;
const MIN = 320, MAX = 600;
let dragging = false;
gutter.addEventListener('mousedown', (e) => {
dragging = true;
gutter.classList.add('dragging');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const rect = grid.getBoundingClientRect();
const newRight = rect.right - e.clientX;
const clamped = Math.max(MIN, Math.min(MAX, newRight));
grid.style.setProperty('--chat-w', clamped + 'px');
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
gutter.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
});
})();
// hash routing
function activateStage(n) {
const cur = Number(n);
document.querySelectorAll('.stage').forEach(s => s.classList.remove('active'));
document.querySelector(`[data-stage-pane="${cur}"]`)?.classList.add('active');
// 圆点状态:< cur → done(森林绿) · = cur → active(橙实心+光晕) · > cur → 默认(浅灰)
document.querySelectorAll('#stage-pill .sp-dot').forEach(s => {
const i = +s.dataset.stage;
s.classList.remove('active', 'done');
if (i < cur) s.classList.add('done');
else if (i === cur) s.classList.add('active');
});
// 连接线 · idx+1 < cur 时染森林绿
document.querySelectorAll('#stage-pill .sp-line').forEach((ln, idx) => {
ln.classList.toggle('done', (idx + 1) < cur);
});
// 全高度布局:所有 stage 操作模块 hug content、内容区域 fill content
// 全部走 flat(像 model-photo 那样的全宽扁平布局,去卡片)
const contentEl = document.getElementById('page-content');
if (contentEl) {
contentEl.classList.add('content--fh');
contentEl.classList.add('content--fh-flat');
}
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function readHash() {
const m = location.hash.match(/stage-(\d)/);
if (m) { activateStage(+m[1]); return; }
// ?stage=N query 参数也接收
const q = new URLSearchParams(location.search);
const s = q.get('stage');
if (s) { activateStage(+s); return; }
// 兜底:默认 Stage 1 进行中(让 stage-pill 首个圆点点亮)
activateStage(1);
}
window.addEventListener('hashchange', readHash);
readHash();
/* ============================================================
STAGE 1 · 脚本助手 + 镜头脚本 状态驱动
============================================================ */
const Stage1 = (function () {
let shots = []; // [{ id, painting, dialog, duration }]
let chatMsgs = []; // [{ role, html, time }]
let mode = null;
let scriptTags = { char: [], scene: [] }; // 自动抓取的人物 / 场景 · 可编辑
const $cb = () => document.getElementById('chat-body');
const $sb = () => document.getElementById('shots-body');
const $sm = () => document.getElementById('shots-meta');
/* 自动从脚本(painting + dialog 文本)抽取人物 / 场景关键词 · 白名单匹配 */
const CHAR_KEYWORDS = ['女主', '男主', '同事', '闺蜜', '男友', '女友', '妈妈', '爸爸', '老师', '同学', '朋友', '路人', '主播', '老板'];
const SCENE_KEYWORDS = ['书桌', '卫生间', '床头', '化妆台', '办公桌', '客厅', '厨房', '卧室', '阳台', '电梯', '咖啡店', '公司', '镜前', '桌面', '会议室', '车里', '公园', '商场'];
function extractScriptTags() {
const text = shots.map(s => (s.painting || '') + ' ' + (s.dialog || '')).join(' ');
const dedup = arr => Array.from(new Set(arr));
const exist = { char: new Set(scriptTags.char), scene: new Set(scriptTags.scene) };
const newChar = CHAR_KEYWORDS.filter(k => text.includes(k) && !exist.char.has(k));
const newScene = SCENE_KEYWORDS.filter(k => text.includes(k) && !exist.scene.has(k));
scriptTags.char = dedup([...scriptTags.char, ...newChar]);
scriptTags.scene = dedup([...scriptTags.scene, ...newScene]);
}
function renderScriptTags() {
['char', 'scene'].forEach(kind => {
const group = document.querySelector(`.script-tags .tag-group[data-kind="${kind}"]`);
if (!group) return;
const addBtn = group.querySelector('.tag-add');
group.querySelectorAll('.script-tag').forEach(el => el.remove());
scriptTags[kind].forEach((name, i) => {
const chip = document.createElement('span');
chip.className = 'script-tag';
chip.innerHTML = `<span class="t" contenteditable="true" spellcheck="false" data-i="${i}">${name}</span><button class="x" type="button" aria-label="删除">×</button>`;
group.insertBefore(chip, addBtn);
});
group.querySelectorAll('.script-tag').forEach((chip, i) => {
const t = chip.querySelector('.t');
const x = chip.querySelector('.x');
x.addEventListener('click', (e) => { e.stopPropagation(); scriptTags[kind].splice(i, 1); renderScriptTags(); });
t.addEventListener('blur', () => {
const v = (t.textContent || '').trim();
if (!v) { scriptTags[kind].splice(i, 1); renderScriptTags(); }
else { scriptTags[kind][i] = v; }
});
t.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); t.blur(); } });
});
});
}
function bindTagAdders() {
document.querySelectorAll('.script-tags .tag-add').forEach(btn => {
btn.addEventListener('click', () => {
const kind = btn.parentElement.dataset.kind;
scriptTags[kind].push('');
renderScriptTags();
const group = document.querySelector(`.script-tags .tag-group[data-kind="${kind}"]`);
const chips = group.querySelectorAll('.script-tag .t');
const last = chips[chips.length - 1];
if (last) {
last.focus();
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(last);
sel.removeAllRanges(); sel.addRange(range);
}
});
});
}
function now() {
const d = new Date();
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}
function pushMsg(role, html) { chatMsgs.push({ role, html, time: now() }); }
function renderChat() {
const body = $cb(); if (!body) return;
if (chatMsgs.length === 0 && !mode) {
body.innerHTML = `<div class="chat-empty">
<div class="ce-title">选择一种生成方式开始</div>
<div class="ce-hint">// 三种,由「最省事」到「最保真原意」</div>
<div class="chat-modes">
<button class="chat-mode primary" data-mode="ai"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z"/></svg>AI 全生</button>
<button class="chat-mode" data-mode="theme"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5"/></svg>一句话主题</button>
<button class="chat-mode" data-mode="manual"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>自带脚本</button>
</div>
</div>`;
body.querySelectorAll('.chat-mode').forEach(btn => {
btn.addEventListener('click', () => pickMode(btn.dataset.mode));
});
return;
}
body.innerHTML = chatMsgs.map(msg => {
if (msg.role === 'ai') {
return `<div class="msg ai"><div style="display:flex; gap:10px; align-items:flex-start;"><div class="ai-avatar" style="margin-top:2px;">AI</div><div class="bubble">${msg.html}</div></div><div class="time" style="margin-left:36px;">${msg.time}</div></div>`;
}
return `<div class="msg user"><div class="bubble">${msg.html}</div><div class="time">${msg.time}</div></div>`;
}).join('');
body.scrollTop = body.scrollHeight;
}
function renderShots() {
const body = $sb(); if (!body) return;
const meta = $sm();
const phMeta = document.getElementById('page-head-shots-meta'); // 头部已移除,可能为 null · 下方写入全程 ?. 防御
if (shots.length === 0) {
body.innerHTML = `<div class="shots-empty">
<div class="empty-ico"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18M9 5v14"/></svg></div>
<div class="empty-title">还没有镜头脚本</div>
<div class="empty-hint">// 跟右侧脚本助手对话<br>选择一种方式生成你的第一稿</div>
</div>`;
if (meta) meta.textContent = '· 空 · 待生成';
if (phMeta) phMeta.textContent = '待生成镜头脚本';
renderScriptTags();
return;
}
let cum = 0;
let html = '';
shots.forEach((s, i) => {
const start = cum;
cum += (s.duration || 5);
const tlabel = start + '-' + cum + 's';
html += `<div class="shot-card" data-id="${s.id}">
<div class="shot-head">
<div class="shot-num">${i + 1}</div>
<div class="shot-time">${tlabel}</div>
<span class="spacer"></span>
<button class="icon-mini-btn" title="重写本场" data-act="regen" data-id="${s.id}">↻</button>
<button class="icon-mini-btn" title="删除本场" data-act="del" data-id="${s.id}">×</button>
</div>
<div class="shot-row"><span class="shot-k">画面</span><div class="shot-v" contenteditable="true" data-field="painting" data-placeholder="(画面必填)点击编辑"${s.painting ? '' : ' data-empty="true"'}>${s.painting || ''}</div></div>
<div class="shot-row"><span class="shot-k">对白</span><div class="shot-v" contenteditable="true" data-field="dialog" data-placeholder="(对白可空)点击编辑"${s.dialog ? '' : ' data-empty="true"'}>${s.dialog || ''}</div></div>
</div>`;
// 每张卡片后都跟一个 gap(包括最后一张),允许在任意位置 hover 加分镜
html += `<div class="shot-insert-gap" data-after="${s.id}"><button class="add-shot-btn" data-act="add-here" data-after="${s.id}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>添加分镜</button></div>`;
});
body.innerHTML = html;
if (meta) meta.textContent = '· ' + shots.length + ' 镜 · 0-' + cum + 's';
if (phMeta) phMeta.textContent = shots.length + ' 镜 · 0-' + cum + 's';
body.querySelectorAll('.shot-v[contenteditable]').forEach(el => {
el.addEventListener('focus', () => { el.dataset.empty = 'false'; });
el.addEventListener('blur', () => {
const card = el.closest('.shot-card');
const id = card.dataset.id;
const field = el.dataset.field;
const v = el.textContent.trim();
const s = shots.find(x => x.id === id);
if (s) s[field] = v;
if (!v) el.dataset.empty = 'true';
});
});
body.querySelectorAll('[data-act]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const act = btn.dataset.act;
const id = btn.dataset.id;
const after = btn.dataset.after;
if (act === 'del') {
shots = shots.filter(x => x.id !== id);
renderShots();
} else if (act === 'regen') {
Shell.toast('已请求重写本场', '↻ shot-' + id);
} else if (act === 'add-here') {
const idx = shots.findIndex(x => x.id === after);
shots.splice(idx + 1, 0, { id: 'sh' + Date.now(), painting: '', dialog: '', duration: 5 });
renderShots();
}
});
});
// 镜头有变,刷新人物/场景标签(增量抽取 · 已有的保留)
extractScriptTags();
renderScriptTags();
}
function pickMode(m) {
mode = m;
if (m === 'ai') {
pushMsg('user', '帮我 AI 全自动生成一稿脚本');
renderChat();
setTimeout(() => {
pushMsg('ai', '<span class="ai-thinking">正在解析商品卖点与目标人群 <span class="dots"><span></span><span></span><span></span></span></span>');
renderChat();
}, 300);
// 7 镜 · 0-40s · 与 Stage 2 / 4 的 3 场切分对齐(场 1 深夜办公桌 0-15s / 场 2 面膜包装 15-27s / 场 3 化妆台定格 27-40s)
const draft = [
// ─ 场 1 · 深夜办公桌(15s)─
{ id: 'sh1', painting: '中景慢推 · 深夜居家书桌全景。屏幕仍亮着 PPT,女主背影瘫在椅子上,屏幕冷光 + 台灯暖光对比。字幕"凌晨 02:14"淡入。', dialog: '(无台词 · BGM 渐起)', duration: 5 },
{ id: 'sh2', painting: '近景 · 卫生间镜前。女主低头看脸,T 区起皮、暗沉特写,冷白灯偏惨。', dialog: '"做完这版稿又是凌晨两点……(叹气)脸已经不能看了。"', duration: 5 },
{ id: 'sh3', painting: '俯拍特写 · 回到书桌,拉开抽屉。囤好的透真补水面膜露半角,手伸进去抽出一片。', dialog: '"还好抽屉里囤了透真玻尿酸面膜。"', duration: 5 },
// ─ 场 2 · 面膜包装/特写(12s)─
{ id: 'sh4', painting: '桌面微距特写 · 撕开锡纸包装的瞬间。30g 厚精华液缓缓滴落,面膜布展开,质地拉丝可见。', dialog: '"30g 一片,精华液比普通面膜厚整整三倍。"', duration: 6 },
{ id: 'sh5', painting: '床头近景 · 女主敷好面膜闭眼躺下,台灯暖光打在脸侧。膜布贴合脸型,边缘服帖。', dialog: '"贴上去那一瞬间 —— 凉凉的,像把皮肤泡了一次澡。"', duration: 6 },
// ─ 场 3 · 化妆台/产品定格(13s)─
{ id: 'sh6', painting: '中景 · 第二天清晨化妆台。阳光透过窗帘,女主对镜上妆,皮肤透亮、粉底服帖。同事画外音"你最近用啥了"。', dialog: '"第二天脸是软的,粉底都不卡了。同事都跑来问。"', duration: 8 },
{ id: 'sh7', painting: '平铺俯拍 · 桌面五片装产品 + 单片包装。价格 "618 · 5 片 ¥39.9" 弹出,购物车图标右下角浮现。', dialog: '"618 五片 39.9,自用送人都合适。链接放评论区。"', duration: 5 },
];
let cur = 0;
const step = () => {
if (cur >= draft.length) {
// remove thinking msg
chatMsgs = chatMsgs.filter(x => !(x.role === 'ai' && /ai-thinking/.test(x.html)));
pushMsg('ai', '初稿完成。点击任意卡片文字可直接编辑;鼠标移到卡片之间会出现「+ 添加分镜」。');
renderChat();
return;
}
shots.push(draft[cur++]);
renderShots();
setTimeout(step, 700);
};
setTimeout(step, 1100);
} else if (m === 'theme') {
pushMsg('ai', '好,请给我一句话主题(530 字),例如:<br>· 熬夜党的急救面膜<br>· 加班吃啥不内疚<br>下面输入框直接打就行,我会按这句话扩成一稿镜头脚本。');
renderChat();
} else if (m === 'manual') {
pushMsg('ai', '好,把你的脚本(旁白 / 镜头描述均可)粘贴到下面输入框,我会按场自然切分并适配商品卖点。');
renderChat();
}
}
function init() {
renderChat();
renderShots();
document.getElementById('chat-clear-btn')?.addEventListener('click', () => {
chatMsgs = []; mode = null; shots = []; scriptTags = { char: [], scene: [] };
renderChat(); renderShots();
});
bindTagAdders();
document.getElementById('chat-regen-btn')?.addEventListener('click', () => {
Shell.toast('已请求整体重写', 'POST /script/regen');
});
const sendBtn = document.getElementById('chat-send-btn');
const ta = document.getElementById('chat-textarea');
const attachRow = document.getElementById('chat-attach-row');
let attachments = [];
const renderAttach = () => {
if (!attachRow) return;
if (!attachments.length) { attachRow.hidden = true; attachRow.innerHTML = ''; return; }
attachRow.hidden = false;
attachRow.innerHTML = attachments.map((f, i) => `
<span class="chat-attach-chip">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
${f.name.replace(/</g, '&lt;')}
<button class="x" data-rm="${i}" aria-label="移除">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</span>`).join('');
attachRow.querySelectorAll('button[data-rm]').forEach(b => {
b.addEventListener('click', () => {
attachments.splice(+b.dataset.rm, 1);
renderAttach();
});
});
};
const upBtn = document.getElementById('chat-upload-btn');
const upInput = document.getElementById('chat-upload-input');
if (upBtn && upInput) {
upBtn.addEventListener('click', () => upInput.click());
upInput.addEventListener('change', (e) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
attachments.push(...files);
renderAttach();
Shell.toast('已附加脚本文件', files.map(f => f.name).join('、'));
upInput.value = '';
});
}
if (sendBtn && ta) {
const send = () => {
const v = ta.value.trim();
if (!v && !attachments.length) return;
const fileTags = attachments.length
? `<div class="hstack" style="gap:6px; flex-wrap:wrap; margin-bottom:6px;">${attachments.map(f => `<span class="pill" style="font-family:var(--font-mono); font-size:10.5px;">📎 ${f.name.replace(/</g, '&lt;')}</span>`).join('')}</div>`
: '';
pushMsg('user', fileTags + (v ? v.replace(/</g, '&lt;') : '<span class="muted-2">(已附加文件)</span>'));
ta.value = '';
attachments = []; renderAttach();
renderChat();
setTimeout(() => {
pushMsg('ai', '收到。我会按这个方向调整脚本(静态演示;实际接 LLM API)。');
renderChat();
}, 400);
};
sendBtn.addEventListener('click', send);
ta.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); send(); }
});
}
// 顶部「+ 追加一场」按钮已移除 — 添加分场改为卡片间 hover 时出现「+ 添加分场」
}
return { init };
})();
Stage1.init();
/* ============================================================
STAGE 2 · 基础资产 · 锚点 + 横滑预设 + 详情/库 modal
============================================================ */
const Stage2 = (function () {
const MODEL_LIB = [
{ id: 'm1', name: '清新短发女', sub: '通勤白领',
gender: '女', age: '青年', region: '东亚', skin: '白皙', height: '中等', build: '纤细',
hairLen: '短发', hairColor: '黑色', vibe: '清新', feature: '邻家气质 · 微笑亲和' },
{ id: 'm2', name: '甜美长发女', sub: '学生党',
gender: '女', age: '青年', region: '东亚', skin: '白皙', height: '偏小', build: '纤细',
hairLen: '长发', hairColor: '深棕', vibe: '甜美', feature: '校园风 · 书卷气重' },
{ id: 'm3', name: '商务套装男', sub: '总裁 IP',
gender: '男', age: '中年', region: '东亚', skin: '健康', height: '偏高', build: '标准',
hairLen: '短发', hairColor: '黑色', vibe: '稳重', feature: '商务精英范 · 西装常驻' },
{ id: 'm4', name: '宝妈居家女', sub: '家庭决策',
gender: '女', age: '中年', region: '东亚', skin: '白皙', height: '中等', build: '标准',
hairLen: '中发', hairColor: '棕黑', vibe: '温柔', feature: '居家氛围 · 决策力强' },
{ id: 'm5', name: '运动健身女', sub: '健身博主',
gender: '女', age: '青年', region: '东亚', skin: '健康', height: '偏高', build: '运动',
hairLen: '中发', hairColor: '栗色', vibe: '活力', feature: '马尾 · 健身房常客' },
{ id: 'm6', name: '少年学生男', sub: 'Z 世代',
gender: '男', age: '青年', region: '东亚', skin: '白皙', height: '中等', build: '纤细',
hairLen: '短发', hairColor: '黑色', vibe: '阳光', feature: '校服感 · 朝气十足' },
];
const SCENE_LIB = [
{ id: 's1', name: '日系卧室', sub: '居家温柔' },
{ id: 's2', name: '咖啡厅工位', sub: '通勤场景' },
{ id: 's3', name: '梳妆台', sub: '美妆个护' },
{ id: 's4', name: '健身房', sub: '运动场景' },
{ id: 's5', name: '厨房料理台', sub: '家居家电' },
{ id: 's6', name: '日落天台', sub: '氛围户外' },
];
// 商品名 / 项目名已提升到 page 顶层 script(see CURRENT_PRODUCT_NAME / PROJECT_TITLE)
// 此处通过闭包引用即可
const ASSET_DETAILS = {
'ch-linxi': { kind: 'character', title: '林夕 · 都市白领', hasTri: true, info: [['类别', '人物 · 主角'], ['年龄', '25-30'], ['服装', '宽松米色家居服'], ['妆面', '日常裸妆 · 略疲倦'], ['用途', '主角出镜 · 痛点共鸣'], ['状态', '已确认']] },
'ch-anan': { kind: 'character', title: '阿楠 · 朋友/同事', hasTri: false, info: [['类别', '人物 · 对照角色'], ['年龄', '25-30'], ['服装', '白色衬衫 / 精致'], ['用途', '对照出镜'], ['状态', '生成中']] },
'sc-desk': { kind: 'scene', title: '深夜办公桌', info: [['类别', '场景 · 室内'], ['光线', '台灯暖光 · 屏幕冷光'], ['用途', '镜 1 痛点'], ['状态', '已确认']] },
'sc-bed': { kind: 'scene', title: '卧室床头', info: [['类别', '场景 · 室内'], ['光线', '夜灯暖光'], ['用途', '镜 4 敷膜'], ['状态', '已确认']] },
'sc-subway': { kind: 'scene', title: '通勤地铁', info: [['类别', '场景 · 室内'], ['用途', '镜 5 对照'], ['状态', '失败 · 待重跑']] },
'prod-main': { kind: 'product', title: CURRENT_PRODUCT_NAME, hasTri: false, info: [['类别', '商品 · 当前项目'], ['名称', CURRENT_PRODUCT_NAME], ['三视图', '待生成'], ['状态', '缺三视图']] },
};
// ─── 统一详情面板渲染 ───
function _hashCode(s) { let h = 0; for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; return Math.abs(h); }
function _fmtAssetId(name, kind) {
const seed = _hashCode(name);
const code = String(seed % 1000).padStart(3, '0');
return 'ASSET-20240520-' + (kind === 'model' ? 'M' : kind === 'scene' ? 'S' : 'P') + code;
}
function _fmtFileSize(name) { const seed = _hashCode(name); return (4 + (seed % 100) / 10).toFixed(1) + 'MB'; }
function _fmtFavCt(name) { return String(8 + _hashCode(name) % 80); }
function _fmtDlCt(name) { const n = 200 + _hashCode(name) % 1800; return (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)); }
function renderAssetDetail(payload) {
// payload: { title, tagText, leadLabel, kind ('actor'|'scene'|'product'), ratio, intro, tags[], thumbs[], props[], hasTri, missingTriHint, applyLabel, ownPortraits[], ownTriVersions[] }
document.getElementById('asset-detail-title').textContent = payload.title;
document.getElementById('asset-detail-kind').textContent = '/ ' + payload.tagText;
// 主立绘 · 用户上传有 portraits[0].url 时显示真实图片;否则占位
const leadEl = document.getElementById('asset-detail-lead-img');
// 把当前主图 src/name 挂在 leadEl 的 dataset 上,zoom 按钮读 dataset 始终拿到最新值
function _adSetLead(p) {
if (!leadEl) return;
const old = leadEl.querySelector('img.ad-lead-pic');
if (old) old.remove();
const ph = leadEl.querySelector('.ph-frame');
if (p && p.url) {
if (ph) ph.style.display = 'none';
const img = document.createElement('img');
img.className = 'ad-lead-pic'; img.src = p.url; img.alt = p.name || payload.title;
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;';
leadEl.appendChild(img);
leadEl.dataset.curSrc = p.url; leadEl.dataset.curName = p.name || payload.title;
} else {
if (ph) { ph.style.display = ''; ph.textContent = (p && p.label) || payload.leadLabel || payload.title; }
leadEl.dataset.curSrc = ''; leadEl.dataset.curName = (p && p.label) || payload.leadLabel || payload.title;
}
}
leadEl.innerHTML = `<span class="ph-frame">${payload.leadLabel || payload.title}</span>`;
// 立绘 zoom 按钮 → 打开 lightbox(单次绑定,值从 dataset 读)
const _leadZoomBtn = document.getElementById('asset-detail-zoom-btn');
if (_leadZoomBtn && !_leadZoomBtn.dataset.bound) {
_leadZoomBtn.dataset.bound = '1';
_leadZoomBtn.addEventListener('click', e => {
e.stopPropagation();
if (window.Shell?._openLightbox) Shell._openLightbox(leadEl.dataset.curSrc || '', leadEl.dataset.curName || '');
});
}
// 缩略图 strip · 用户上传有 ownPortraits 数组 → 显示多张真实图;平台预设 → 仅 1 张占位
const ownPortraits = (payload.ownPortraits && payload.ownPortraits.length) ? payload.ownPortraits : null;
const thumbs = ownPortraits || (payload.thumbs && payload.thumbs.length ? payload.thumbs.map(t => ({ label: t })) : [{ label: payload.kind === 'scene' ? '场景' : '立绘' }]);
const thumbsEl = document.getElementById('asset-detail-thumbs');
thumbsEl.innerHTML = thumbs.map((p, i) => {
const inner = p.url
? `<img src="${p.url}" alt="${(p.name||'').replace(/"/g,'&quot;')}" style="width:100%;height:100%;object-fit:cover;display:block;">`
: `<span class="ph-frame">${p.label || ('v'+(i+1))}</span>`;
return `<div class="thumb placeholder${i === 0 ? ' active' : ''}" data-idx="${i}">${inner}</div>`;
}).join('');
_adSetLead(thumbs[0]);
thumbsEl.querySelectorAll('.thumb').forEach(t => t.addEventListener('click', () => {
thumbsEl.querySelectorAll('.thumb').forEach(x => x.classList.remove('active'));
t.classList.add('active');
_adSetLead(thumbs[+t.dataset.idx]);
}));
// 三视图区
const tri = document.getElementById('asset-detail-tri');
const triSection = document.getElementById('asset-detail-tri-section');
const tip = document.getElementById('asset-detail-tip');
const ratioChip = document.getElementById('asset-detail-ratio');
const ownTri = (payload.ownTriVersions && payload.ownTriVersions.length) ? payload.ownTriVersions : null;
if (payload.kind === 'scene') {
triSection.style.display = 'none';
} else if (payload.kind === 'actor') {
triSection.style.display = '';
tri.style.display = '';
tri.classList.remove('actor');
ratioChip.textContent = '16:9';
tip.style.display = 'none';
const _zoomBtn = `<button class="ad-zoom-btn" type="button" data-zoom-tri aria-label="查看大图" title="查看大图"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5"/></svg></button>`;
if (ownTri) {
const cur = ownTri[ownTri.length - 1];
const stripHtml = ownTri.map((v, i) => `<div class="v-thumb${i === ownTri.length - 1 ? ' active' : ''}" data-idx="${i}" title="${v.label} · ${v.ts}"><span class="v">${v.label}</span></div>`).join('');
tri.innerHTML = `
<div class="placeholder"><span class="ph-frame" id="ad-tri-main-lbl">${payload.title} · ${cur.label} · ${cur.ts}</span>${_zoomBtn}</div>
<div class="md-view-versions" style="margin-top:8px;">${stripHtml}</div>`;
const mainLbl = tri.querySelector('#ad-tri-main-lbl');
tri.querySelectorAll('.v-thumb').forEach(t => t.addEventListener('click', () => {
tri.querySelectorAll('.v-thumb').forEach(x => x.classList.remove('active'));
t.classList.add('active');
const v = ownTri[+t.dataset.idx];
if (mainLbl) mainLbl.textContent = `${payload.title} · ${v.label} · ${v.ts}`;
}));
} else {
tri.innerHTML = `<div class="placeholder"><span class="ph-frame">${payload.title} · 三视图 (正/侧/背)</span>${_zoomBtn}</div>`;
}
tri.querySelector('[data-zoom-tri]')?.addEventListener('click', e => {
e.stopPropagation();
const lbl = tri.querySelector('#ad-tri-main-lbl')?.textContent || (payload.title + ' · 三视图');
if (window.Shell?._openLightbox) Shell._openLightbox('', lbl);
});
} else {
// product
triSection.style.display = '';
tri.style.display = '';
tri.classList.remove('actor');
ratioChip.textContent = '16:9';
if (payload.hasTri !== false) {
tri.innerHTML = `<div class="placeholder"><span class="ph-frame">${payload.title} · 三视图</span><button class="ad-zoom-btn" type="button" aria-label="查看大图" title="查看大图"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5"/></svg></button></div>`;
tip.style.display = 'none';
tri.querySelector('.ad-zoom-btn')?.addEventListener('click', e => {
e.stopPropagation();
if (window.Shell?._openLightbox) Shell._openLightbox('', payload.title + ' · 三视图');
});
} else {
tri.innerHTML = `<div class="placeholder missing" data-tri="0">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></svg>
<span>${payload.missingTriHint || '暂未生成三视图(16:9 单图)'}</span>
</div>`;
tip.style.display = 'flex';
}
}
// 简介
document.getElementById('asset-detail-intro').textContent = payload.intro || '暂无简介';
// 标签 chips
const tags = payload.tags && payload.tags.length ? payload.tags : [];
document.getElementById('asset-detail-tags').innerHTML = tags.map(t => `<span class="ad-tag-chip">${t}</span>`).join('')
+ `<button class="ad-tag-add" type="button" title="添加标签"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg></button>`;
// 属性表 (3 列 × N 行 grid)
const props = payload.props || [];
document.getElementById('asset-detail-props').innerHTML = props
.map(([k, v]) => `<div class="ad-prop"><span class="k">${k}</span><span class="v">${v}</span></div>`)
.join('');
// apply 按钮文案 (默认「使用该资产」)
const applyBtn = document.getElementById('asset-detail-apply-btn');
if (applyBtn) applyBtn.textContent = payload.applyLabel || '使用该资产';
document.getElementById('asset-detail-modal').classList.add('show');
}
function openStripDetail(name, sub, kind) {
// 优先查用户上传:演员 MODEL_OWN / 场景 SCENE_OWN(带 portraits + triVersions)
const ownArr = kind === 'model' ? MODEL_OWN : SCENE_OWN;
const ownItem = ownArr.find(x => x.name === name) || null;
const isOwn = !!ownItem;
const sourceLabel = isOwn ? '我的上传' : '平台预设';
if (kind === 'model') {
const actor = !isOwn ? MODEL_LIB.find(x => x.name === name) : null;
const a = actor || { gender: '女', age: '青年', region: '东亚', skin: '白皙', height: '中等',
build: '标准', hairLen: '中发', hairColor: '黑色', vibe: '清新', feature: sub || (isOwn ? '我的上传演员' : '预设演员') };
const tags = [a.vibe, a.age, a.hairLen, a.region, a.skin].filter(Boolean);
const props = [
['性别', a.gender], ['种族', a.region], ['作品ID', _fmtAssetId(name, 'model')],
['年龄段', a.age], ['气质', a.vibe], ['创作人', 'Airshelf'],
['身高', a.height], ['体格', a.build], ['文件大小', _fmtFileSize(name)],
['发型', a.hairLen + ' · ' + a.hairColor], ['来源', sourceLabel], ['发布时间', '2024-05-20'],
];
renderAssetDetail({
title: name, tagText: '人物 · ' + (isOwn ? '我的演员' : '预设演员'), leadLabel: name + ' · 立绘',
kind: 'actor', intro: a.feature || sub || '',
tags, props, applyLabel: '应用到当前项目',
ownPortraits: isOwn ? ownItem.portraits : null,
ownTriVersions: isOwn ? ownItem.triVersions : null,
});
} else {
const props = [
['类别', '场景 · ' + (isOwn ? '我的上传' : '预设')], ['标签', sub || '-'], ['作品ID', _fmtAssetId(name, 'scene')],
['来源', sourceLabel], ['用途', '本项目场景资产'], ['创作人', 'Airshelf'],
['镜头', '通用'], ['光线', '自然光'], ['文件大小', _fmtFileSize(name)],
];
renderAssetDetail({
title: name, tagText: '场景 · ' + (isOwn ? '我的场景' : '预设'), leadLabel: name + ' · 主图',
kind: 'scene', intro: sub || '场景资产',
tags: [sub].filter(Boolean), props, applyLabel: '应用到当前项目',
ownPortraits: isOwn ? ownItem.portraits : null,
});
}
}
function renderStrip(containerId, items, kind) {
const el = document.getElementById(containerId);
if (!el) return;
el.innerHTML = items.map(it => `<div class="asset-card-2" data-strip-kind="${kind}" data-strip-id="${it.id}" data-strip-name="${it.name}" data-strip-sub="${it.sub}">
<div class="placeholder thumb-2"><span class="ph-frame">${it.name}</span></div>
<div class="body-2">
<div class="hstack"><strong style="font-size:13.5px;">${it.name}</strong><span class="spacer"></span><span class="pill info" style="font-size:10.5px;">${it.sub}</span></div>
</div>
</div>`).join('');
el.querySelectorAll('.asset-card-2').forEach(card => {
card.addEventListener('click', () => {
const name = card.dataset.stripName;
const sub = card.dataset.stripSub;
openStripDetail(name, sub, kind);
});
});
}
function openDetail(id) {
const d = ASSET_DETAILS[id];
if (!d) return;
const kindMap = { character: 'actor', scene: 'scene', product: 'product' };
const kindLabelMap = { character: '人物', scene: '场景', product: '商品' };
const baseProps = d.info.slice();
baseProps.push(['作品ID', _fmtAssetId(d.title, d.kind === 'character' ? 'model' : d.kind === 'scene' ? 'scene' : 'product')]);
baseProps.push(['创作人', 'Airshelf']);
baseProps.push(['文件大小', _fmtFileSize(d.title)]);
baseProps.push(['发布时间', '2024-05-20']);
renderAssetDetail({
title: d.title,
tagText: kindLabelMap[d.kind] + (d.kind === 'character' ? ' · 主角/对照' : d.kind === 'product' ? ' · 当前项目' : ' · 预设'),
leadLabel: d.title,
kind: kindMap[d.kind],
intro: (d.info.find(r => r[0] === '用途') || [, ''])[1] || '资产用于本项目生成',
tags: d.info.slice(0, 3).map(r => r[1]).filter(Boolean),
props: baseProps,
hasTri: !!d.hasTri,
missingTriHint: '暂未生成三视图(16:9 单图)',
applyLabel: '应用到当前项目',
});
}
// 用户上传的演员 / 场景(分别累积,source='own')
const MODEL_OWN = [];
const SCENE_OWN = [];
let _curLibKind = 'model';
let _curLibSource = 'all';
function _libItemsForSource(kind, src) {
const isModel = kind === 'model';
const presets = isModel ? MODEL_LIB : SCENE_LIB;
const owns = isModel ? MODEL_OWN : SCENE_OWN;
if (src === 'preset') return presets;
if (src === 'own') return owns;
return [...owns, ...presets];
}
function _renderLibGrid() {
const isModel = _curLibKind === 'model';
const items = _libItemsForSource(_curLibKind, _curLibSource);
const grid = document.getElementById('ml-grid');
// 「添加演员 / 添加场景」入口卡 · 平台预设是只读素材,不展示入口
const uploadCardHTML = (_curLibSource === 'preset') ? '' : `
<div class="ml-card ml-upload-card" id="ml-upload-card" role="button" tabindex="0" aria-label="${isModel ? '添加演员' : '添加场景'}">
<div class="up-thumb">
<div class="up-plus">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
</div>
</div>
<div class="ml-card-nm">${isModel ? '添加演员' : '添加场景'}</div>
<div class="ml-card-sub">// AI 生成 / 本地上传</div>
</div>
`;
grid.innerHTML = uploadCardHTML + items.map(it => `
<div class="ml-card" data-name="${it.name}" data-sub="${it.sub}">
<div class="placeholder"><span class="ph-frame">${it.name}</span></div>
<div class="ml-card-nm">${it.name}</div>
<div class="ml-card-sub">${it.sub}</div>
</div>
`).join('');
const upCard = grid.querySelector('#ml-upload-card');
if (upCard) {
upCard.addEventListener('click', () => _openLibUploadChoice());
upCard.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _openLibUploadChoice(); }
});
}
// 普通卡片 click → 应用 / 详情
grid.querySelectorAll('.ml-card:not(.ml-upload-card)').forEach(card => {
card.addEventListener('click', (e) => {
const name = card.dataset.name;
const sub = card.dataset.sub;
if (e.target.closest('[data-apply]')) {
e.stopPropagation();
Shell.toast('已应用「' + name + '」', isModel ? '演员库 · 来自' + (_curLibSource === 'own' ? '我的上传' : '预设') : '场景库 · 来自' + (_curLibSource === 'own' ? '我的上传' : '预设'));
document.getElementById('ml-modal-bg').classList.remove('show');
return;
}
openStripDetail(name, sub, _curLibKind);
});
});
}
/* ════════ 添加演员 / 场景 · 工作台(同模特库 ml-canvas)════════ */
// 词典:模特→演员、立绘→立绘/场景图、三视图(场景隐藏)
const _MC_LABELS = {
actor: {
title: '添加演员', mono: '// 添加演员 · 工作台',
emptyTitle: '用 AI 生成一位新演员',
emptyDesc: '描述外形 + 风格 + 服装,AI 会同时生成立绘 + 正/侧/背三视图,加入演员库。',
nameSec: '// 演员姓名', namePh: '给演员起个名字…',
portraitSec: '// 演员立绘', portraitPick: '点击或拖入立绘',
portraitAiDesc: '在左侧 AI 生成后<br>点击想要的立绘添加到这里',
promptPh: '描述演员外形、年龄、风格、服饰…例如:清新校园风女生',
commit: '加入演员库', toast: '已加入演员库',
examples: [
{ ex: '清新校园风女生,黑色长直发,白色 T 恤 + 牛仔短裙,室内自然光', lbl: '清新校园风女生' },
{ ex: '都市 OL 通勤,黑色西装套装,30 岁知性气质', lbl: '都市 OL 通勤' },
{ ex: '健身房教练男性,运动背心 + 短裤,健身房布景', lbl: '健身教练 · 男' },
{ ex: '日系简约女生,棕色短发,米色针织衫,温柔气质', lbl: '日系简约' },
],
},
scene: {
title: '添加场景', mono: '// 添加场景 · 工作台',
emptyTitle: '用 AI 生成一处新场景',
emptyDesc: '描述类型 + 氛围 + 光线,AI 会生成场景图,加入场景库。',
nameSec: '// 场景名称', namePh: '给场景起个名字…',
portraitSec: '// 场景图', portraitPick: '点击或拖入场景图',
portraitAiDesc: '在左侧 AI 生成后<br>点击想要的场景图添加到这里',
promptPh: '描述场景类型、氛围、光线…例如:夜景咖啡馆,暖光灯',
commit: '加入场景库', toast: '已加入场景库',
examples: [
{ ex: '日系咖啡馆室内,木质桌面,落地窗自然光,午后氛围', lbl: '日系咖啡馆' },
{ ex: '城市天台夜景,霓虹灯背景,冷色调高对比', lbl: '城市天台夜景' },
{ ex: '极简白色摄影棚,无缝背景,顶光均匀', lbl: '极简摄影棚' },
{ ex: '复古港风街头,湿润地面,招牌灯反射', lbl: '港风街头' },
],
},
};
function _mcKind() { return _curLibKind === 'model' ? 'actor' : 'scene'; }
function _mcOwnsArr() { return _curLibKind === 'model' ? MODEL_OWN : SCENE_OWN; }
function _mcPresetsArr() { return _curLibKind === 'model' ? MODEL_LIB : SCENE_LIB; }
// ─── 工作台 DOM 引用 ───
const _uploadCanvas = document.getElementById('ml-canvas');
const _mcUp = document.getElementById('mc-up');
function _applyKindToCanvas() {
const k = _mcKind();
const L = _MC_LABELS[k];
_mcUp.dataset.kind = k;
document.getElementById('ml-canvas-title').textContent = L.title;
document.getElementById('ml-canvas-mono').textContent = L.mono;
document.getElementById('mc-empty-title').textContent = L.emptyTitle;
document.getElementById('mc-empty-desc').textContent = L.emptyDesc;
document.getElementById('mc-up-name-label').textContent = L.nameSec;
document.getElementById('mc-up-name').placeholder = L.namePh;
document.getElementById('mc-up-portrait-label').textContent = L.portraitSec;
document.getElementById('mc-portrait-ai-empty-desc').innerHTML = L.portraitAiDesc;
document.getElementById('mc-portrait-local-t').textContent = L.portraitPick;
// 场景仅允许 1 张本地图;演员允许多张
const _localInput = document.getElementById('mc-portrait-local-input');
const _localD = document.querySelector('.mc-portrait-local .drop .d');
if (k === 'scene') {
_localInput.removeAttribute('multiple');
if (_localD) _localD.textContent = '仅支持 1 张 JPG / PNG / WEBP · ≤ 10MB';
} else {
_localInput.setAttribute('multiple', '');
if (_localD) _localD.textContent = '支持多张 JPG / PNG / WEBP · ≤ 10MB / 张';
}
document.getElementById('mc-input-text').placeholder = L.promptPh;
document.getElementById('mc-up-commit-label').textContent = L.commit;
// 示例 chip 重渲(场景需要不同的示例)
const ex = document.getElementById('mc-empty-examples');
if (ex && !ex.dataset.cleanInit) {
ex.dataset.cleanInit = '1';
}
ex.innerHTML = L.examples.map(e => `<button class="ex" type="button" data-ex="${e.ex.replace(/"/g, '&quot;')}">${e.lbl}</button>`).join('');
// 重新绑定示例 click
ex.querySelectorAll('.ex').forEach(b => {
b.addEventListener('click', () => {
const t = document.getElementById('mc-input-text');
t.value = b.dataset.ex;
t.dispatchEvent(new Event('input'));
t.focus();
});
});
}
function _openLibUploadChoice() {
_applyKindToCanvas();
// 当前 kind 下重渲右侧栏:刷新底部状态文案(场景:名称 / 场景图,无三视图)
if (typeof _renderRight === 'function') { try { _renderRight(); } catch(e) {} }
_uploadCanvas.classList.add('show');
_uploadCanvas.setAttribute('aria-hidden', 'false');
}
function _closeUploadCanvasNow() {
_uploadCanvas.classList.remove('show');
_uploadCanvas.setAttribute('aria-hidden', 'true');
}
// ─── 工作台状态(AI / Local 两个 tab 各自独立)───
let _mcRightTab = 'ai';
const _mcAi = { name: '', portrait: null, triVersions: [], triActiveIdx: -1 };
const _mcLocal = { name: '', portraits: [], triVersions: [], triActiveIdx: -1 };
function _mcCurState() { return _mcRightTab === 'ai' ? _mcAi : _mcLocal; }
function _hasTri(s) { return s.triVersions.length > 0 && s.triActiveIdx >= 0; }
function _kindRequiresTri() { return _mcKind() === 'actor'; }
function _isWorkbenchDirty() {
if (!_uploadCanvas.classList.contains('show')) return false;
if ((_mcAi.name || '').trim()) return true;
if (_mcAi.portrait) return true;
if (_mcAi.triVersions.length > 0) return true;
if ((_mcLocal.name || '').trim()) return true;
if (_mcLocal.portraits.length > 0) return true;
if (_mcLocal.triVersions.length > 0) return true;
return false;
}
function _resetWorkbenchState() {
if (_mcAi.portrait?.cellEl) _mcAi.portrait.cellEl.classList.remove('selected');
_mcAi.name = ''; _mcAi.portrait = null; _mcAi.triVersions = []; _mcAi.triActiveIdx = -1;
_mcLocal.name = ''; _mcLocal.portraits = []; _mcLocal.triVersions = []; _mcLocal.triActiveIdx = -1;
const nameInput = document.getElementById('mc-up-name');
if (nameInput) nameInput.value = '';
_renderRight();
}
// ─── 二次确认弹窗 ───
const _leaveBg = document.getElementById('mc-leave-bg');
const _leaveBody = document.getElementById('mc-leave-body');
let _leavePending = null;
function _openLeaveConfirm(mode, onConfirm) {
const ki = _mcKind() === 'actor' ? '加入演员库' : '加入场景库';
if (mode === 'nav') {
_leaveBody.innerHTML = `工作台已有内容,跳转到其他页面后<b>不会保存</b>。可继续编辑并点「${ki}」来保留进度。`;
} else {
_leaveBody.innerHTML = `工作台已有内容,退出后<b>不会保存</b>。可继续编辑并点「${ki}」来保留进度。`;
}
_leavePending = onConfirm || null;
_leaveBg.classList.add('show');
_leaveBg.setAttribute('aria-hidden', 'false');
}
function _closeLeaveConfirm() {
_leaveBg.classList.remove('show');
_leaveBg.setAttribute('aria-hidden', 'true');
_leavePending = null;
}
document.getElementById('mc-leave-cancel').addEventListener('click', _closeLeaveConfirm);
_leaveBg.addEventListener('click', e => { if (e.target === _leaveBg) _closeLeaveConfirm(); });
document.getElementById('mc-leave-confirm').addEventListener('click', () => {
const fn = _leavePending;
_closeLeaveConfirm();
if (typeof fn === 'function') fn();
});
function _closeUploadChoice() {
if (_isWorkbenchDirty()) {
_openLeaveConfirm('exit', () => { _resetWorkbenchState(); _closeUploadCanvasNow(); });
return;
}
_closeUploadCanvasNow();
}
document.getElementById('ml-canvas-back').addEventListener('click', _closeUploadChoice);
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && _uploadCanvas.classList.contains('show') && !_leaveBg.classList.contains('show')) {
_closeUploadChoice();
}
});
// 全局拦截 · 工作台展开时,根据脏态决定是否二次确认
document.addEventListener('click', e => {
if (!_uploadCanvas.classList.contains('show')) return;
const dirty = _isWorkbenchDirty();
// 库 X · 关闭整个库 modal(脏态确认 · 非脏直接关)
if (e.target.closest('#ml-close-btn')) {
e.preventDefault(); e.stopPropagation();
const doClose = () => {
_resetWorkbenchState();
_closeUploadCanvasNow();
document.getElementById('ml-modal-bg').classList.remove('show');
};
if (dirty) _openLeaveConfirm('exit', doClose); else doClose();
return;
}
// 库左侧「来源」筛选项 · 切换 = 离开工作台
const sideItem = e.target.closest('#ml-side .ml-side-item');
if (sideItem) {
e.preventDefault(); e.stopPropagation();
const src = sideItem.dataset.source;
const doSwitch = () => {
_resetWorkbenchState();
_closeUploadCanvasNow();
_curLibSource = src;
document.querySelectorAll('#ml-side .ml-side-item').forEach(x =>
x.classList.toggle('active', x.dataset.source === src));
_renderLibGrid();
};
if (dirty) _openLeaveConfirm('nav', doSwitch); else doSwitch();
return;
}
if (!dirty) return; // 后续仅脏态时拦截
// 外页跳转
const a = e.target.closest('a[href]');
if (a) {
const href = a.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
e.preventDefault(); e.stopPropagation();
_openLeaveConfirm('nav', () => { _resetWorkbenchState(); _closeUploadCanvasNow(); location.href = href; });
return;
}
// 余额胶囊
if (e.target.closest('.balance-chip')) {
e.preventDefault(); e.stopPropagation();
_openLeaveConfirm('nav', () => { _resetWorkbenchState(); _closeUploadCanvasNow(); location.href = 'account.html'; });
return;
}
}, true);
window.addEventListener('beforeunload', e => {
if (_isWorkbenchDirty()) { e.preventDefault(); e.returnValue = ''; return ''; }
});
// ─── 左:AI 生成区 ───
const _mcInputText = document.getElementById('mc-input-text');
const _mcSendBtn = document.getElementById('mc-send-btn');
_mcInputText?.addEventListener('input', () => {
_mcSendBtn.disabled = _mcInputText.value.trim().length === 0;
_mcInputText.style.height = 'auto';
_mcInputText.style.height = Math.min(_mcInputText.scrollHeight, 220) + 'px';
});
const _MC_SVG = {
rerun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/></svg>',
dl: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>',
more: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none"/></svg>',
adopt: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg>',
del: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6"/></svg>',
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>',
};
function _mcAppendMsg(prompt, refs) {
const inner = document.getElementById('mc-stream-inner');
if (!inner) return;
const empty = inner.querySelector('.mc-empty');
if (empty) empty.remove();
const safe = String(prompt).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'})[c]);
const tags = ['3:4', '默认', '4 张'];
const tagsHtml = tags.map(t => `<span class="meta-chip">${t}</span>`).join('<span class="sep">·</span>');
const subjLabel = _mcKind() === 'actor' ? '演员' : '场景';
const cells = Array.from({ length: 4 }, (_, i) => `
<div class="mc-cell gen" data-idx="${i}">
<div class="ph-frame">生成中 · v${i + 1}</div>
<div class="cell-ops" hidden>
<button type="button" data-act="cell-rerun" title="再次生成">${_MC_SVG.rerun}</button>
<button type="button" data-act="cell-dl" title="下载">${_MC_SVG.dl}</button>
</div>
</div>`).join('');
const msg = document.createElement('div');
msg.className = 'mc-msg';
msg.innerHTML = `
<div class="mc-msg-prompt">
<div class="quote">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c0-3.5 3-6 6-6s6 2.5 6 6"/><circle cx="9" cy="8" r="4"/><path d="M19 8v6M22 11h-6"/></svg>
</div>
<div class="pt"><div class="pt-text">${safe}</div><div class="pt-tags">${tagsHtml}</div></div>
</div>
<div class="mc-msg-grid">${cells}</div>
<div class="mc-msg-ops">
<button type="button" data-act="edit">${_MC_SVG.edit}重新编辑</button>
<button type="button" data-act="rerun">${_MC_SVG.rerun}再次生成</button>
</div>`;
inner.appendChild(msg);
setTimeout(() => {
msg.querySelectorAll('.mc-cell').forEach((c, i) => {
c.classList.remove('gen');
const ph = c.querySelector('.ph-frame');
if (ph) ph.textContent = subjLabel + ' · v' + (i + 1);
const ops = c.querySelector('.cell-ops');
if (ops) ops.hidden = false;
});
_bindMcCellPick();
}, 1600);
msg.querySelectorAll('[data-act="rerun"]').forEach(b =>
b.addEventListener('click', e => { e.stopPropagation(); _mcAppendMsg(prompt, []); }));
msg.querySelectorAll('[data-act="edit"]').forEach(b =>
b.addEventListener('click', e => {
e.stopPropagation();
_mcInputText.value = prompt;
_mcInputText.focus();
_mcInputText.dispatchEvent(new Event('input'));
}));
msg.querySelectorAll('[data-act="cell-rerun"]').forEach(b =>
b.addEventListener('click', e => {
e.stopPropagation();
const cell = b.closest('.mc-cell');
const ops = cell.querySelector('.cell-ops');
const ph = cell.querySelector('.ph-frame');
const idx = Number(cell.dataset.idx || 0);
cell.classList.add('gen'); cell.classList.remove('selected');
if (ops) ops.hidden = true;
if (ph) ph.textContent = '生成中 · v' + (idx + 1);
setTimeout(() => {
cell.classList.remove('gen');
if (ph) ph.textContent = subjLabel + ' · v' + (idx + 1);
if (ops) ops.hidden = false;
}, 1200 + Math.random() * 600);
Shell.toast('已重跑', '该图重新生成中');
}));
msg.querySelectorAll('[data-act="cell-dl"]').forEach(b =>
b.addEventListener('click', e => { e.stopPropagation(); Shell.toast('下载', '已开始下载 · MOCK'); }));
const stream = document.getElementById('mc-stream');
if (stream) stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' });
}
_mcSendBtn?.addEventListener('click', () => {
const txt = _mcInputText.value.trim();
if (!txt) return;
_mcAppendMsg(txt, _mcRefList.slice());
_mcInputText.value = '';
_mcInputText.style.height = 'auto';
_mcSendBtn.disabled = true;
_mcRefList = [];
_renderMcRefs();
});
_mcInputText?.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); _mcSendBtn.click(); }
});
// 参考图上传
const _mcAiRefInput = document.getElementById('mc-ai-ref-input');
const _mcRefs = document.getElementById('mc-input-refs');
let _mcRefList = [];
document.getElementById('mc-add-btn')?.addEventListener('click', () => _mcAiRefInput.click());
_mcAiRefInput?.addEventListener('change', e => {
const files = [...(e.target.files || [])].filter(f => /^image\//.test(f.type));
files.forEach(f => _mcRefList.push({ name: f.name, url: URL.createObjectURL(f) }));
e.target.value = '';
_renderMcRefs();
});
function _renderMcRefs() {
if (!_mcRefs) return;
_mcRefs.innerHTML = _mcRefList.map((r, i) => `
<div class="mc-input-ref">
<img src="${r.url}" alt="${r.name}">
<button class="x" data-idx="${i}" aria-label="移除"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
</div>`).join('');
_mcRefs.querySelectorAll('.x').forEach(x => x.addEventListener('click', () => {
_mcRefList.splice(+x.dataset.idx, 1);
_renderMcRefs();
}));
}
// Tab 切换
document.querySelectorAll('.mc-up-tab').forEach(btn => {
btn.addEventListener('click', () => {
_mcRightTab = btn.dataset.tab;
document.querySelectorAll('.mc-up-tab').forEach(b => b.classList.toggle('active', b === btn));
document.querySelectorAll('.mc-up-body [data-show]').forEach(el => {
el.hidden = el.dataset.show !== _mcRightTab;
});
document.getElementById('mc-up-name').value = _mcCurState().name;
_renderRight();
});
});
const _mcUpName = document.getElementById('mc-up-name');
_mcUpName?.addEventListener('input', () => {
_mcCurState().name = _mcUpName.value;
_updateCommit();
});
// AI tab · 立绘选中态
const _aiEmpty = document.getElementById('mc-portrait-ai-empty');
const _aiPicked = document.getElementById('mc-portrait-ai-picked');
const _aiLabel = document.getElementById('mc-portrait-ai-label');
function _renderAiPortrait() {
if (_mcAi.portrait) {
_aiEmpty.hidden = true; _aiPicked.hidden = false;
_aiLabel.textContent = _mcAi.portrait.label || '立绘';
} else { _aiEmpty.hidden = false; _aiPicked.hidden = true; }
}
function _setAiPortrait(data) {
_mcAi.portrait = data;
_renderAiPortrait();
_updateTriBtn(); _updateCommit();
}
function _clearAiPortrait() {
if (_mcAi.portrait?.cellEl) _mcAi.portrait.cellEl.classList.remove('selected');
_mcAi.portrait = null;
_mcAi.triVersions = []; _mcAi.triActiveIdx = -1;
_renderAiPortrait(); _renderTriView(); _updateTriBtn(); _updateCommit();
}
document.getElementById('mc-portrait-ai-clear').addEventListener('click', _clearAiPortrait);
// Local tab · 多张立绘 / 场景图
const _lpDrop = document.getElementById('mc-portrait-local-drop');
const _lpInput = document.getElementById('mc-portrait-local-input');
const _lpList = document.getElementById('mc-portrait-local-list');
const _lpCount = document.getElementById('mc-portrait-local-count');
function _renderLocalPortraits() {
_lpCount.textContent = _mcLocal.portraits.length;
_lpList.innerHTML = _mcLocal.portraits.map((p, i) => `
<div class="thumb">
<img src="${p.url}" alt="${p.name}">
<button class="x" data-idx="${i}" aria-label="移除"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
</div>`).join('');
_lpList.querySelectorAll('.x').forEach(x => x.addEventListener('click', () => {
_mcLocal.portraits.splice(+x.dataset.idx, 1);
if (_mcLocal.portraits.length === 0) {
_mcLocal.triVersions = []; _mcLocal.triActiveIdx = -1;
}
_renderLocalPortraits(); _renderTriView(); _updateTriBtn(); _updateCommit();
}));
}
function _lpAdd(files) {
const imgs = [...(files || [])].filter(f => /^image\//.test(f.type));
if (!imgs.length) return;
// 场景:仅允许 1 张 · 新选直接替换旧的(后选覆盖前选)
if (_mcKind() === 'scene') {
const f = imgs[0];
_mcLocal.portraits = [{ file: f, url: URL.createObjectURL(f), name: f.name, size: f.size }];
} else {
imgs.forEach(f => _mcLocal.portraits.push({ file: f, url: URL.createObjectURL(f), name: f.name, size: f.size }));
}
_renderLocalPortraits(); _updateTriBtn(); _updateCommit();
}
_lpDrop?.addEventListener('click', () => _lpInput.click());
_lpDrop?.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _lpInput.click(); } });
_lpInput?.addEventListener('change', e => { _lpAdd(e.target.files); e.target.value = ''; });
['dragenter', 'dragover'].forEach(ev => _lpDrop?.addEventListener(ev, e => { e.preventDefault(); _lpDrop.classList.add('dragover'); }));
['dragleave', 'drop'].forEach(ev => _lpDrop?.addEventListener(ev, e => { e.preventDefault(); _lpDrop.classList.remove('dragover'); }));
_lpDrop?.addEventListener('drop', e => _lpAdd(e.dataTransfer?.files));
// 三视图模块 · 演员使用 · 场景隐藏
const _triSec = document.getElementById('mc-triview-sec');
const _triResultImg = document.getElementById('mc-triview-result');
const _triFrame = document.getElementById('mc-triview-frame');
const _triGenBtn = document.getElementById('mc-triview-gen-btn');
const _triHint = document.getElementById('mc-triview-hint');
const _triOps = document.getElementById('mc-triview-ops');
const _triRerunBtn = document.getElementById('mc-triview-rerun');
const _triHistory = document.getElementById('mc-triview-history');
const _triHistoryRow = document.getElementById('mc-triview-history-row');
const _triHistoryCount = document.getElementById('mc-triview-history-count');
let _triGenerating = false;
function _portraitReady() {
return _mcRightTab === 'ai' ? !!_mcAi.portrait : _mcLocal.portraits.length > 0;
}
function _renderTriView() {
if (!_kindRequiresTri()) return; // 场景模式整个模块被 CSS 隐藏
const s = _mcCurState();
const has = _hasTri(s);
_triSec.classList.toggle('has-result', has);
if (_triGenerating) {
_triGenBtn.hidden = true; _triHint.hidden = true;
_triOps.hidden = !has; _triHistory.hidden = !has;
_triFrame.textContent = '三视图生成中…';
_triResultImg.classList.add('gen');
_triRerunBtn.disabled = true;
if (has) _renderTriHistory(s);
} else if (has) {
_triGenBtn.hidden = true; _triHint.hidden = true;
_triOps.hidden = false; _triHistory.hidden = false;
const ver = s.triVersions[s.triActiveIdx];
_triFrame.textContent = `三视图(正/侧/背) · ${ver.label}`;
_triResultImg.classList.remove('gen');
_triRerunBtn.disabled = false;
_renderTriHistory(s);
} else {
_triGenBtn.hidden = false; _triHint.hidden = false;
_triOps.hidden = true; _triHistory.hidden = true;
_triFrame.textContent = '三视图(正/侧/背)';
_triResultImg.classList.remove('gen');
}
}
function _renderTriHistory(s) {
_triHistoryCount.textContent = s.triVersions.length;
_triHistoryRow.innerHTML = s.triVersions.map((ver, i) => `
<div class="h-thumb${i === s.triActiveIdx ? ' active' : ''}" data-idx="${i}" title="${ver.label} · ${ver.ts}${i === s.triActiveIdx ? ' · 当前采用' : ''}">
<span class="badge">当前</span><span class="v">${ver.label}</span>
</div>`).join('');
_triHistoryRow.querySelectorAll('.h-thumb').forEach(el => {
el.addEventListener('click', () => {
const idx = Number(el.dataset.idx);
if (idx === s.triActiveIdx) return;
s.triActiveIdx = idx;
_renderTriView(); _updateCommit();
});
});
}
function _updateTriBtn() {
if (!_kindRequiresTri()) return;
if (_hasTri(_mcCurState())) return;
const ok = _portraitReady() && !_triGenerating;
_triGenBtn.disabled = !ok;
_triHint.textContent = _portraitReady()
? '// 一键生成正/侧/背 三视图'
: (_mcRightTab === 'ai' ? '// 先选中左侧 AI 立绘' : '// 先上传至少 1 张立绘');
}
function _startTriGen() {
if (!_kindRequiresTri()) return;
if (!_portraitReady() || _triGenerating) return;
_triGenerating = true;
const stateAtStart = _mcCurState();
_renderTriView();
setTimeout(() => {
_triGenerating = false;
const now = new Date();
const ts = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0');
const label = 'v' + (stateAtStart.triVersions.length + 1);
stateAtStart.triVersions.push({ ts, label });
stateAtStart.triActiveIdx = stateAtStart.triVersions.length - 1;
if (stateAtStart === _mcCurState()) _renderTriView();
_updateTriBtn(); _updateCommit();
}, 1600);
}
_triGenBtn.addEventListener('click', _startTriGen);
_triRerunBtn.addEventListener('click', _startTriGen);
// 整体渲染 + 提交
const _mcUpCommit = document.getElementById('mc-up-commit');
const _mcUpStat = document.getElementById('mc-up-stat');
function _updateCommit() {
const s = _mcCurState();
const nameOk = s.name.trim().length > 0;
const portraitOk = _portraitReady();
const needTri = _kindRequiresTri();
const triOk = needTri ? _hasTri(s) : true;
const ready = nameOk && portraitOk && triOk;
_mcUpCommit.disabled = !ready;
if (ready) {
_mcUpStat.classList.add('ok');
_mcUpStat.innerHTML = `✓ 已就绪 · <b>${s.name}</b>`;
} else {
_mcUpStat.classList.remove('ok');
const miss = [];
if (!nameOk) miss.push(_mcKind() === 'actor' ? '姓名' : '名称');
if (!portraitOk) miss.push(_mcKind() === 'actor' ? '立绘' : '场景图');
if (needTri && !triOk) miss.push('三视图');
_mcUpStat.innerHTML = `// 待完成 · <b>${miss.join(' / ')}</b>`;
}
}
function _renderRight() {
_renderAiPortrait();
_renderLocalPortraits();
_renderTriView();
_updateTriBtn();
_updateCommit();
}
_renderRight();
_mcUpCommit?.addEventListener('click', () => {
const s = _mcCurState();
if (_mcUpCommit.disabled) return;
const baseName = s.name.trim().slice(0, 12);
const ts = Date.now().toString(36);
const subjLabel = _mcKind() === 'actor' ? '演员' : '场景';
const ownsArr = _mcOwnsArr();
// 持久化多张立绘 / 场景图 + 三视图历史版本
const portraits = _mcRightTab === 'ai'
? (_mcAi.portrait ? [{ url: '', name: _mcAi.portrait.label || baseName, label: _mcAi.portrait.label || subjLabel }] : [])
: _mcLocal.portraits.map((p, i) => ({ url: p.url, name: p.name || `${baseName}-${i+1}`, label: `本地 ${i+1}` }));
const triVersions = _kindRequiresTri() ? s.triVersions.map(v => ({ ts: v.ts, label: v.label })) : [];
ownsArr.unshift({
id: 'up-' + ts,
name: baseName,
sub: (_mcRightTab === 'ai' ? 'AI 生成' : '我的上传') + ' · ' + (_kindRequiresTri() ? '已含三视图' : subjLabel),
source: 'own',
portraits, triVersions,
kind: _mcKind(),
});
const L = _MC_LABELS[_mcKind()];
Shell.toast(L.toast, `${baseName} · 来源 ${_mcRightTab === 'ai' ? 'AI 生成' : '我的上传'}`);
_resetWorkbenchState();
_curLibSource = 'own';
const side = document.getElementById('ml-side');
side.querySelectorAll('.ml-side-item').forEach(x =>
x.classList.toggle('active', x.dataset.source === 'own'));
const ownCt = side.querySelector('.ml-side-item[data-source="own"] .ct');
if (ownCt) ownCt.textContent = ownsArr.length;
const allCt = side.querySelector('.ml-side-item[data-source="all"] .ct');
if (allCt) allCt.textContent = ownsArr.length + _mcPresetsArr().length;
_renderLibGrid();
_closeUploadCanvasNow();
});
// 左侧 cell 可点击选为立绘(仅 AI tab)
function _bindMcCellPick() {
document.querySelectorAll('#mc-stream .mc-cell').forEach(cell => {
if (cell.dataset.boundPick) return;
cell.dataset.boundPick = '1';
if (!cell.querySelector('.pick-badge')) {
const b = document.createElement('span');
b.className = 'pick-badge';
b.textContent = '已选用';
cell.appendChild(b);
}
cell.addEventListener('click', () => {
if (_mcRightTab !== 'ai') {
Shell.toast('请切到「AI 生成」标签', '只有 AI 模式才能选用左侧立绘');
return;
}
if (cell.classList.contains('gen')) return;
if (cell.classList.contains('selected')) { _clearAiPortrait(); return; }
document.querySelectorAll('#mc-stream .mc-cell.selected').forEach(c => c.classList.remove('selected'));
cell.classList.add('selected');
const label = cell.querySelector('.ph-frame')?.textContent || '立绘';
_mcAi.triVersions = []; _mcAi.triActiveIdx = -1;
_renderTriView();
_setAiPortrait({ label, cellEl: cell });
});
});
}
function openLib(kind) {
_curLibKind = kind;
_curLibSource = 'all';
const isModel = kind === 'model';
const title = isModel ? '演员库' : '场景库';
const presets = isModel ? MODEL_LIB : SCENE_LIB;
const owns = isModel ? MODEL_OWN : SCENE_OWN;
document.getElementById('ml-modal-title').textContent = title;
document.getElementById('ml-modal-ct').textContent = '// 共 ' + (presets.length + owns.length) + ' 个';
// 侧栏 · 来源
const side = document.getElementById('ml-side');
side.innerHTML = `
<div class="ml-side-h">来源</div>
<div class="ml-side-item active" data-source="all">全部 <span class="ct">${presets.length + owns.length}</span></div>
<div class="ml-side-item" data-source="preset">平台预设 <span class="ct">${presets.length}</span></div>
<div class="ml-side-item" data-source="own">我的上传 <span class="ct">${owns.length}</span></div>
`;
side.querySelectorAll('.ml-side-item').forEach(it => {
it.addEventListener('click', () => {
side.querySelectorAll('.ml-side-item').forEach(x => x.classList.remove('active'));
it.classList.add('active');
_curLibSource = it.dataset.source;
_renderLibGrid();
});
});
// toolbar · chip groups (去掉了 btn-up 上传按钮,改用网格内入口卡)
const toolbar = document.getElementById('ml-toolbar');
if (isModel) {
toolbar.innerHTML = `
<div class="chip-group">
<span class="lbl">性别</span>
<button class="chip active" type="button">全部</button>
<button class="chip" type="button">女</button>
<button class="chip" type="button">男</button>
</div>
<div class="chip-group">
<span class="lbl">年龄</span>
<button class="chip active" type="button">全部</button>
<button class="chip" type="button">青年</button>
<button class="chip" type="button">中年</button>
</div>
`;
} else {
toolbar.innerHTML = `
<div class="chip-group">
<span class="lbl">类型</span>
<button class="chip active" type="button">全部</button>
<button class="chip" type="button">室内</button>
<button class="chip" type="button">室外</button>
</div>
<div class="chip-group">
<span class="lbl">氛围</span>
<button class="chip active" type="button">全部</button>
<button class="chip" type="button">日</button>
<button class="chip" type="button">夜</button>
</div>
`;
}
toolbar.querySelectorAll('.chip-group').forEach(group => {
group.querySelectorAll('.chip').forEach(c => {
c.addEventListener('click', () => {
group.querySelectorAll('.chip').forEach(x => x.classList.remove('active'));
c.classList.add('active');
});
});
});
// 卡片网格(含 + 入口 + apply 绑定都在 _renderLibGrid 内完成)
_renderLibGrid();
document.getElementById('ml-modal-bg').classList.add('show');
}
function closeLib() {
document.getElementById('ml-modal-bg').classList.remove('show');
}
function init() {
// 侧栏 ttab → 锚点
document.querySelectorAll('.asset-side .ttab[data-jump]').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.asset-side .ttab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const target = document.getElementById(tab.dataset.jump);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
// 卡片 click → 详情(点击空白区域;按钮和可编辑提示词带 data-stop 不触发)
document.querySelectorAll('.asset-card-2[data-asset-id]').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('[data-stop]')) return;
openDetail(card.dataset.assetId);
});
});
// 重跑按钮
document.querySelectorAll('[data-rerun]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const card = btn.closest('.asset-card-2');
const name = card ? card.querySelector('strong').textContent : '资产';
Shell.toast('重跑「' + name + '」', 'POST /assets/regen · 使用当前提示词');
});
});
// 替换按钮
document.querySelectorAll('[data-replace]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const card = btn.closest('.asset-card-2');
const name = card ? card.querySelector('strong').textContent : '资产';
const kind = card ? card.dataset.assetKind : '';
if (kind === 'character') openLib('model');
else if (kind === 'scene') openLib('scene');
else Shell.toast('替换「' + name + '」', '请从素材库挑选或上传');
});
});
// 提示词区域 blur → 保存
document.querySelectorAll('.asset-card-2 .prompt-box[contenteditable="true"]').forEach(box => {
box.addEventListener('blur', () => {
const card = box.closest('.asset-card-2');
const name = card ? card.querySelector('strong').textContent : '资产';
Shell.toast('提示词已更新', name + ' · 下次重跑生效');
});
});
// 演员库 / 场景库
document.getElementById('open-model-lib')?.addEventListener('click', () => openLib('model'));
document.getElementById('open-scene-lib')?.addEventListener('click', () => openLib('scene'));
// 新增
document.getElementById('asset-add-character')?.addEventListener('click', () => {
document.getElementById('new-character-modal').classList.add('show');
});
document.getElementById('asset-add-scene')?.addEventListener('click', () => {
Shell.toast('+ 新增场景', '请上传场景图或填写提示词');
});
// modal 通用关闭
document.querySelectorAll('.asset-modal-bg').forEach(bg => {
bg.addEventListener('click', (e) => { if (e.target === bg) bg.classList.remove('show'); });
bg.querySelectorAll('.x, [data-modal-close]').forEach(el => {
el.addEventListener('click', () => bg.classList.remove('show'));
});
});
// 详情 modal · 应用 → 关详情 + 关演员/场景库 → 回到项目页
document.getElementById('asset-detail-apply-btn')?.addEventListener('click', () => {
const name = document.getElementById('asset-detail-title').textContent;
document.getElementById('asset-detail-modal').classList.remove('show');
document.getElementById('ml-modal-bg')?.classList.remove('show');
Shell.toast('已应用「' + name + '」', '已加入当前项目');
});
// 详情 modal · AI 生成三视图
document.querySelectorAll('.ai-gen-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
Shell.toast('AI 生成三视图中', '约 12s · POST /assets/tri-view');
});
});
// 新增人物 · 保存
document.getElementById('nc-save-btn')?.addEventListener('click', () => {
Shell.toast('已新增人物', '保存到资产库 · 待 AI 补三视图');
document.getElementById('new-character-modal').classList.remove('show');
});
// 把所有 modal 提升到 body 直接子级,避免被 .content 滚动容器裁切
document.querySelectorAll('.asset-modal-bg, .ml-modal-bg').forEach(el => {
if (el.parentElement !== document.body) document.body.appendChild(el);
});
// 演员库 / 场景库 全屏弹窗关闭按钮(仍保留,经"替换"气泡进入)
document.getElementById('ml-close-btn')?.addEventListener('click', closeLib);
// 注入当前项目的商品名(从 URL ?product= 或默认)
const nameEls = ['asset-prod-name', 'asset-prod-card-name'];
nameEls.forEach(eid => {
const el = document.getElementById(eid);
if (el) el.textContent = CURRENT_PRODUCT_NAME;
});
const thumbLbl = document.getElementById('asset-prod-thumb-label');
if (thumbLbl) thumbLbl.textContent = CURRENT_PRODUCT_NAME + ' · 主图';
// 商品卡 · AI 生成三视图 → 右侧 prod-preview · 预览/采用 双状态 + 点击主图放大
(function setupProdPreview() {
const aigenBtn = document.getElementById('asset-prod-aigen-btn');
const pane = document.getElementById('asset-prod-preview');
const img = document.getElementById('prod-preview-img');
const statusEl = document.getElementById('prod-preview-status');
const foot = document.getElementById('prod-preview-foot');
const triBadge = document.getElementById('asset-prod-tri-badge');
const prodAction = document.getElementById('asset-prod-action');
const history = document.getElementById('prod-preview-history');
const historyRow = document.getElementById('prod-preview-history-row');
const historyCount = document.getElementById('prod-preview-history-count');
if (!aigenBtn || !pane || !img || !statusEl || !foot || !history || !historyRow) return;
const versions = []; // [{ ts, label }]
let previewIdx = -1; // 主图正在「预览」哪一版(浏览态,不动采用状态)
let adoptedIdx = -1; // 真正被「采用」的那一版,决定商品资产生效版本
let generating = false;
function prodName() {
return CURRENT_PRODUCT_NAME || (document.getElementById('asset-prod-card-name')?.textContent ?? '商品');
}
function renderHistory() {
if (versions.length === 0) {
history.classList.remove('show');
return;
}
history.classList.add('show');
historyCount.textContent = versions.length;
historyRow.innerHTML = versions.map((ver, i) => {
const isAdopted = i === adoptedIdx;
const isPreview = i === previewIdx;
const cls = [
isAdopted ? 'adopted' : '',
isPreview && !isAdopted ? 'previewing' : '',
].filter(Boolean).join(' ');
const titleParts = [ver.label, ver.ts];
if (isAdopted) titleParts.push('已采用');
else if (isPreview) titleParts.push('预览中');
return `
<div class="h-thumb ${cls}" data-idx="${i}" title="${titleParts.join(' · ')}">
<span class="badge">已采用</span>
<span class="v">${ver.label}</span>
</div>
`;
}).join('');
historyRow.querySelectorAll('.h-thumb').forEach(el => {
el.addEventListener('click', () => {
const idx = Number(el.dataset.idx);
if (idx === previewIdx) return;
setPreview(idx);
});
});
}
function renderMain() {
if (previewIdx < 0) return;
const ver = versions[previewIdx];
const isAdopted = previewIdx === adoptedIdx;
img.innerHTML = `<span class="ph-frame">${prodName()} · 三视图(正/侧/背) · ${ver.label}</span>`;
img.classList.add('is-zoomable');
img.title = '点击放大查看';
statusEl.textContent = isAdopted
? `${ver.label} · 已采用,不满意可重跑`
: `${ver.label} · 预览中(未采用)`;
foot.innerHTML = `
<button class="btn btn-ghost btn-sm" id="prod-preview-rerun">↻ 重跑</button>
<button class="btn btn-sm ${isAdopted ? '' : 'btn-primary'}" id="prod-preview-adopt" ${isAdopted ? 'disabled title="此版本已采用"' : 'title="将此版本设为商品采用版本,其余转为不通过"'} style="display:inline-flex; align-items:center; gap:4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>
${isAdopted ? '已采用' : '采用此版本'}
</button>
<span class="spacer"></span>
<span class="muted-2 mono" style="font-size:11px;">~¥0.30 / 次</span>
`;
document.getElementById('prod-preview-rerun')?.addEventListener('click', start);
document.getElementById('prod-preview-adopt')?.addEventListener('click', adoptPreview);
}
// 仅切预览主图,不动采用/不动商品资产
function setPreview(idx) {
previewIdx = idx;
renderHistory();
renderMain();
}
// 显式「采用」当前预览版本 · 同步商品资产 + 隐藏缺三视图徽标
function adoptPreview() {
if (previewIdx < 0) return;
if (previewIdx === adoptedIdx) return;
adoptedIdx = previewIdx;
applyAdoption(/* fromClick */ true);
}
function applyAdoption(fromClick) {
const ver = versions[adoptedIdx];
if (triBadge) triBadge.hidden = true;
const detail = ASSET_DETAILS['prod-main'];
if (detail) {
detail.hasTri = true;
detail.currentVersion = ver.label;
detail.info = [
['类别', '商品 · 当前项目'],
['名称', prodName()],
['三视图', '已采用 · ' + ver.label],
['状态', '已三视图'],
];
}
renderHistory();
renderMain();
if (fromClick) Shell.toast('已采用 ' + ver.label, prodName() + ' · 商品资产已更新为该版本');
}
function renderLoading() {
img.innerHTML = `<div style="display:flex;flex-direction:column;gap:6px;align-items:center;"><div class="spinner"></div><span class="ph-frame" style="font-size:10.5px;">生成中</span></div>`;
img.classList.remove('is-zoomable');
img.removeAttribute('title');
statusEl.textContent = '生成中 · 约 12s';
foot.innerHTML = '<span class="muted-2 mono" style="font-size:11px;">// POST /assets/tri-view</span>';
aigenBtn.disabled = true;
}
function start() {
if (generating) return;
generating = true;
pane.classList.add('show');
renderLoading();
setTimeout(() => {
generating = false;
aigenBtn.disabled = false;
const now = new Date();
const ts = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0');
const newVer = { ts, label: 'v' + (versions.length + 1) };
versions.push(newVer);
const newIdx = versions.length - 1;
previewIdx = newIdx;
if (adoptedIdx === -1) {
adoptedIdx = newIdx;
applyAdoption(/* fromClick */ false);
} else {
renderHistory();
renderMain();
Shell.toast('三视图已生成 ' + newVer.label, prodName() + ' · 预览中,满意请点「采用此版本」');
}
}, 1800);
}
// 主图点击 → 放大查看
img.addEventListener('click', (e) => {
if (!img.classList.contains('is-zoomable')) return;
if (previewIdx < 0) return;
e.stopPropagation();
openTriLightbox(versions[previewIdx], previewIdx === adoptedIdx, prodName());
});
aigenBtn.addEventListener('click', (e) => {
e.stopPropagation();
start();
});
})();
}
return { init };
})();
Stage2.init();
/* ============================================================
STAGE 3 · 故事板 · 按场分 · 切换/重跑/应用/历史
============================================================ */
const Stage3 = (function () {
const scenes = [
{ id: 'sc1', name: '场 1 · 深夜办公桌', time: '0-15s', desc: '深夜居家办公环境,女主对镜叹气,皮肤干燥起皮特写。台灯暖光 + 屏幕冷光对比。', prompt: '中景 / 固定机位\n光线:台灯暖光 + 屏幕冷光\n演员:林夕(疲倦状态)\n关键道具:面膜盒(从抽屉露半角)\n氛围:午夜、安静、些许焦虑', adopted: 0, versions: [{ ts: '14:02', label: 'v1' }] },
{ id: 'sc2', name: '场 2 · 面膜包装/特写', time: '15-30s', desc: '女主从抽屉拿出补水面膜,包装盒微距特写 → 面膜布展开 → 30g 精华液滴落慢镜。', prompt: '特写 / 微距推镜\n光线:柔和暖光\n关键道具:面膜布、精华液\n节奏:慢镜头 + 水滴回弹', adopted: 0, versions: [{ ts: '14:08', label: 'v1' }, { ts: '14:21', label: 'v2' }] },
{ id: 'sc3', name: '场 3 · 化妆台/产品定格', time: '30-45s', desc: '第二天早上,女主对镜化妆,皮肤透亮。淡入产品定格大图 + 价格标签 ¥39.9。', prompt: '中景 / 定格\n光线:晨光 + 暖色滤镜\n演员:林夕(精致妆面)\n结尾:产品大图 + 价格 + 购物车浮动', adopted: 0, versions: [{ ts: '14:30', label: 'v1' }] },
];
let curId = scenes[0].id;
function renderRow() {
const row = document.getElementById('sb-scenes-row');
if (!row) return;
row.innerHTML = scenes.map(s => `<div class="sb-scene-thumb${s.id === curId ? ' selected' : ''}" data-sid="${s.id}">
<div class="placeholder"><span class="ph-frame">${s.name.split(' · ')[1] || s.name}</span></div>
<div class="nm">${s.name.split(' · ')[0]}</div>
<div class="sub">${s.time}</div>
</div>`).join('');
row.querySelectorAll('.sb-scene-thumb').forEach(t => {
t.addEventListener('click', () => { curId = t.dataset.sid; renderAll(); });
});
}
function renderMain() {
const s = scenes.find(x => x.id === curId); if (!s) return;
const v = s.versions[s.adopted];
document.getElementById('sb-main-img').innerHTML = `<span class="ph-frame">${s.name} · ${v.label}</span>`;
document.getElementById('sb-side-scene').textContent = s.name.split(' · ')[0];
document.getElementById('sb-prompt-edit').textContent = s.prompt;
// history
const ct = document.getElementById('sb-history-ct');
const hist = document.getElementById('sb-history-row');
ct.textContent = s.versions.length;
if (s.versions.length === 0) {
hist.innerHTML = '<div style="font-size: 11.5px; color: var(--black-alpha-48); padding: 12px 4px;">// 暂无历史版本</div>';
} else {
hist.innerHTML = s.versions.map((vv, i) => `<div class="sb-history-thumb${i === s.adopted ? ' current' : ''}" data-vi="${i}">
<div class="placeholder"><span class="ph-frame">${vv.label}</span></div>
<div class="ts">${vv.ts}</div>
</div>`).join('');
hist.querySelectorAll('.sb-history-thumb').forEach(t => {
t.addEventListener('click', () => {
s.adopted = +t.dataset.vi;
renderMain();
Shell.toast('已切换至 ' + s.versions[s.adopted].label, s.name);
});
});
}
}
function renderAll() { renderRow(); renderMain(); }
function init() {
renderAll();
document.getElementById('sb-rerun-btn')?.addEventListener('click', () => {
const s = scenes.find(x => x.id === curId);
const v = { ts: (new Date()).toTimeString().slice(0, 5), label: 'v' + (s.versions.length + 1) };
s.versions.push(v);
s.adopted = s.versions.length - 1;
Shell.toast('整张重跑', s.name + ' · ' + v.label);
renderAll();
});
}
return { init };
})();
Stage3.init();
/* ============================================================
STAGE 4 · 视频 · 详情 modal(大图 + 历史 + 采用)
============================================================ */
const Stage4 = (function () {
const VIDEOS = {
'v1': { title: '场 1 · 深夜办公桌', time: '0-15s', info: [['场次', '场 1'], ['时长', '15.0s'], ['分辨率', '1080×1920 · 9:16'], ['模型', 'Seedance v2'], ['成本', '¥0.45']], versions: [{ ts: '14:32', label: 'v1' }, { ts: '14:48', label: 'v2' }, { ts: '15:02', label: 'v3' }], adopted: 2, prompt: '中景 / 固定机位\n光线:台灯暖光 + 屏幕冷光\n演员:林夕(疲倦状态)\n关键道具:面膜盒(从抽屉露半角)\n氛围:午夜、安静、些许焦虑' },
'v2': { title: '场 2 · 面膜包装/特写', time: '15-27s', info: [['场次', '场 2'], ['时长', '12.0s'], ['分辨率', '1080×1920 · 9:16'], ['模型', 'Seedance v2'], ['成本', '¥0.45']], versions: [{ ts: '14:35', label: 'v1' }, { ts: '14:52', label: 'v2' }], adopted: 1, prompt: '特写 / 缓推镜\n光线:柔光顶打 + 背景虚化\n关键道具:面膜包装、撕开瞬间\n氛围:精致、放心、产品感' },
'v3': { title: '场 3 · 化妆台/产品定格', time: '27-40s', info: [['场次', '场 3'], ['时长', '13.0s'], ['分辨率', '1080×1920 · 9:16'], ['模型', 'Seedance v2'], ['成本', '¥0.45']], versions: [{ ts: '14:40', label: 'v1' }], adopted: 0, prompt: '中景 / 定格\n光线:晨光 + 暖色滤镜\n演员:林夕(精致妆面)\n结尾:产品大图 + 价格 + 购物车浮动' },
};
let curVid = null;
function getPreviewIndex(v) {
const idx = Number.isInteger(v.preview) ? v.preview : v.adopted;
return v.versions[idx] ? idx : Math.max(0, v.adopted || 0);
}
function openDetail(id) {
const v = VIDEOS[id]; if (!v) return;
curVid = id;
document.getElementById('vd-title').textContent = v.title;
document.getElementById('vd-sub').textContent = '// ' + v.title + ' · ' + v.time;
const previewIdx = getPreviewIndex(v);
const cur = v.versions[previewIdx];
document.getElementById('vd-main-img').innerHTML = `<span class="ph-frame">${v.title} · ${cur.label}</span>`;
document.getElementById('vd-info').innerHTML = v.info.map(([k, val]) => `<div class="row"><span class="k">${k}</span><span class="v">${val}</span></div>`).join('') + `<div class="row"><span class="k">当前展示</span><span class="v" style="color: var(--heat); font-weight:600;">${cur.label} · ${cur.ts}</span></div>`;
const promptEl = document.getElementById('vd-prompt-edit');
if (promptEl) {
promptEl.textContent = v.prompt || '';
promptEl.oninput = () => { v.prompt = promptEl.textContent.trim(); };
}
document.getElementById('vd-history-ct').textContent = v.versions.length;
const row = document.getElementById('vd-history-row');
row.innerHTML = v.versions.map((vv, i) => `<div class="vd-history-thumb${i === previewIdx ? ' current' : ''}${i === v.adopted ? ' adopted' : ''}" data-vi="${i}">
<div class="placeholder"><span class="ph-frame">${vv.label}</span></div>
<div class="ts">${vv.ts}</div>
</div>`).join('');
row.querySelectorAll('.vd-history-thumb').forEach(t => {
t.addEventListener('click', () => {
v.preview = +t.dataset.vi;
openDetail(id);
});
});
document.getElementById('video-detail-modal').classList.add('show');
}
function init() {
// 根据各场实际时长(data-duration)计算总时长 + 单场平均(用于「每场 Seedance 约 n 秒」)
const cards = document.querySelectorAll('.video-card[data-duration]');
if (cards.length) {
let total = 0;
cards.forEach(c => { total += Number(c.dataset.duration) || 0; });
const avg = Math.round(total / cards.length);
const avgEl = document.getElementById('seedance-avg');
const totalEl = document.getElementById('seedance-total');
if (avgEl) avgEl.textContent = String(avg);
if (totalEl) totalEl.textContent = String(total);
}
document.querySelectorAll('.video-card[data-video-id]').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('[data-vstop]')) return;
openDetail(card.dataset.videoId);
});
});
document.getElementById('vd-adopt-btn')?.addEventListener('click', () => {
if (!curVid) return;
const v = VIDEOS[curVid];
v.adopted = getPreviewIndex(v);
v.preview = v.adopted;
Shell.toast('已采用 ' + v.versions[v.adopted].label, v.title + ' · 拼接将用此版');
document.getElementById('video-detail-modal').classList.remove('show');
});
document.getElementById('vd-regen-btn')?.addEventListener('click', () => {
if (!curVid) return;
const v = VIDEOS[curVid];
const promptEl = document.getElementById('vd-prompt-edit');
const promptText = (promptEl?.textContent || '').trim();
if (promptText) v.prompt = promptText;
const nv = { ts: (new Date()).toTimeString().slice(0, 5), label: 'v' + (v.versions.length + 1) };
v.versions.push(nv);
v.preview = v.versions.length - 1;
Shell.toast('重跑中', v.title + ' · 约 30s · 新版预览中');
openDetail(curVid);
});
}
return { init };
})();
Stage4.init();
/* ============================================================
Quota · 全局额度预检(PRD §10.3 四层)
============================================================ */
window.Quota = (function () {
// mock 团队/个人额度快照 - 与 team.html / account.html 数据保持一致
const SNAP = {
userDailyLeft: 499.55, // 个人日剩余
userMonthlyLeft: 9837.40, // 个人月剩余
teamMonthlyLeft: 2837.40, // 团队月剩余(月限额 3000 - 当月已用 162.60)
teamBalance: 327.40, // 团队总余额
};
function buildChecks(est, demoBlock) {
const need = est * 1.2; // PRD §10.3 任务预估 × 1.2
const layers = [
{ name: '个人日剩余', left: SNAP.userDailyLeft, need },
{ name: '个人月剩余', left: SNAP.userMonthlyLeft, need },
{ name: '团队月剩余', left: SNAP.teamMonthlyLeft, need },
{ name: '团队总余额', left: demoBlock ? 0.50 : SNAP.teamBalance, need },
];
return layers.map((l, i) => ({
...l,
ok: l.left >= l.need,
idx: i + 1,
}));
}
function fmt(n) { return '¥' + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
function preflight({ stage, est = 0, hash = '', demo = '', force = false } = {}) {
const isDemoBlock = demo === 'block';
const checks = buildChecks(est, isDemoBlock);
const allOk = checks.every(c => c.ok);
// 渲染 stage 行
document.getElementById('quota-stage-row').innerHTML =
'<span style="font-family:var(--font-mono);font-size:11.5px;color:var(--black-alpha-48);letter-spacing:.02em;">// 任务</span> <strong>' + stage + '</strong> · 预估扣费 <strong style="color:var(--heat);font-variant-numeric:tabular-nums;">' + fmt(est) + '</strong> <span style="font-family:var(--font-mono);font-size:11px;color:var(--black-alpha-48);">(×1.2 预留 = ' + fmt(est * 1.2) + ')</span>';
// 渲染 4 行检查
document.getElementById('quota-checks').innerHTML = checks.map(c => `
<div style="display:grid;grid-template-columns:22px 1fr auto;gap:8px;align-items:baseline;font-size:12.5px;padding:8px 10px;background:var(--background-lighter);border:1px solid var(--border-faint);border-radius:var(--r-sm);">
<span style="width:18px;height:18px;border-radius:50%;display:grid;place-items:center;font-family:var(--font-mono);font-size:10.5px;font-weight:600;background:${c.ok ? 'rgba(66,195,102,.12)' : 'rgba(235,52,36,.12)'};color:${c.ok ? 'var(--accent-forest)' : 'var(--accent-crimson)'};">${c.idx}</span>
<span><strong style="color:var(--accent-black);font-weight:500;">${c.name}</strong> <span style="color:var(--black-alpha-48);font-family:var(--font-mono);font-size:11px;">${fmt(c.left)}${fmt(c.need)}</span></span>
<span style="font-family:var(--font-mono);font-size:11px;color:${c.ok ? 'var(--accent-forest)' : 'var(--accent-crimson)'};font-weight:600;">${c.ok ? '✓ 通过' : '✗ 不足'}</span>
</div>
`).join('');
// 标题 + footer 按钮
const ic = document.getElementById('quota-ic');
const title = document.getElementById('quota-title');
const sub = document.getElementById('quota-sub');
const tip = document.getElementById('quota-block-tip');
const confirmBtn = document.getElementById('quota-confirm');
const topupBtn = document.getElementById('quota-topup');
if (allOk) {
ic.style.background = 'rgba(66,195,102,.12)';
ic.style.color = 'var(--accent-forest)';
ic.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>';
title.firstChild.textContent = '额度预检通过';
sub.textContent = '// 4 层检查 · 全部通过';
tip.style.display = 'none';
confirmBtn.style.display = '';
topupBtn.style.display = 'none';
confirmBtn.onclick = () => {
Shell.closeModal('quota-bg');
Shell.toast('已确认扣费', stage + ' · 预估 ' + fmt(est));
if (hash) location.hash = hash;
};
} else {
ic.style.background = 'rgba(235,52,36,.12)';
ic.style.color = 'var(--accent-crimson)';
ic.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>';
title.firstChild.textContent = '额度预检未通过';
sub.textContent = '// ' + checks.filter(c => !c.ok).length + ' 层不通过 · 任务已拦截';
tip.style.display = '';
confirmBtn.style.display = 'none';
topupBtn.style.display = '';
}
Shell.openModal('quota-bg');
}
return { preflight };
})();
/* ============================================================
三视图 · 放大查看 lightbox · setupProdPreview 共用
============================================================ */
function openTriLightbox(ver, isAdopted, prodName) {
let bg = document.getElementById('tri-lightbox-bg');
if (!bg) {
bg = document.createElement('div');
bg.id = 'tri-lightbox-bg';
bg.className = 'modal-bg';
bg.innerHTML = `
<div class="tri-lightbox" role="dialog" aria-label="三视图放大查看">
<button class="tri-lightbox-close" type="button" aria-label="关闭">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
<div class="tri-lightbox-head">
// 三视图(正/侧/背) · <span class="lb-ver" id="tri-lightbox-label">v1</span>
<span class="lb-tag" id="tri-lightbox-tag" hidden>已采用</span>
</div>
<div class="placeholder tri-lightbox-img" id="tri-lightbox-img"></div>
<div class="tri-lightbox-foot">
<span id="tri-lightbox-meta">// 生成于 --:--</span>
<span class="spc"></span>
<span><kbd>Esc</kbd> 关闭</span>
</div>
</div>
`;
document.body.appendChild(bg);
bg.addEventListener('click', (e) => {
if (e.target === bg) Shell.closeModal('tri-lightbox-bg');
});
bg.querySelector('.tri-lightbox-close')?.addEventListener('click', () => {
Shell.closeModal('tri-lightbox-bg');
});
}
bg.querySelector('#tri-lightbox-img').innerHTML =
`<span class="ph-frame">${prodName} · 三视图(正/侧/背) · ${ver.label}</span>`;
bg.querySelector('#tri-lightbox-label').textContent = ver.label;
const tag = bg.querySelector('#tri-lightbox-tag');
tag.hidden = !isAdopted;
bg.querySelector('#tri-lightbox-meta').textContent = `// 生成于 ${ver.ts}`;
Shell.openModal('tri-lightbox-bg');
}
/* ═══════════════════════════════════════════════════════════
STAGE 5 · 时间轴编辑器交互(剪映式)
select / seek / drag-playhead / play-pause / zoom / del / split / trim
═══════════════════════════════════════════════════════════ */
(function initTimelineEditor() {
const $tl = document.getElementById('ed-timeline');
if (!$tl) return;
const $ruler = document.getElementById('ed-ruler');
const $playhead = document.getElementById('ed-playhead');
const $laneV = document.getElementById('ed-lane-video');
const $laneS = document.getElementById('ed-lane-subtitle');
const $playBtn = document.getElementById('ed-play-btn');
const $playIcon = document.getElementById('ed-play-icon');
const $prevBtn = document.getElementById('ed-prev-btn');
const $nextBtn = document.getElementById('ed-next-btn');
const $splitBtn = document.getElementById('ed-split-btn');
const $copyBtn = document.getElementById('ed-copy-btn');
const $delBtn = document.getElementById('ed-del-btn');
const $zoom = document.getElementById('ed-zoom-input');
const $curTime = document.getElementById('ed-cur-time');
const $totalTime= document.getElementById('ed-total-time');
const $insName = document.getElementById('ed-inspect-name');
const $insStart = document.getElementById('ed-inspect-start');
const $insDur = document.getElementById('ed-inspect-dur');
const $canvasLb = document.getElementById('ed-canvas-label');
const TOTAL = 15;
let currentTime = 0;
let selectedEl = null;
let playing = false;
let rafId = null;
let lastTs = 0;
const PLAY_SVG = '<path d="M5 4l7 4-7 4z" fill="currentColor"/>';
const PAUSE_SVG = '<path d="M4 3h3v10H4zM9 3h3v10H9z" fill="currentColor"/>';
function fmtTime(s) {
s = Math.max(0, Math.min(TOTAL, s));
const m = Math.floor(s / 60);
const sec = s - m * 60;
return String(m).padStart(2,'0') + ':' + sec.toFixed(2).padStart(5,'0');
}
// 磁吸时间轴 · 仅 data-dur (当前时长 · 秒) + data-max (源最大 · 可恢复至此)
const MIN_DUR = 0.2;
const $laneB = document.querySelector('#ed-timeline .bgm-track .lane');
const lanes = { video: $laneV, subtitle: $laneS, bgm: $laneB };
const D = (c) => Number(c.dataset.dur || 1);
const M = (c) => Number(c.dataset.max || 1);
const setD = (c, v) => { c.dataset.dur = String(Math.max(MIN_DUR, Math.min(M(c), v))); };
function clipDur(c) { return D(c); }
function clipStart(c) {
let acc = 0;
for (const sib of c.parentElement.querySelectorAll('.clip')) {
if (sib === c) return acc;
acc += D(sib);
}
return 0;
}
function clipEnd(c) { return clipStart(c) + D(c); }
// 磁吸布局:每轨片段按 DOM 顺序紧贴排列 · 无 gap
function layoutLane(lane) {
let acc = 0;
for (const clip of lane.querySelectorAll('.clip')) {
const dur = D(clip);
clip.style.left = (acc / TOTAL * 100) + '%';
clip.style.width = (dur / TOTAL * 100) + '%';
acc += dur;
}
}
function layoutAll() {
Object.values(lanes).forEach(l => l && layoutLane(l));
}
function clipAtTimeOnTrack(track, t) {
const lane = lanes[track];
if (!lane) return null;
for (const c of lane.querySelectorAll('.clip')) {
if (t >= clipStart(c) && t < clipEnd(c)) return c;
}
return null;
}
function updateTimeUI() {
$curTime.textContent = fmtTime(currentTime);
$totalTime.textContent = fmtTime(TOTAL);
$playhead.style.left = (currentTime / TOTAL * 100) + '%';
const v = clipAtTimeOnTrack('video', currentTime);
if ($canvasLb) {
$canvasLb.textContent = v
? '9:16 预览 · 1080×1920 · ' + (v.dataset.label || '')
: '9:16 预览 · 1080×1920';
}
updateActionButtons();
}
function selectClip(clip) {
document.querySelectorAll('#ed-timeline .clip.selected').forEach(c => {
c.classList.remove('selected');
c.querySelectorAll('[data-trim]').forEach(t => t.remove());
});
selectedEl = clip;
if (clip) {
clip.classList.add('selected');
// 三轨皆可剪 · 任意选中片段都加 trim 把手
const l = document.createElement('span'); l.className = 'trim-l'; l.dataset.trim = 'l';
const r = document.createElement('span'); r.className = 'trim-r'; r.dataset.trim = 'r';
clip.prepend(l); clip.appendChild(r);
}
updateInspector();
updateActionButtons();
}
function updateInspector() {
if (!selectedEl) {
$insName.textContent = '未选';
$insStart.value = '—';
$insDur.value = '—';
return;
}
const t = selectedEl.dataset.track;
const num = selectedEl.querySelector('.num')?.textContent.trim();
const label = selectedEl.dataset.label || '';
$insName.textContent = (t === 'video' ? '镜 ' + num : t === 'subtitle' ? '字幕' : 'BGM')
+ (label ? ' · ' + label.slice(0, 12) : '');
$insStart.value = fmtTime(clipStart(selectedEl));
$insDur.value = clipDur(selectedEl).toFixed(2) + 's';
}
function updateActionButtons() {
const has = !!selectedEl;
$delBtn.disabled = !has;
$copyBtn.disabled = !has;
if (has) {
const s = clipStart(selectedEl), e = clipEnd(selectedEl);
$splitBtn.disabled = !(currentTime > s + 0.05 && currentTime < e - 0.05);
} else {
$splitBtn.disabled = true;
}
}
function bindClipClicks() {
document.querySelectorAll('#ed-timeline .clip').forEach(clip => {
if (clip.dataset.boundClick) return;
clip.dataset.boundClick = '1';
clip.addEventListener('click', (e) => {
if (e.target.closest('[data-trim]')) return;
if (_bodyDragMoved) return; // 拖动重排刚结束 · 抑制 click
e.stopPropagation();
selectClip(clip);
currentTime = clipStart(clip);
updateTimeUI();
});
});
}
function laneSeek(lane, ev) {
if (ev.target.closest('.clip') || ev.target.closest('.playhead') || ev.target.closest('[data-trim]')) return;
const rect = lane.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width));
currentTime = pct * TOTAL;
updateTimeUI();
}
$laneV.addEventListener('click', e => laneSeek($laneV, e));
$laneS.addEventListener('click', e => laneSeek($laneS, e));
$ruler.addEventListener('click', (e) => {
const rect = $ruler.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
currentTime = pct * TOTAL;
updateTimeUI();
});
let dragging = false;
$playhead.querySelector('.ph-grab').addEventListener('mousedown', (e) => {
e.preventDefault();
dragging = true;
if (playing) pause();
$playhead.classList.add('is-dragging');
$tl.classList.add('is-dragging-playhead');
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const rect = $laneS.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
currentTime = pct * TOTAL;
updateTimeUI();
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
$playhead.classList.remove('is-dragging');
$tl.classList.remove('is-dragging-playhead');
});
function play() {
if (playing) return;
if (currentTime >= TOTAL - 0.01) currentTime = 0;
playing = true;
$playIcon.innerHTML = PAUSE_SVG;
$playBtn.classList.add('is-playing');
lastTs = performance.now();
rafId = requestAnimationFrame(tick);
}
function pause() {
if (!playing) return;
playing = false;
$playIcon.innerHTML = PLAY_SVG;
$playBtn.classList.remove('is-playing');
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
}
function tick(ts) {
if (!playing) return;
const dt = (ts - lastTs) / 1000;
lastTs = ts;
currentTime = Math.min(TOTAL, currentTime + dt);
updateTimeUI();
if (currentTime >= TOTAL) { pause(); return; }
rafId = requestAnimationFrame(tick);
}
$playBtn.addEventListener('click', () => playing ? pause() : play());
const FRAME = 1 / 30;
$prevBtn.addEventListener('click', () => {
pause();
currentTime = Math.max(0, currentTime - FRAME);
updateTimeUI();
});
$nextBtn.addEventListener('click', () => {
pause();
currentTime = Math.min(TOTAL, currentTime + FRAME);
updateTimeUI();
});
document.addEventListener('keydown', (e) => {
const stage5 = document.querySelector('section.stage.active[data-stage-pane="5"]');
if (!stage5) return;
if (e.target.matches('input, textarea, [contenteditable]')) return;
if (e.code === 'Space') { e.preventDefault(); playing ? pause() : play(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); $prevBtn.click(); }
else if (e.key === 'ArrowRight') { e.preventDefault(); $nextBtn.click(); }
else if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedEl) { e.preventDefault(); deleteSelected(); }
}
});
$zoom.addEventListener('input', () => {
const pct = Number($zoom.value);
document.querySelectorAll('#ed-timeline .lane, #ed-timeline #ed-ruler').forEach(l => {
l.style.minWidth = (pct === 100 ? '' : (pct + '%'));
});
});
function deleteSelected() {
if (!selectedEl) return;
const next = selectedEl.nextElementSibling?.classList.contains('clip')
? selectedEl.nextElementSibling
: selectedEl.previousElementSibling?.classList.contains('clip')
? selectedEl.previousElementSibling
: null;
selectedEl.remove();
if (next) selectClip(next);
else { selectedEl = null; updateInspector(); updateActionButtons(); }
renumberVideo();
updateTimeUI();
}
$delBtn.addEventListener('click', deleteSelected);
$copyBtn.addEventListener('click', () => {
if (!selectedEl) return;
const dup = selectedEl.cloneNode(true);
dup.classList.remove('selected');
dup.querySelectorAll('[data-trim]').forEach(t => t.remove());
delete dup.dataset.boundClick;
selectedEl.after(dup);
bindClipClicks();
renumberVideo();
selectClip(dup);
});
$splitBtn.addEventListener('click', () => {
if (!selectedEl) return;
const s = clipStart(selectedEl), e = clipEnd(selectedEl);
if (!(currentTime > s + 0.05 && currentTime < e - 0.05)) return;
const cutOff = currentTime - s; // 距 clip 起始的偏移
const origDur = D(selectedEl);
const leftDur = cutOff;
const rightDur = origDur - cutOff;
// 左半:dur=max=leftDur(切开后双方各自独立 · 不能再恢复跨越切点)
selectedEl.dataset.dur = String(leftDur);
selectedEl.dataset.max = String(leftDur);
const right = selectedEl.cloneNode(true);
right.classList.remove('selected');
right.querySelectorAll('[data-trim]').forEach(t => t.remove());
delete right.dataset.boundClick;
right.dataset.dur = String(rightDur);
right.dataset.max = String(rightDur);
// 右半若是视频片段 · 重新生成胶卷帧条数量(按时长成比例)
if (right.classList.contains('video')) {
const oldFrames = right.querySelector('.frames');
if (oldFrames) {
const n = Math.max(2, Math.round(rightDur * 1.5));
oldFrames.innerHTML = '<span class="fr"></span>'.repeat(n);
}
const oldFramesL = selectedEl.querySelector('.frames');
if (oldFramesL) {
const n = Math.max(2, Math.round(leftDur * 1.5));
oldFramesL.innerHTML = '<span class="fr"></span>'.repeat(n);
}
}
selectedEl.after(right);
layoutLane(selectedEl.parentElement);
bindClipClicks();
renumberVideo();
selectClip(right);
});
function renumberVideo() {
$laneV.querySelectorAll('.clip.video').forEach((c, i) => {
const num = c.querySelector('.num');
if (num) num.textContent = String(i + 1);
});
}
// Trim 把手 · 三轨通用 · 磁吸:邻居自动跟随前移/后退
document.addEventListener('mousedown', (e) => {
const handle = e.target.closest('[data-trim]');
if (!handle || !selectedEl) return;
e.preventDefault(); e.stopPropagation();
const side = handle.dataset.trim;
const lane = selectedEl.parentElement;
const laneRect = lane.getBoundingClientRect();
const startMouseX = e.clientX;
const startDur = D(selectedEl);
function onMove(ev) {
const dx = ev.clientX - startMouseX;
const dt = (dx / laneRect.width) * TOTAL;
// 左把手:右拖缩短 / 左拖恢复(直至 max)
// 右把手:右拖扩长(直至 max)/ 左拖缩短
const newDur = side === 'l' ? (startDur - dt) : (startDur + dt);
setD(selectedEl, newDur);
layoutLane(lane);
updateInspector();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
updateTimeUI();
}
document.body.style.cursor = 'ew-resize';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
// 拖拽选中片段身体 · 在同轨内重排位置
let _bodyDragMoved = false;
document.addEventListener('mousedown', (e) => {
const clip = e.target.closest('#ed-timeline .clip');
if (!clip || !clip.classList.contains('selected')) return;
if (e.target.closest('[data-trim]')) return;
if (e.target.closest('.ph-grab')) return;
e.preventDefault();
_bodyDragMoved = false;
const lane = clip.parentElement;
const laneRect = lane.getBoundingClientRect();
const startMouseX = e.clientX;
const startStartT = clipStart(clip);
function onMove(ev) {
const dx = ev.clientX - startMouseX;
if (!_bodyDragMoved && Math.abs(dx) < 5) return;
_bodyDragMoved = true;
clip.style.transform = 'translateX(' + dx + 'px)';
clip.style.zIndex = '6';
clip.style.opacity = '.88';
document.body.style.cursor = 'grabbing';
}
function onUp(ev) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
clip.style.transform = '';
clip.style.zIndex = '';
clip.style.opacity = '';
document.body.style.cursor = '';
if (!_bodyDragMoved) return;
const dx = ev.clientX - startMouseX;
const dt = (dx / laneRect.width) * TOTAL;
const newCenter = startStartT + D(clip) / 2 + dt;
const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== clip);
let acc = 0;
let insertBefore = null;
for (const s of siblings) {
const mid = acc + D(s) / 2;
if (newCenter < mid) { insertBefore = s; break; }
acc += D(s);
}
clip.remove();
if (insertBefore) lane.insertBefore(clip, insertBefore);
else lane.appendChild(clip);
layoutLane(lane);
if (lane === lanes.video) renumberVideo();
updateTimeUI();
setTimeout(() => { _bodyDragMoved = false; }, 50);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
// 初始化:磁吸布局 + 绑定
layoutAll();
bindClipClicks();
updateTimeUI();
})();
</script>
</body>
</html>