auto-save 2026-05-13 21:29 (~7)
This commit is contained in:
@@ -232,6 +232,20 @@ export default function Home() {
|
||||
if (!frame) return
|
||||
|
||||
const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback
|
||||
const keyframeRef: ImageRef = {
|
||||
kind: "keyframe",
|
||||
frame_idx: frameIdx,
|
||||
label: `分镜 ${frameIdx + 1} 首帧`,
|
||||
}
|
||||
const orderedSelected = job.frames
|
||||
.filter((f) => selectedFrames.has(f.index))
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
const nextFrame = orderedSelected.find((f) => f.timestamp > frame.timestamp) ?? null
|
||||
const defaultLastRef: ImageRef | null = nextFrame
|
||||
? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${nextFrame.index + 1} 尾帧` }
|
||||
: null
|
||||
const firstRef = scene.first_image ?? keyframeRef
|
||||
const lastRef = scene.last_image ?? defaultLastRef
|
||||
const duration = scene.duration && scene.duration > 0 ? scene.duration : 5
|
||||
const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : ""
|
||||
const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : ""
|
||||
@@ -245,21 +259,21 @@ export default function Home() {
|
||||
const sceneDirection = scene.scene?.trim()
|
||||
|| "借鉴参考画面的构图、可信感和空间层次,但改造成适合 SKG 产品广告的现代家居、办公或零售场景。"
|
||||
const actionDirection = scene.action?.trim()
|
||||
|| "一镜到底缓慢推进,先建立画面,再出现自然手部互动,最后停在产品细节或使用状态特写。"
|
||||
|| "按首帧到尾帧做平滑过渡,动作连续自然,镜头运动稳定,最后准确停在尾帧意图。"
|
||||
const prompt = [
|
||||
`竖屏 9:16,${duration.toFixed(1)} 秒,SKG 产品短视频广告。`,
|
||||
"直接根据当前分镜关键帧生成视频。必须使用输入的完整视频关键帧作为第一帧和视觉锚点:第一帧构图、主体位置、透视关系和光线方向保持稳定,然后从这一帧自然动起来。",
|
||||
"生成一段单镜头连续视频,一镜到底,不要跳切,不要突然换场景,不要突然换主体,不要蒙太奇,不要多镜头拼接。",
|
||||
"使用首帧和尾帧生成连续过渡视频:首帧必须严格作为视频开始画面,尾帧必须作为视频结束目标画面,中间只做自然运动补间。",
|
||||
"生成一段单镜头连续视频,一镜到底,从首帧平滑过渡到尾帧;不要跳切,不要突然换场景,不要突然换主体,不要蒙太奇,不要多镜头拼接。",
|
||||
"如果提供了原视频链接,把它只作为节奏、镜头运动、动作顺序和画面调度参考;不要照搬原视频里的品牌、文字、水印、竞品产品或具体人物。",
|
||||
"时间线:0%-25% 保持首帧构图并轻微启动;25%-70% 做一个清晰、缓慢、可信的产品展示动作;70%-100% 镜头自然停稳在 SKG 产品或使用效果特写。",
|
||||
"时间线:0%-15% 锁住首帧构图并轻微启动;15%-85% 做平滑连续运动;85%-100% 缓慢贴近尾帧并稳定收住。",
|
||||
`主体改造:${subjectDirection}`,
|
||||
`产品替换:${productDirection}`,
|
||||
`场景改造:${sceneDirection}`,
|
||||
`连续动作和镜头:${actionDirection}`,
|
||||
`参考主体图槽:${labelOf(scene.subject_image, "产品演示主体或手部姿态")}`,
|
||||
`参考场景图槽:${labelOf(scene.scene_image, "现代健康生活场景")}`,
|
||||
`SKG 产品图槽:${labelOf(scene.product_image, "SKG 产品视觉主角")}`,
|
||||
`参考动作图槽:${labelOf(scene.action_image, "自然拿取、佩戴、展示或靠近产品的动作")}`,
|
||||
`首帧:${labelOf(firstRef, "当前分镜关键帧")}`,
|
||||
`尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`,
|
||||
`SKG 产品参考:${labelOf(scene.product_image, "SKG 产品视觉主角")}`,
|
||||
`动作参考:${labelOf(scene.action_image, "自然拿取、佩戴、展示或靠近产品的动作")}`,
|
||||
sourceScene,
|
||||
sourceStyle,
|
||||
sourceObjects,
|
||||
@@ -270,16 +284,13 @@ export default function Home() {
|
||||
|
||||
try {
|
||||
toast.info(`已提交 ${model} 生视频 · 分镜 ${frameIdx + 1}`)
|
||||
const keyframeRef: ImageRef = {
|
||||
kind: "keyframe",
|
||||
frame_idx: frameIdx,
|
||||
label: `分镜 ${frameIdx + 1} 关键帧`,
|
||||
}
|
||||
const sourceUrl = job.url?.trim()
|
||||
const updated = await generateStoryboardVideo(job.id, frameIdx, {
|
||||
prompt,
|
||||
duration,
|
||||
subject_image: keyframeRef,
|
||||
first_image: firstRef,
|
||||
last_image: lastRef,
|
||||
subject_image: firstRef,
|
||||
scene_image: null,
|
||||
product_image: null,
|
||||
action_image: null,
|
||||
@@ -293,7 +304,7 @@ export default function Home() {
|
||||
} catch (e) {
|
||||
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}, [job, setJob])
|
||||
}, [job, selectedFrames, setJob])
|
||||
|
||||
// URL ?job=xxx,yyy 自动恢复多个 job
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle } from "lucide-react"
|
||||
import { type Job, effectiveFrameUrl, hasCutout } from "@/lib/api"
|
||||
import { LayoutGrid, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { type Job, hasCutout } from "@/lib/api"
|
||||
|
||||
interface Props {
|
||||
job: Job | null
|
||||
@@ -15,19 +13,12 @@ interface Props {
|
||||
}
|
||||
|
||||
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, workbenchOpen = false, onOpenWorkbench, onCloseWorkbench }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
const [hover, setHover] = useState<{ src: string; topLabel: string; subLabel: string; rect: DOMRect } | null>(null)
|
||||
const btnRefs = useRef<Record<number, HTMLButtonElement | null>>({})
|
||||
|
||||
if (!job) return null
|
||||
|
||||
const frames = job.frames
|
||||
.filter((f) => selectedFrames.has(f.index))
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
const aspect = job.height > 0 ? `${job.width}/${job.height}` : "9/16"
|
||||
const totalElements = frames.reduce(
|
||||
(sum, f) => sum + (f.elements?.filter((e) => hasCutout(e)).length ?? 0),
|
||||
0,
|
||||
@@ -73,7 +64,6 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
|
||||
if (frames.length === 0) return
|
||||
const nextFrame = focusedFrame ?? frames[0].index
|
||||
if (focusedFrame === null) onFocusFrame(nextFrame)
|
||||
setCollapsed(false)
|
||||
onOpenWorkbench?.(nextFrame)
|
||||
}}
|
||||
disabled={frames.length === 0}
|
||||
@@ -83,123 +73,8 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
|
||||
{workbenchOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
{workbenchOpen ? "收起编排" : "展开编排"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextCollapsed = !collapsed
|
||||
setCollapsed(nextCollapsed)
|
||||
if (nextCollapsed) onCloseWorkbench?.()
|
||||
}}
|
||||
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>
|
||||
|
||||
{/* thumbnails row */}
|
||||
{!collapsed && (
|
||||
frames.length === 0 ? (
|
||||
<div className="px-4 pb-3 text-[11px] text-white/40">
|
||||
还没选用分镜 · 在「关键帧」节点上点击缩略图右下勾选「选用此帧」,被选用的分镜按时间序出现在这里
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-3 flex gap-2 overflow-x-auto">
|
||||
{frames.map((f, i) => {
|
||||
const elementCount = f.elements?.filter((e) => hasCutout(e)).length ?? 0
|
||||
const totalElCount = f.elements?.length ?? 0
|
||||
const cleaned = f.cleaned_applied
|
||||
const isFocused = focusedFrame === f.index
|
||||
return (
|
||||
<button
|
||||
key={f.index}
|
||||
ref={(el) => { btnRefs.current[f.index] = el }}
|
||||
onClick={() => {
|
||||
onFocusFrame(f.index)
|
||||
setCollapsed(false)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
const el = btnRefs.current[f.index]
|
||||
if (el) setHover({
|
||||
src: effectiveFrameUrl(job.id, f),
|
||||
topLabel: `分镜 ${i + 1}`,
|
||||
subLabel: `${f.timestamp.toFixed(2)}s`,
|
||||
rect: el.getBoundingClientRect(),
|
||||
})
|
||||
}}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
title={`分镜 ${i + 1} · ${f.timestamp.toFixed(2)}s${cleaned ? " · 已清洗" : ""} · ${elementCount}/${totalElCount} 元素 · 点击聚焦`}
|
||||
className={`relative shrink-0 rounded-md border transition shadow-lg hover:-translate-y-0.5 ${
|
||||
isFocused
|
||||
? "border-violet-300 ring-2 ring-violet-300/70"
|
||||
: "border-white/15 hover:border-violet-300/60"
|
||||
}`}
|
||||
style={{ width: 88, aspectRatio: aspect }}
|
||||
>
|
||||
<img
|
||||
src={effectiveFrameUrl(job.id, f)}
|
||||
alt={`frame ${f.index}`}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-md"
|
||||
/>
|
||||
<div className="absolute top-1 left-1 text-[9.5px] font-bold text-white bg-violet-500/85 backdrop-blur px-1.5 py-0.5 rounded">
|
||||
#{i + 1}
|
||||
</div>
|
||||
{cleaned && (
|
||||
<div className="absolute top-1 right-1 text-[9px] text-white bg-cyan-500/85 backdrop-blur px-1 py-0.5 rounded font-bold" title="已清洗">
|
||||
✨
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 right-0 left-0 px-1.5 py-0.5 text-[9px] font-mono text-white bg-gradient-to-t from-black/85 to-transparent flex items-center justify-between rounded-b-md">
|
||||
<span>{f.timestamp.toFixed(1)}s</span>
|
||||
{totalElCount > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Sparkle className="h-2 w-2" />
|
||||
{elementCount}/{totalElCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
|
||||
{/* Hover 预览 · 浮在缩略图正下方(bar 在顶部 fixed,下方是 DAG 画布区) */}
|
||||
{mounted && hover && (() => {
|
||||
const vidAspect = job.height > 0 ? job.height / job.width : 16 / 9
|
||||
const w = 280
|
||||
const h = w * vidAspect
|
||||
const gap = 10
|
||||
const centerX = hover.rect.left + hover.rect.width / 2
|
||||
const left = Math.max(12, Math.min(window.innerWidth - w - 12, centerX - w / 2))
|
||||
const top = hover.rect.bottom + gap
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed z-[120] pointer-events-none"
|
||||
style={{
|
||||
left, top,
|
||||
animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="rounded-lg overflow-hidden border-2 border-violet-300/50 bg-white shadow-2xl">
|
||||
<img
|
||||
src={hover.src}
|
||||
alt="preview"
|
||||
className="block"
|
||||
style={{ width: w, height: h, objectFit: "cover" }}
|
||||
/>
|
||||
<div className="px-2 py-1 bg-black/80 text-white text-[10.5px] flex items-center justify-between gap-2">
|
||||
<span className="truncate">{hover.topLabel}</span>
|
||||
<span className="text-white/60 font-mono shrink-0">{hover.subLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,13 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
.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 defaultFirstRef: ImageRef | null = focusFrame
|
||||
? { kind: "keyframe", frame_idx: focusFrame.index, label: `分镜 ${focusSeq || focusFrame.index + 1} 首帧` }
|
||||
: null
|
||||
const nextFrame = focusFrame ? frames.find((f) => f.timestamp > focusFrame.timestamp) ?? null : null
|
||||
const defaultLastRef: ImageRef | null = nextFrame
|
||||
? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${frames.findIndex((f) => f.index === nextFrame.index) + 1} 尾帧` }
|
||||
: null
|
||||
|
||||
const queueSave = (next: StoryboardScene) => {
|
||||
setForm(next)
|
||||
@@ -176,21 +183,20 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4 图槽 grid:图片是参考,不是最终复刻素材 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{/* 首尾帧:图片直接参与视频生成 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{([
|
||||
{ key: "subject_image" as const, label: "参考主体", placeholder: "人物 / 手部 / 模特姿态" },
|
||||
{ key: "scene_image" as const, label: "参考场景", placeholder: "药店柜台 / 卧室 / 浴室" },
|
||||
{ key: "product_image" as const, label: "SKG 产品", placeholder: "产品图 / 包装 / 使用状态" },
|
||||
{ key: "action_image" as const, label: "参考动作", placeholder: "拿起 / 佩戴 / 展示 / 递给顾客" },
|
||||
{ key: "first_image" as const, label: "首帧", placeholder: "默认当前分镜关键帧" },
|
||||
{ key: "last_image" as const, label: "尾帧", placeholder: defaultLastRef ? "默认下一张已选分镜" : "粘贴一张结束画面" },
|
||||
]).map(({ key, label, placeholder }) => {
|
||||
const ref = form[key]
|
||||
const fallback = key === "first_image" ? defaultFirstRef : defaultLastRef
|
||||
const ref = form[key] ?? fallback
|
||||
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 && (
|
||||
{form[key] && (
|
||||
<button
|
||||
onClick={() => queueSave({ ...form, [key]: null })}
|
||||
className="text-[10px] text-white/40 hover:text-rose-300"
|
||||
@@ -238,6 +244,9 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="rounded-md border border-violet-300/20 bg-violet-500/10 px-3 py-2 text-[11px] leading-relaxed text-violet-100/75">
|
||||
现在按首尾帧出片:首帧默认当前分镜,尾帧默认下一张已选分镜。需要指定结尾时,在任意关键帧或生成图点 📋,再粘贴到「尾帧」。
|
||||
</div>
|
||||
|
||||
{/* 改造 brief:明确“借鉴参考 → 变成 SKG 产品视频”,避免直接复刻 */}
|
||||
<section className="rounded-lg bg-white/[0.035] border border-white/10 p-3">
|
||||
@@ -314,13 +323,13 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
}
|
||||
}}
|
||||
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 to-violet-500 text-white border border-violet-300/40 shadow-lg shadow-violet-500/20 hover:from-rose-400 hover:to-violet-400 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={`用当前分镜关键帧作为首帧,调用 ${currentModelLabel} 生视频 API`}
|
||||
title={`用首帧和尾帧调用 ${currentModelLabel} 生视频 API`}
|
||||
>
|
||||
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
|
||||
用当前关键帧生成视频
|
||||
用首尾帧生成视频
|
||||
</button>
|
||||
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
|
||||
直接用当前分镜关键帧作为首帧快速出片;4 图槽和改造目标只作为提示词参考,生成中的进度和完成后的 MP4 会显示在 Video Gen 节点。
|
||||
直接用首帧 + 尾帧快速生成连续过渡视频;改造目标和原视频链接只作为节奏 / 镜头参考,生成进度和 MP4 会显示在 Video Gen 节点。
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface ImageRef {
|
||||
|
||||
export interface StoryboardScene {
|
||||
duration: number
|
||||
first_image?: ImageRef | null
|
||||
last_image?: ImageRef | null
|
||||
subject_image?: ImageRef | null
|
||||
scene_image?: ImageRef | null
|
||||
product_image?: ImageRef | null
|
||||
@@ -378,6 +380,8 @@ export async function generateStoryboardVideo(
|
||||
body: {
|
||||
prompt: string
|
||||
duration?: number
|
||||
first_image?: ImageRef | null
|
||||
last_image?: ImageRef | null
|
||||
subject_image?: ImageRef | null
|
||||
scene_image?: ImageRef | null
|
||||
product_image?: ImageRef | null
|
||||
|
||||
Reference in New Issue
Block a user