auto-save 2026-05-13 20:12 (~5)

This commit is contained in:
2026-05-13 20:12:54 +08:00
parent 52c120cd50
commit 0b6a463943
5 changed files with 94 additions and 10 deletions

View File

@@ -2275,6 +2275,19 @@
"message": "auto-save 2026-05-13 20:01 (~6)",
"hash": "3f9075f",
"files_changed": 6
},
{
"ts": "2026-05-13T20:07:24+08:00",
"type": "commit",
"message": "auto-save 2026-05-13 20:07 (~5)",
"hash": "52c120c",
"files_changed": 5
},
{
"ts": "2026-05-13T12:09:29Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 1 项未提交变更 · 最近提交auto-save 2026-05-13 20:07 (~5)",
"files_changed": 1
}
]
}

View File

@@ -15,7 +15,11 @@ import {
import { ThemeToggle } from "@/components/theme-toggle"
import { StoryboardBar } from "@/components/storyboard-bar"
import { StoryboardWorkbench } from "@/components/storyboard-workbench"
import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, type Job, type ImageRef } from "@/lib/api"
import {
addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
effectiveFrameUrl, resolveImageRefUrl,
type Job, type ImageRef, type StoryboardScene, type GeneratedVideoDraft,
} from "@/lib/api"
import { VideoLightbox } from "@/components/video-lightbox"
const NODE_TYPES = {
@@ -73,6 +77,7 @@ export default function Home() {
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
const [workbenchOpen, setWorkbenchOpen] = useState(false)
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
const [videoDrafts, setVideoDrafts] = useState<GeneratedVideoDraft[]>([])
const flowRef = useRef<any>(null)
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
@@ -101,6 +106,7 @@ export default function Home() {
const handleSwitchJob = useCallback((id: string) => {
setActiveJobId(id)
setSelectedFrames(new Set())
setVideoDrafts([])
}, [])
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -211,6 +217,45 @@ export default function Home() {
toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`)
}, [])
const handleQuickGenerateVideo = useCallback((frameIdx: number, scene: StoryboardScene) => {
if (!job) return
const frame = job.frames.find((f) => f.index === frameIdx)
if (!frame) return
const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback
const posterRef = scene.product_image ?? scene.subject_image ?? scene.scene_image ?? scene.action_image ?? null
const posterUrl = posterRef ? resolveImageRefUrl(job.id, posterRef) : effectiveFrameUrl(job.id, frame)
const duration = scene.duration && scene.duration > 0 ? scene.duration : 5
const prompt = [
`Vertical 9:16 short product video for SKG, ${duration.toFixed(1)} seconds.`,
"Use the reference materials only for composition, pose, scene mood and motion rhythm; do not copy the original video, text, watermark, logo, or non-SKG product.",
`Reference subject: ${labelOf(scene.subject_image, "clean product demonstration subject")}.`,
`Reference scene: ${labelOf(scene.scene_image, "clean modern wellness / home / retail scene")}.`,
`SKG product reference: ${labelOf(scene.product_image, "SKG product as the hero object")}.`,
`Reference action: ${labelOf(scene.action_image, "hands-on product demonstration action")}.`,
scene.subject ? `Subject change: ${scene.subject}.` : "Subject change: clean, trustworthy product demo talent or hands, no medical skeleton unless explicitly requested.",
scene.product ? `Product replacement: ${scene.product}.` : "Product replacement: make SKG product the visual focus, premium, clean, realistic, clearly visible.",
scene.scene ? `Scene adaptation: ${scene.scene}.` : "Scene adaptation: borrow only the useful layout and credibility from the reference, convert it to SKG product context.",
scene.action ? `Camera and action: ${scene.action}.` : "Camera and action: slow push-in, product reveal, close-up detail, natural hand interaction, stable commercial lighting.",
"High quality realistic commercial video, clean background, no captions, no platform UI, no TikTok watermark, no extra text.",
].join("\n")
const draft: GeneratedVideoDraft = {
id: `quick-${frameIdx}-${Date.now().toString(36)}`,
frame_idx: frameIdx,
label: `分镜 ${frameIdx + 1} · 快速视频`,
prompt,
provider: "Quick Prompt",
poster_url: posterUrl,
duration,
created_at: Date.now(),
status: "ready",
}
setVideoDrafts((prev) => [draft, ...prev.filter((x) => x.id !== draft.id)].slice(0, 8))
void navigator.clipboard?.writeText(prompt).catch(() => {})
toast.success("已生成视频 prompt · 已显示到 Video Gen 节点")
}, [job])
// URL ?job=xxx,yyy 自动恢复多个 job
useEffect(() => {
const params = new URLSearchParams(window.location.search)
@@ -287,6 +332,7 @@ export default function Home() {
expandedFrame,
framePanelScale,
framePanelPinned,
videoDrafts,
onSubmitUrl: handleSubmit,
onUploadFile: handleUpload,
onAnalyze: handleAnalyze,
@@ -308,7 +354,7 @@ export default function Home() {
setWorkbenchOpen(true)
},
onCopyImage: handleCopyImage,
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, videoDrafts, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
@@ -429,6 +475,7 @@ export default function Home() {
onJobUpdate={setJob as any}
clipboard={clipboard}
focusedFrame={storyboardFrame}
onGenerateVideo={handleQuickGenerateVideo}
/>
</div>
<div className="relative flex-1 min-h-0">

View File

@@ -7,7 +7,10 @@ import {
Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid, Pin, Maximize2,
} from "lucide-react"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import { type Job, type ImageRef, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl } from "@/lib/api"
import {
type Job, type ImageRef, type GeneratedVideoDraft,
effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
} from "@/lib/api"
import { FrameLightbox } from "@/components/lightbox"
export interface NodeData {
@@ -20,6 +23,7 @@ export interface NodeData {
expandedFrame: number | null
framePanelScale?: number
framePanelPinned?: boolean
videoDrafts?: GeneratedVideoDraft[]
onSubmitUrl: (url: string) => void
onUploadFile: (file: File) => void
onAnalyze: () => void

View File

@@ -15,13 +15,14 @@ interface Props {
onJobUpdate?: (j: Job) => void
clipboard: ImageRef | null // 全局剪贴板page.tsx 提供)
focusedFrame: number | null
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene) => void
}
const emptyScene = (): StoryboardScene => ({
subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [],
})
export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame }: Props) {
export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame, onGenerateVideo }: Props) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
@@ -113,6 +114,8 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
window.addEventListener("pointerup", onUp)
}
const hasVideoRefs = !!(form.subject_image || form.scene_image || form.product_image || form.action_image)
return (
<div
className="relative z-20 flex-shrink-0 border-t border-white/5 border-b border-white/10 bg-black/70 backdrop-blur-xl shadow-2xl"
@@ -268,18 +271,23 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
</div>
</section>
{/* 生成按钮Phase 2 占位) */}
{/* 快速生成:先产出视频 prompt / 任务卡,结果显示到 Video Gen 节点 */}
<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 实施"
disabled={!hasVideoRefs || focusedIdx === null}
onClick={() => {
if (focusedIdx === null) return
queueSave(form)
onGenerateVideo?.(focusedIdx, form)
}}
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={hasVideoRefs ? "根据当前 4 图槽和改造目标生成视频 prompt并推送到 Video Gen 节点" : "先粘贴至少一张参考图"}
>
<Wand2 className="h-4 w-4" />
/ Phase 2
</button>
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
4 + + SKG Seedance / Kling / Veo3
prompt / Video Gen Seedance / Kling / Veo 3
</div>
</section>
</div>

View File

@@ -69,6 +69,18 @@ export interface StoryboardScene {
reference_ids?: string[]
}
export interface GeneratedVideoDraft {
id: string
frame_idx: number
label: string
prompt: string
provider: "Quick Prompt" | "Seedance" | "Kling" | "Veo 3"
poster_url: string
duration: number
created_at: number
status: "ready" | "queued" | "failed"
}
// 把 ImageRef 解析成可显示的 src URL
export function resolveImageRefUrl(jobId: string, ref: ImageRef): string {
if (ref.kind === "keyframe") {