fix(core): 删脚本分镜联动删故事板帧 + 演员库自取 person + 分镜编辑行抽组件
- views.py: delete-script-segment 同步删对应 StoryboardFrame(原 SET_NULL 留孤儿帧致故事板仍显示已删的场),并重排剩余帧 sort_order - actor-library.tsx: 演员库改自取 person 资产(全局 assets 已懒加载不再全量预载,否则演员库全空),保存后 reload 即时入列 - pipeline.tsx: 抽出 EditableShotField 组件、PromptBox 参数化 - 附 perf-report 产物 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -270,10 +270,10 @@ function AddTagInline({ onAdd, placeholder, ariaLabel }: { onAdd?: (value: strin
|
||||
// DOM,React 不再 reconcile 其子节点。受控写法(把 state 当 children 回灌)在输入触发换行时,浏览器会
|
||||
// 往 contentEditable 里插 <div>/<br>,React 拿单个文本子节点去对账就会 removeChild 崩溃 → 整页白屏。
|
||||
// 读取走 onInput 写回上层 state(供「生成/重跑」带上),与渲染解耦,既能编辑又不崩。
|
||||
function PromptBox({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
function PromptBox({ value, onChange, className = "prompt-box", id, ariaLabel, stop = true }: { value: string; onChange: (v: string) => void; className?: string; id?: string; ariaLabel?: string; stop?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
// 只挂载时灌初值;后续用户编辑由 DOM 自己维护,不受 value 变化驱动(避免重渲染打断输入/白屏)。
|
||||
// 切换实体/草稿时上层用 key 强制重挂载,会重新走这次初值灌入,所以不需要把 value 进依赖。
|
||||
// 切换实体/草稿/版本时上层用 key 强制重挂载,会重新走这次初值灌入,所以不需要把 value 进依赖。
|
||||
useEffect(() => {
|
||||
if (ref.current && ref.current.textContent !== value) ref.current.textContent = value;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -281,16 +281,50 @@ function PromptBox({ value, onChange }: { value: string; onChange: (v: string) =
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="prompt-box"
|
||||
className={className}
|
||||
id={id}
|
||||
role={ariaLabel ? "textbox" : undefined}
|
||||
aria-label={ariaLabel}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
data-stop
|
||||
{...(stop ? { "data-stop": true } : {})}
|
||||
onInput={(e) => onChange(e.currentTarget.textContent || "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 分镜「旁白 / 画面」可编辑行:同样**非受控** contentEditable,落库走 onBlur(回车=失焦提交)。
|
||||
// 受控写法(把 narration 当 children)在提交后父组件用旧值重渲染时,React 会把 DOM 文本重置回旧值 →
|
||||
// 「按回车后变成空白」。这里不放受控子节点:初值/外部改动(切版本、AI 改稿)由 effect 同步进 DOM,
|
||||
// 但**正在编辑(聚焦中)时绝不覆盖**,避免打断输入。
|
||||
function EditableShotField({ value, placeholder, onCommit }: { value: string; placeholder: string; onCommit: (text: string) => void }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || document.activeElement === el) return; // 聚焦中=用户在打字,别覆盖
|
||||
if (el.textContent !== value) el.textContent = value;
|
||||
}, [value]);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="shot-v"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
data-placeholder={placeholder}
|
||||
data-empty={value ? undefined : "true"}
|
||||
onFocus={(e) => { e.currentTarget.removeAttribute("data-empty"); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
onBlur={(e) => {
|
||||
const text = (e.currentTarget.textContent || "").trim();
|
||||
if (!text) e.currentTarget.setAttribute("data-empty", "true");
|
||||
if (text !== value) onCommit(text);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 行34 · 「添加分镜」插入的本地可编辑空白卡片:输入旁白/画面,提交后落库
|
||||
function DraftShotCard({ draft, onCommit, onCancel }: {
|
||||
draft: { id: string; afterId: string | null; narration: string; visual: string };
|
||||
@@ -2071,39 +2105,19 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
<div className="shot-row">
|
||||
<span className="shot-k">旁白</span>
|
||||
<div
|
||||
className="shot-v"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
data-placeholder="(旁白)点击编辑"
|
||||
data-empty={narration ? undefined : "true"}
|
||||
onFocus={(event) => { event.currentTarget.removeAttribute("data-empty"); }}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.blur(); } }}
|
||||
onBlur={(event) => {
|
||||
const text = (event.currentTarget.textContent || "").trim();
|
||||
if (!text) event.currentTarget.setAttribute("data-empty", "true");
|
||||
if (text !== narration) void onUpdateShot({ segment_id: shot.id, narration: text });
|
||||
}}
|
||||
>{narration}</div>
|
||||
<EditableShotField
|
||||
value={narration}
|
||||
placeholder="(旁白)点击编辑"
|
||||
onCommit={(text) => { void onUpdateShot({ segment_id: shot.id, narration: text }); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="shot-row">
|
||||
<span className="shot-k">画面</span>
|
||||
<div
|
||||
className="shot-v"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
data-placeholder="(画面描述)点击编辑"
|
||||
data-empty={visualShown ? undefined : "true"}
|
||||
onFocus={(event) => { event.currentTarget.removeAttribute("data-empty"); }}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.blur(); } }}
|
||||
onBlur={(event) => {
|
||||
const text = (event.currentTarget.textContent || "").trim();
|
||||
if (!text) event.currentTarget.setAttribute("data-empty", "true");
|
||||
if (text !== visualShown) void onUpdateShot({ segment_id: shot.id, visual_prompt: text });
|
||||
}}
|
||||
>{visualShown}</div>
|
||||
<EditableShotField
|
||||
value={visualShown}
|
||||
placeholder="(画面描述)点击编辑"
|
||||
onCommit={(text) => { void onUpdateShot({ segment_id: shot.id, visual_prompt: text }); }}
|
||||
/>
|
||||
</div>
|
||||
{/* ScriptDraft 结构化字段:镜型(钩子/痛点/卖点/CTA)+ 商品露出方式(只读展示) */}
|
||||
{(shot.role || shot.product_exposure) ? (
|
||||
@@ -2298,7 +2312,13 @@ export function PipelinePage(props: {
|
||||
const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset;
|
||||
const hasTriView = productVersions.length > 0;
|
||||
const triPanelShow = triPanelOpen || hasTriView || triGenerating;
|
||||
const productAssetUrl = assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板
|
||||
// 商品主图:优先用序列化器内嵌的 preview_url(全局 assets 已不再全量取,assetUrl 反查会落空),
|
||||
// 依次 封面 → 主图 → 首图,最后才回退旧的全局反查。
|
||||
const productAssetUrl =
|
||||
productRecord?.cover_preview_url
|
||||
|| productRecord?.images?.find((img) => img.is_primary)?.preview_url
|
||||
|| productRecord?.images?.[0]?.preview_url
|
||||
|| assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板
|
||||
const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`;
|
||||
const runProductTri = () => { setTriPanelOpen(true); setTriPreviewId(null); void genBaseAsset("product", triViewGen, undefined, "tri:product"); };
|
||||
return (
|
||||
@@ -2522,23 +2542,8 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 人物区末尾「添加人物」卡:点开「添加人物工作台」(studio) */}
|
||||
{kind === "person" && (
|
||||
<div className="asset-card-2" data-asset-kind="person" data-stop role="button" tabIndex={0}
|
||||
title="打开添加人物工作台" style={{ cursor: "pointer" }}
|
||||
onClick={() => setActorLib({ mode: "browse", studio: true })}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setActorLib({ mode: "browse", studio: true }); } }}>
|
||||
<div className="placeholder thumb-2" style={{ flexDirection: "column", gap: 8 }}>
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
<span className="ph-frame">添加人物</span>
|
||||
</div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>添加人物</strong><span className="spacer"></span><span className="pill neutral"><span className="dot"></span>工作台</span></div>
|
||||
<div className="muted mono" style={{ fontSize: 12, marginTop: 6 }}>// AI 生成 / 本地上传 → 三视图 → 入库</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{entities.length === 0 && pendingTags.length === 0 && (kind !== "scene" || sceneDrafts.length === 0) && kind !== "person" && (
|
||||
{/* 添加人物入口统一走右上「+ 新增人物」按钮(原末尾常驻卡与之重复,已移除) */}
|
||||
{entities.length === 0 && pendingTags.length === 0 && (kind !== "scene" || sceneDrafts.length === 0) && (
|
||||
<div className="placeholder" style={{ gridColumn: "1 / -1", minHeight: "120px", flexDirection: "column", gap: "10px" }}>
|
||||
<span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 点右上「新增{KIND_LABEL[kind]}」</span>
|
||||
</div>
|
||||
@@ -2616,16 +2621,15 @@ export function PipelinePage(props: {
|
||||
<div className="note-copy"><strong>仅支持整张重跑</strong> · 不能局部改某一镜。如需调单镜,先在 <a href="#stage-1" onClick={(event) => { event.preventDefault(); goStage(1); }}>Stage 1 脚本</a> 改镜头描述,再回此处整张重跑。</div>
|
||||
</div>
|
||||
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>// 整张提示词(重跑时生效,可编辑)</div>
|
||||
{/* key 跟版本走:切版本重新种子;onInput 实时回写 state → 整张重跑用的就是你编辑后的文本 */}
|
||||
<div
|
||||
className="prompt-edit"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
id="sb-prompt-edit"
|
||||
{/* key 跟版本走:切版本重新种子;onChange 实时回写 state → 整张重跑用的就是你编辑后的文本 */}
|
||||
<PromptBox
|
||||
key={displayedStoryboard?.id || "no-version"}
|
||||
onInput={(event) => setStoryboardPrompt((event.currentTarget.textContent || "").trim())}
|
||||
>{displayedStoryboard?.prompt || SB_PROMPT_DEFAULT}</div>
|
||||
className="prompt-edit"
|
||||
id="sb-prompt-edit"
|
||||
stop={false}
|
||||
value={displayedStoryboard?.prompt || SB_PROMPT_DEFAULT}
|
||||
onChange={(v) => setStoryboardPrompt(v.trim())}
|
||||
/>
|
||||
<div className="sb-stage-actions">
|
||||
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="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>
|
||||
@@ -2651,9 +2655,18 @@ export function PipelinePage(props: {
|
||||
<div className="divider" style={{ marginTop: "16px" }}></div>
|
||||
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 绑定的资产</div>
|
||||
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }} id="sb-bound-assets">
|
||||
{groups.filter((g) => g.adopted_asset).length ? groups.filter((g) => g.adopted_asset).map((g) => (
|
||||
<span className="asset-tag" key={g.id}><span className="dotc"></span>{assetName(g.adopted_asset) || KIND_LABEL[g.kind] || g.kind}({KIND_LABEL[g.kind] || g.kind})</span>
|
||||
)) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>// 暂无绑定资产</span>}
|
||||
{(() => {
|
||||
// 与 Stage 2 同源:用 metadata.label 取名(不依赖全局 assets 列表),排除三视图组并按名字去重,
|
||||
// 否则同一角色的立绘组+三视图组+多版本会各算一个,且名字会退化成「人物(人物)」。
|
||||
const bound = [
|
||||
...groupsByKind("product").filter((g) => g.adopted_asset).map((g) => ({ id: g.id, name: (g.metadata?.label || "").trim() || KIND_LABEL.product, kind: "product" as const })),
|
||||
...buildEntities("person").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "person" as const })),
|
||||
...buildEntities("scene").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "scene" as const })),
|
||||
];
|
||||
return bound.length ? bound.map((b) => (
|
||||
<span className="asset-tag" key={b.id}><span className="dotc"></span>{b.name}({KIND_LABEL[b.kind]})</span>
|
||||
)) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>// 暂无绑定资产</span>;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3177,16 +3190,14 @@ export function PipelinePage(props: {
|
||||
<span className="label">// 本场提示词(重跑生效,可编辑)</span>
|
||||
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>系统会自动织入本镜旁白与参考图</span>
|
||||
</div>
|
||||
<div
|
||||
className="vd-prompt-edit"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
role="textbox"
|
||||
aria-label="视频提示词"
|
||||
<PromptBox
|
||||
key={vdSeg.id}
|
||||
onInput={(event) => setVdPrompt((event.currentTarget.textContent || "").trim())}
|
||||
>{`${videoPrompt} 第 ${vdSeg.sort_order + 1} 段,时长 ${vdSeg.target_duration_seconds} 秒`}</div>
|
||||
className="vd-prompt-edit"
|
||||
ariaLabel="视频提示词"
|
||||
stop={false}
|
||||
value={`${videoPrompt} 第 ${vdSeg.sort_order + 1} 段,时长 ${vdSeg.target_duration_seconds} 秒`}
|
||||
onChange={(v) => setVdPrompt(v.trim())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user