auto-save 2026-05-13 16:06 (+1, ~3)
This commit is contained in:
@@ -1848,6 +1848,13 @@
|
|||||||
"type": "session-heartbeat",
|
"type": "session-heartbeat",
|
||||||
"message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 15:55 (~1)",
|
"message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 15:55 (~1)",
|
||||||
"files_changed": 1
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-13T16:01:31+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-13 16:01 (~1)",
|
||||||
|
"hash": "f30f6c4",
|
||||||
|
"files_changed": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { ThemeToggle } from "@/components/theme-toggle"
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
import { Dashboard, type DashboardHandle } from "@/components/dashboard"
|
import { Dashboard, type DashboardHandle } from "@/components/dashboard"
|
||||||
import { StoryboardBar } from "@/components/storyboard-bar"
|
import { StoryboardBar } from "@/components/storyboard-bar"
|
||||||
|
import { StoryboardWorkbench } from "@/components/storyboard-workbench"
|
||||||
import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, pushStoryboardImage, type Job } from "@/lib/api"
|
import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, pushStoryboardImage, type Job } from "@/lib/api"
|
||||||
import { VideoLightbox } from "@/components/video-lightbox"
|
import { VideoLightbox } from "@/components/video-lightbox"
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ export default function Home() {
|
|||||||
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
|
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
|
||||||
const [videoLightboxOpen, setVideoLightboxOpen] = useState(false)
|
const [videoLightboxOpen, setVideoLightboxOpen] = useState(false)
|
||||||
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
|
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
|
||||||
|
const [workbenchOpen, setWorkbenchOpen] = useState(false)
|
||||||
const dashboardRef = useRef<DashboardHandle>(null)
|
const dashboardRef = useRef<DashboardHandle>(null)
|
||||||
|
|
||||||
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
|
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
|
||||||
@@ -335,6 +337,7 @@ export default function Home() {
|
|||||||
focusedFrame={storyboardFrame}
|
focusedFrame={storyboardFrame}
|
||||||
onFocusFrame={setStoryboardFrame}
|
onFocusFrame={setStoryboardFrame}
|
||||||
onJobUpdate={setJob as any}
|
onJobUpdate={setJob as any}
|
||||||
|
onOpenWorkbench={() => setWorkbenchOpen(true)}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex-1 min-h-0">
|
<div className="relative flex-1 min-h-0">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -369,6 +372,15 @@ export default function Home() {
|
|||||||
onAddFrame={handleAddManualFrame}
|
onAddFrame={handleAddManualFrame}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 分镜头编排工作台 — 全屏覆盖 DAG */}
|
||||||
|
<StoryboardWorkbench
|
||||||
|
job={job}
|
||||||
|
selectedFrames={selectedFrames}
|
||||||
|
open={workbenchOpen}
|
||||||
|
onClose={() => setWorkbenchOpen(false)}
|
||||||
|
onJobUpdate={setJob as any}
|
||||||
|
/>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ interface Props {
|
|||||||
focusedFrame: number | null
|
focusedFrame: number | null
|
||||||
onFocusFrame: (idx: number | null) => void
|
onFocusFrame: (idx: number | null) => void
|
||||||
onJobUpdate?: (j: Job) => void
|
onJobUpdate?: (j: Job) => void
|
||||||
|
onOpenWorkbench?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, onJobUpdate }: Props) {
|
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, onJobUpdate, onOpenWorkbench }: Props) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
useEffect(() => setMounted(true), [])
|
useEffect(() => setMounted(true), [])
|
||||||
@@ -75,14 +76,27 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
{onOpenWorkbench && (
|
||||||
className="shrink-0 text-white/50 hover:text-white text-[11px] inline-flex items-center gap-1"
|
<button
|
||||||
title={collapsed ? "展开" : "折叠"}
|
onClick={onOpenWorkbench}
|
||||||
>
|
disabled={frames.length === 0}
|
||||||
{collapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
|
className="text-[11px] px-2.5 py-1 rounded-md bg-gradient-to-r from-violet-500 to-pink-500 hover:from-violet-400 hover:to-pink-400 text-white inline-flex items-center gap-1 disabled:opacity-40 disabled:cursor-not-allowed font-medium shadow"
|
||||||
{collapsed ? "展开" : "折叠"}
|
title={frames.length === 0 ? "先到关键帧节点选用分镜" : "全屏进入分镜头编排工作台"}
|
||||||
</button>
|
>
|
||||||
|
<LayoutGrid className="h-3 w-3" />
|
||||||
|
进入编排
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="text-white/50 hover:text-white text-[11px] inline-flex items-center gap-1"
|
||||||
|
title={collapsed ? "展开" : "折叠"}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
|
||||||
|
{collapsed ? "展开" : "折叠"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* thumbnails row */}
|
{/* thumbnails row */}
|
||||||
|
|||||||
360
web/components/storyboard-workbench.tsx
Normal file
360
web/components/storyboard-workbench.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
"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 {
|
||||||
|
type Job, type StoryboardScene,
|
||||||
|
effectiveFrameUrl, cutoutUrl, updateStoryboard,
|
||||||
|
} from "@/lib/api"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
job: Job | null
|
||||||
|
selectedFrames: Set<number>
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onJobUpdate?: (j: Job) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyScene = (): StoryboardScene => ({
|
||||||
|
subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate }: Props) {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
|
const [focusedIdx, setFocusedIdx] = useState<number | null>(null)
|
||||||
|
const [form, setForm] = useState<StoryboardScene>(emptyScene())
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [savedTick, setSavedTick] = useState(0)
|
||||||
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
// Esc 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }
|
||||||
|
window.addEventListener("keydown", onKey)
|
||||||
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
// 默认选第一个分镜
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !job) return
|
||||||
|
if (focusedIdx !== null && job.frames.find((f) => f.index === focusedIdx)) return
|
||||||
|
const frames = job.frames
|
||||||
|
.filter((f) => selectedFrames.has(f.index))
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
if (frames.length > 0) setFocusedIdx(frames[0].index)
|
||||||
|
else setFocusedIdx(null)
|
||||||
|
}, [open, job?.id, selectedFrames, focusedIdx, job?.frames])
|
||||||
|
|
||||||
|
// 切换 focused 加载表单数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (!job || focusedIdx === null) { setForm(emptyScene()); return }
|
||||||
|
const f = job.frames.find((x) => x.index === focusedIdx)
|
||||||
|
setForm(f?.storyboard ? { ...emptyScene(), ...f.storyboard } : emptyScene())
|
||||||
|
}, [focusedIdx, job])
|
||||||
|
|
||||||
|
if (!mounted || !open || !job) return null
|
||||||
|
|
||||||
|
const frames = job.frames
|
||||||
|
.filter((f) => selectedFrames.has(f.index))
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
const focusFrame = focusedIdx !== null ? job.frames.find((f) => f.index === focusedIdx) ?? null : null
|
||||||
|
const focusSeq = focusFrame ? frames.findIndex((f) => f.index === focusFrame.index) + 1 : 0
|
||||||
|
|
||||||
|
const queueSave = (next: StoryboardScene) => {
|
||||||
|
setForm(next)
|
||||||
|
if (!job || focusedIdx === null) return
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current)
|
||||||
|
saveTimer.current = setTimeout(async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const updated = await updateStoryboard(job.id, focusedIdx, next)
|
||||||
|
onJobUpdate?.(updated)
|
||||||
|
setSavedTick((t) => t + 1)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("保存失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, 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(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[200] bg-black/92 backdrop-blur-xl flex flex-col"
|
||||||
|
style={{ animation: "drawer-in 0.2s cubic-bezier(0.32, 0.72, 0, 1)" }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex items-center justify-between px-5 py-2.5 border-b border-white/10 bg-black/40 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2 text-white">
|
||||||
|
<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} 素材
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-[11px] text-white/45 font-mono inline-flex items-center gap-1">
|
||||||
|
{saving ? (<><Loader2 className="h-2.5 w-2.5 animate-spin" /> 保存中</>)
|
||||||
|
: savedTick > 0 ? (<><Check className="h-2.5 w-2.5 text-emerald-300" /> 已自动保存</>)
|
||||||
|
: "字段变更自动保存"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 px-3 rounded-md bg-white/10 hover:bg-white/20 text-white inline-flex items-center gap-1.5 text-[12px]"
|
||||||
|
title="返回 DAG (Esc)"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" /> 返回 DAG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
{/* 左侧分镜列表 */}
|
||||||
|
<aside
|
||||||
|
className="flex-shrink-0 border-r border-white/10 overflow-y-auto bg-black/20"
|
||||||
|
style={{ width: 232 }}
|
||||||
|
>
|
||||||
|
{frames.length === 0 ? (
|
||||||
|
<div className="p-4 text-[11px] text-white/40 leading-relaxed">
|
||||||
|
暂无分镜 · 到关键帧节点缩略图点「选用此帧」
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{frames.map((f, i) => {
|
||||||
|
const isFocused = focusedIdx === f.index
|
||||||
|
const sb = f.storyboard
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.index}
|
||||||
|
onClick={() => setFocusedIdx(f.index)}
|
||||||
|
className={`w-full text-left rounded-lg p-2 transition flex gap-2 items-start ${
|
||||||
|
isFocused
|
||||||
|
? "bg-violet-500/25 border border-violet-300/60"
|
||||||
|
: "hover:bg-white/[0.04] border border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 rounded-md overflow-hidden bg-black"
|
||||||
|
style={{ width: 64, aspectRatio: aspect }}
|
||||||
|
>
|
||||||
|
<img src={effectiveFrameUrl(job.id, f)} className="w-full h-full object-cover" alt="" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[11.5px] font-semibold text-white flex items-center gap-1.5">
|
||||||
|
<span>分镜 {i + 1}</span>
|
||||||
|
{sb?.duration ? (
|
||||||
|
<span className="text-[9px] text-violet-300/80 font-mono">{sb.duration}s</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9.5px] text-white/40 font-mono">
|
||||||
|
{f.timestamp.toFixed(2)}s · {f.elements?.length ?? 0} 元素
|
||||||
|
</div>
|
||||||
|
{sb?.subject && (
|
||||||
|
<div className="text-[10px] text-white/65 truncate mt-0.5" title={sb.subject}>
|
||||||
|
{sb.subject}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 右侧详情 */}
|
||||||
|
<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="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>
|
||||||
|
</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}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 生成按钮(Phase 2 占位) */}
|
||||||
|
<section>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="w-full py-3 rounded-lg text-[13.5px] font-semibold inline-flex items-center justify-center gap-2 bg-gradient-to-r from-rose-500/35 to-violet-500/35 text-white/70 border border-violet-300/30 disabled:opacity-60 cursor-not-allowed"
|
||||||
|
title="Phase 2 实施"
|
||||||
|
>
|
||||||
|
<Wand2 className="h-4 w-4" />
|
||||||
|
⚡ 生成此分镜视频片段(Phase 2 待实施)
|
||||||
|
</button>
|
||||||
|
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
|
||||||
|
下一阶段:基于上述字段 + 参考图,调视频生成模型(Seedance / Kling / Veo3)生成该分镜的视频片段
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部快捷 */}
|
||||||
|
<footer className="border-t border-white/10 px-5 py-1.5 text-[10px] text-white/40 font-mono text-center bg-black/40">
|
||||||
|
ESC 返回 DAG · 字段变更自动保存
|
||||||
|
</footer>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldText({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<div className="text-[10.5px] text-white/55 mb-1">{label}</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full text-[12.5px] px-2.5 py-1.5 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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldNum({ label, value, onChange, placeholder }: { label: string; value: number; onChange: (v: number) => void; placeholder?: string }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<div className="text-[10.5px] text-white/55 mb-1">{label}</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full text-[12.5px] px-2.5 py-1.5 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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTextarea({ label, value, onChange, placeholder, rows = 2 }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; rows?: number }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<div className="text-[10.5px] text-white/55 mb-1">{label}</div>
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
className="w-full text-[12.5px] px-2.5 py-1.5 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 resize-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user