auto-save 2026-05-13 16:28 (~3)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
import { useEffect, useState, useRef, type ReactNode } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { X, LayoutGrid, Loader2, Check, Sparkle, Wand2 } from "lucide-react"
|
||||
import { X, LayoutGrid, Loader2, Check, Wand2 } from "lucide-react"
|
||||
import {
|
||||
type Job, type StoryboardScene, type ImageRef,
|
||||
effectiveFrameUrl, updateStoryboard, resolveImageRefUrl,
|
||||
@@ -83,21 +83,6 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
}, 600)
|
||||
}
|
||||
|
||||
// 参考图候选 = 全部已推送图
|
||||
const pushedImages = job.storyboard_images ?? []
|
||||
const refOptions = pushedImages
|
||||
.map((p) => {
|
||||
const url = p.kind === "keyframe"
|
||||
? effectiveFrameUrl(job.id, { index: p.frame_idx, cleaned_applied: false })
|
||||
: (p.element_id && p.cutout_id
|
||||
? (p.cutout_id === p.element_id
|
||||
? cutoutUrl(job.id, p.frame_idx, p.element_id)
|
||||
: cutoutUrl(job.id, p.frame_idx, p.element_id, p.cutout_id))
|
||||
: "")
|
||||
return { ref_id: p.ref_id, url, label: p.label || "", frame_idx: p.frame_idx, kind: p.kind }
|
||||
})
|
||||
.filter((r) => r.url)
|
||||
|
||||
const aspect = job.height > 0 ? `${job.width}/${job.height}` : "9/16"
|
||||
|
||||
return createPortal(
|
||||
@@ -111,7 +96,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
<LayoutGrid className="h-4 w-4 text-violet-300" />
|
||||
<span className="text-[14px] font-semibold">分镜头编排工作台</span>
|
||||
<span className="text-[11px] text-white/40 font-mono ml-2">
|
||||
{frames.length} 分镜 · {pushedImages.length} 素材
|
||||
{frames.length} 分镜
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -184,106 +169,104 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* 右侧详情 */}
|
||||
{/* 右侧详情 — 4 图槽 + 时长 */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{!focusFrame ? (
|
||||
<div className="p-8 text-[13px] text-white/40">从左侧选一个分镜开始编排</div>
|
||||
) : (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-5">
|
||||
{/* 顶栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 顶栏:分镜信息 + 剪贴板提示 + 时长 */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-[15.5px] font-semibold text-white">
|
||||
分镜 {focusSeq} <span className="text-white/40 text-[12px] font-mono ml-2">{focusFrame.timestamp.toFixed(2)}s</span>
|
||||
分镜 {focusSeq}
|
||||
<span className="text-white/40 text-[12px] font-mono ml-2">{focusFrame.timestamp.toFixed(2)}s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{clipboard ? (
|
||||
<div className="text-[11px] text-emerald-300 inline-flex items-center gap-1">
|
||||
📋 剪贴板:<span className="text-white font-medium truncate max-w-[180px]">{clipboard.label || (clipboard.kind === "keyframe" ? "关键帧" : "元素")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-white/40">剪贴板为空 · 在 DAG / lightbox 上点 📋 复制</div>
|
||||
)}
|
||||
<label className="inline-flex items-center gap-1.5 text-[11px] text-white/55">
|
||||
时长
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={form.duration || ""}
|
||||
onChange={(e) => queueSave({ ...form, duration: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="3.5"
|
||||
className="w-16 text-[12.5px] px-2 py-1 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40 text-center"
|
||||
/>
|
||||
秒
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 左大图 + 右字段 */}
|
||||
<div className="flex gap-5">
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={effectiveFrameUrl(job.id, focusFrame)}
|
||||
alt=""
|
||||
className="rounded-lg bg-black object-contain"
|
||||
style={{ width: 320, maxHeight: "52vh" }}
|
||||
/>
|
||||
<div className="mt-1.5 text-[10.5px] text-white/50 text-center">
|
||||
{focusFrame.cleaned_applied ? "✨ 已清洗版" : "原图"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FieldText label="主体" placeholder="如:戴头带的骨架人"
|
||||
value={form.subject} onChange={(v) => queueSave({ ...form, subject: v })} />
|
||||
<FieldText label="产品" placeholder="如:Goli 营养软糖"
|
||||
value={form.product} onChange={(v) => queueSave({ ...form, product: v })} />
|
||||
<FieldText label="场景" placeholder="如:药店柜台 / 夜晚卧室"
|
||||
value={form.scene} onChange={(v) => queueSave({ ...form, scene: v })} />
|
||||
<FieldNum label="时长 (秒)" placeholder="3.5"
|
||||
value={form.duration} onChange={(v) => queueSave({ ...form, duration: v })} />
|
||||
</div>
|
||||
<FieldTextarea
|
||||
label="在干什么"
|
||||
placeholder="如:骨架人递给顾客一瓶 Goli · 顾客接过并查看 · 灯光柔和"
|
||||
value={form.action}
|
||||
onChange={(v) => queueSave({ ...form, action: v })}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 参考图选择 */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[12.5px] font-semibold text-white inline-flex items-center gap-1.5">
|
||||
<Sparkle className="h-3.5 w-3.5 text-violet-300" />
|
||||
选用参考图
|
||||
<span className="text-[10px] text-white/40 font-mono">
|
||||
· 选用 {form.reference_ids.length} / 可选 {refOptions.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{refOptions.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-white/15 p-3 text-[11px] text-white/40">
|
||||
暂无可选参考图 · 到关键帧节点 / 元素提取图 / 分镜头编排节点等处点 ⬆ 上推到这里
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{refOptions.map((r) => {
|
||||
const checked = form.reference_ids.includes(r.ref_id)
|
||||
return (
|
||||
<button
|
||||
key={r.ref_id}
|
||||
onClick={() => {
|
||||
const next = checked
|
||||
? form.reference_ids.filter((x) => x !== r.ref_id)
|
||||
: [...form.reference_ids, r.ref_id]
|
||||
queueSave({ ...form, reference_ids: next })
|
||||
}}
|
||||
title={r.label}
|
||||
className={`relative rounded-md overflow-hidden border-2 transition bg-white ${
|
||||
checked ? "border-emerald-400 ring-2 ring-emerald-400/40" : "border-white/15 hover:border-white/40"
|
||||
}`}
|
||||
style={{ aspectRatio: "1/1" }}
|
||||
>
|
||||
<img src={r.url} alt={r.label} className="absolute inset-0 w-full h-full object-contain" />
|
||||
{checked && (
|
||||
<div className="absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-emerald-500 text-white inline-flex items-center justify-center">
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
{r.kind === "keyframe" && (
|
||||
<div className="absolute top-0.5 left-0.5 text-[8px] text-white bg-amber-500/85 px-1 py-0.5 rounded font-bold leading-none">KF</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 px-1 py-0.5 text-[8px] text-white bg-black/70 truncate leading-tight">
|
||||
{r.label}
|
||||
{/* 4 图槽 grid */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{([
|
||||
{ key: "subject_image" as const, label: "主体", placeholder: "戴头带的骨架人" },
|
||||
{ key: "scene_image" as const, label: "场景", placeholder: "药店柜台 / 卧室" },
|
||||
{ key: "product_image" as const, label: "产品", placeholder: "Goli 营养软糖" },
|
||||
{ key: "action_image" as const, label: "在干什么", placeholder: "递糖给顾客" },
|
||||
]).map(({ key, label, placeholder }) => {
|
||||
const ref = form[key]
|
||||
const url = ref ? resolveImageRefUrl(job.id, ref) : ""
|
||||
return (
|
||||
<div key={key} className="rounded-lg bg-white/[0.04] border border-white/10 p-2.5">
|
||||
<div className="text-[12px] text-white font-semibold mb-2 flex items-center justify-between">
|
||||
<span>{label}</span>
|
||||
{ref && (
|
||||
<button
|
||||
onClick={() => queueSave({ ...form, [key]: null })}
|
||||
className="text-[10px] text-white/40 hover:text-rose-300"
|
||||
title="清空"
|
||||
>
|
||||
✕ 清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="relative rounded-md overflow-hidden border-2 border-dashed border-white/15 bg-white/[0.03] flex items-center justify-center mb-2"
|
||||
style={{ aspectRatio: "1/1" }}
|
||||
>
|
||||
{ref && url ? (
|
||||
<img src={url} alt={label} className="absolute inset-0 w-full h-full object-contain bg-white" />
|
||||
) : (
|
||||
<div className="text-center text-white/30 text-[10.5px] p-3 leading-relaxed">
|
||||
空 · 点下方<br />「粘贴剪贴板」<br />填入图片
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!clipboard) {
|
||||
toast.error("先在某张图上点 📋 复制")
|
||||
return
|
||||
}
|
||||
queueSave({ ...form, [key]: clipboard })
|
||||
toast.success(`已粘贴到「${label}」`)
|
||||
}}
|
||||
disabled={!clipboard}
|
||||
className={`w-full text-[11.5px] py-1.5 rounded-md inline-flex items-center justify-center gap-1 transition font-medium ${
|
||||
clipboard
|
||||
? "bg-violet-500 hover:bg-violet-400 text-white"
|
||||
: "bg-white/[0.04] text-white/30 cursor-not-allowed"
|
||||
}`}
|
||||
title={clipboard ? `粘贴剪贴板的图:${clipboard.label || ""}` : "剪贴板为空"}
|
||||
>
|
||||
📋 粘贴
|
||||
</button>
|
||||
<div className="mt-1 text-[9.5px] text-white/30 truncate text-center">
|
||||
{ref?.label || placeholder}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 生成按钮(Phase 2 占位) */}
|
||||
<section>
|
||||
@@ -296,7 +279,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
⚡ 生成此分镜视频片段(Phase 2 待实施)
|
||||
</button>
|
||||
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
|
||||
下一阶段:基于上述字段 + 参考图,调视频生成模型(Seedance / Kling / Veo3)生成该分镜的视频片段
|
||||
下一阶段:基于 4 图槽(主体 / 场景 / 产品 / 动作)+ 时长,调视频生成模型(Seedance / Kling / Veo3)生成该分镜的视频片段
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user