auto-save 2026-05-13 20:29 (~9)

This commit is contained in:
2026-05-13 20:29:23 +08:00
parent 989cc912ec
commit e79c33dabd
9 changed files with 315 additions and 120 deletions

View File

@@ -2308,6 +2308,13 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 2 项未提交变更 · 最近提交auto-save 2026-05-13 20:18 (~4)",
"files_changed": 2
},
{
"ts": "2026-05-13T20:23:53+08:00",
"type": "commit",
"message": "auto-save 2026-05-13 20:23 (~2)",
"hash": "989cc91",
"files_changed": 2
}
]
}

View File

@@ -7,7 +7,11 @@ ASR_MODEL=whisper-1
TRANSLATE_MODEL=gemini-2.5-flash
REWRITE_MODEL=gemini-2.5-pro
IMAGE_MODEL=gemini-3-pro-image-preview
VIDEO_MODEL=sora-2
VIDEO_MODEL=seedance
VIDEO_MODEL_SEEDANCE=seedance
VIDEO_MODEL_KLING=kling
VIDEO_MODEL_VEO3=veo3
VIDEO_DURATION_FIELD=seconds
# 工作目录
JOBS_DIR=./jobs

View File

@@ -1584,6 +1584,66 @@ def video_seconds(duration: float) -> str:
return "12"
def resolve_video_model(raw: str | None) -> str:
requested = (raw or VIDEO_MODEL or "seedance").strip()
lowered = requested.lower()
if lowered in {"sora", "sora-2", "sora_2"}:
raise HTTPException(400, "Sora 已停用,请选择 Seedance / Kling / Veo 3")
return VIDEO_MODEL_ALIASES.get(lowered, requested)
def normalize_video_status(status: str | None) -> Literal["queued", "in_progress", "completed", "failed"]:
s = (status or "queued").lower()
if s in {"completed", "complete", "succeeded", "success", "done"}:
return "completed"
if s in {"failed", "failure", "error", "cancelled", "canceled", "expired"}:
return "failed"
if s in {"running", "processing", "in_progress", "generating", "started"}:
return "in_progress"
return "queued"
def video_progress(data: dict, fallback: int) -> int:
raw = data.get("progress", data.get("percentage", data.get("percent", fallback)))
try:
value = int(float(raw))
except Exception:
value = fallback
return max(0, min(100, value))
def video_url_from_response(data: dict) -> str:
for key in ("url", "video_url", "output_url", "download_url"):
v = data.get(key)
if isinstance(v, str) and v:
return v
arr = data.get("data")
if isinstance(arr, list) and arr:
first = arr[0]
if isinstance(first, dict):
for key in ("url", "video_url", "output_url", "download_url"):
v = first.get(key)
if isinstance(v, str) and v:
return v
output = data.get("output")
if isinstance(output, dict):
for key in ("url", "video_url", "download_url"):
v = output.get(key)
if isinstance(v, str) and v:
return v
return ""
def download_generated_video(client, base: str, headers: dict, provider_id: str, direct_url: str, out_mp4: Path) -> None:
if direct_url:
url = direct_url if direct_url.startswith("http") else f"{base}{direct_url if direct_url.startswith('/') else '/' + direct_url}"
r = client.get(url, headers=headers if url.startswith(base) else None)
else:
r = client.get(f"{base}/videos/{provider_id}/content", headers=headers)
r.raise_for_status()
out_mp4.write_bytes(r.content)
def render_storyboard_video(job_id: str, local_id: str, provider_id: str, ref_path: Path, prompt: str, model: str, seconds: str, size: str) -> None:
import httpx
@@ -1594,55 +1654,52 @@ def render_storyboard_video(job_id: str, local_id: str, provider_id: str, ref_pa
headers = {"Authorization": f"Bearer {LLM_API_KEY}"}
try:
prepare_video_reference(ref_path, ref_img)
update_generated_video(job_id, local_id, status="in_progress", progress=5)
with httpx.Client(timeout=120) as client:
with ref_img.open("rb") as fh:
create = client.post(
f"{base}/videos",
headers=headers,
data={
"model": model,
"prompt": prompt,
"seconds": seconds,
"size": size,
},
files={"input_reference": ("reference.jpg", fh, "image/jpeg")},
)
create.raise_for_status()
data = create.json()
video_api_id = data.get("id") or provider_id
update_generated_video(job_id, local_id, provider_id=video_api_id, status=data.get("status", "queued"), progress=int(data.get("progress") or 5))
prepare_video_reference(ref_path, ref_img)
update_generated_video(job_id, local_id, status="in_progress", progress=5)
with httpx.Client(timeout=120) as client:
payload = {"model": model, "prompt": prompt, "size": size}
payload[VIDEO_DURATION_FIELD] = seconds
with ref_img.open("rb") as fh:
create = client.post(
f"{base}/videos",
headers=headers,
data=payload,
files={"input_reference": ("reference.jpg", fh, "image/jpeg")},
)
create.raise_for_status()
data = create.json()
video_api_id = data.get("id") or provider_id or local_id
status = normalize_video_status(data.get("status"))
progress = video_progress(data, 5)
direct_url = video_url_from_response(data)
update_generated_video(job_id, local_id, provider_id=video_api_id, status=status, progress=progress)
status = data.get("status", "queued")
progress = int(data.get("progress") or 5)
deadline = time.time() + 420
while status in {"queued", "in_progress"} and time.time() < deadline:
time.sleep(8)
poll = client.get(f"{base}/videos/{video_api_id}", headers=headers)
poll.raise_for_status()
pdata = poll.json()
status = pdata.get("status", status)
progress = int(pdata.get("progress") or progress)
update_generated_video(job_id, local_id, status=status, progress=progress)
deadline = time.time() + 420
while status in {"queued", "in_progress"} and time.time() < deadline:
time.sleep(8)
poll = client.get(f"{base}/videos/{video_api_id}", headers=headers)
poll.raise_for_status()
pdata = poll.json()
status = normalize_video_status(pdata.get("status"))
progress = video_progress(pdata, progress)
direct_url = video_url_from_response(pdata) or direct_url
update_generated_video(job_id, local_id, status=status, progress=progress)
if status != "completed":
update_generated_video(job_id, local_id, status="failed", error=f"video status: {status}", progress=progress)
return
if status != "completed":
update_generated_video(job_id, local_id, status="failed", error=f"video status: {status}", progress=progress)
return
content = client.get(f"{base}/videos/{video_api_id}/content", headers=headers)
content.raise_for_status()
out_mp4.write_bytes(content.content)
update_generated_video(
job_id,
local_id,
status="completed",
progress=100,
url=f"/jobs/{job_id}/storyboard-videos/{local_id}.mp4",
error="",
)
download_generated_video(client, base, headers, video_api_id, direct_url, out_mp4)
update_generated_video(
job_id,
local_id,
status="completed",
progress=100,
url=f"/jobs/{job_id}/storyboard-videos/{local_id}.mp4",
error="",
)
except Exception as e:
update_generated_video(job_id, local_id, status="failed", error=str(e)[:500])
update_generated_video(job_id, local_id, status="failed", error=str(e)[:500])
@app.post("/jobs/{job_id}/frames/{idx}/storyboard/video", response_model=Job)
@@ -1666,7 +1723,7 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
poster = storyboard_ref_url(job_id, ref) or f"/jobs/{job_id}/frames/{idx}.jpg"
local_id = uuid.uuid4().hex[:12]
model = req.model.strip() or VIDEO_MODEL
model = resolve_video_model(req.model)
seconds = video_seconds(float(req.duration or 4))
item = GeneratedVideo(
id=local_id,
@@ -1686,6 +1743,14 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
return job
@app.get("/jobs/{job_id}/storyboard-videos/{video_id}.mp4")
def get_storyboard_video(job_id: str, video_id: str):
p = job_dir(job_id) / "storyboard_videos" / video_id / "video.mp4"
if not p.exists():
raise HTTPException(404, "storyboard video not found")
return FileResponse(p, media_type="video/mp4")
@app.put("/jobs/{job_id}/frames/{idx}/storyboard", response_model=Job)
def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job:
"""更新分镜的编排字段subject / product / scene / action / duration / reference_ids"""

View File

@@ -557,7 +557,7 @@
<div class="step"><div class="num">4</div><h3>Vision 识别</h3><p>识别场景和候选元素,只是候选,不应锁死。</p></div>
<div class="step"><div class="num">5</div><h3>元素提取</h3><p>编辑/新增/删除元素,对元素反复生成提取图。</p></div>
<div class="step"><div class="num">6</div><h3>元素改造</h3><p>把参考主体、场景、动作和 SKG 产品放入分镜结构。</p></div>
<div class="step"><div class="num">7</div><h3>生成视频</h3><p>用分镜结构生成首帧和视频片段。当前未实现</p></div>
<div class="step"><div class="num">7</div><h3>生成视频</h3><p>用分镜 4 图槽、改造目标和时长调用 Seedance / Kling / Veo 3 生视频 API结果回写到 Video Gen 节点</p></div>
<div class="step"><div class="num">8</div><h3>合成成品</h3><p>片段、字幕、配音、转场合成最终 mp4。当前未实现。</p></div>
</div>
</section>
@@ -760,9 +760,9 @@ api/main.py
</tr>
<tr>
<td><span class="tag green">Video Gen / Compose</span></td>
<td>未来生成视频和合成成品</td>
<td>当前只是占位,不要描述成已打通</td>
<td><code>VideoGenNode</code><code>ComposeNode</code>、未来模型接口</td>
<td>承载生视频任务状态和完成后的 MP4</td>
<td>分镜工作台提交任务Video Gen 节点只展示任务和结果</td>
<td><code>VideoGenNode</code><code>/storyboard/video</code><code>generated_videos</code></td>
</tr>
</tbody>
</table>
@@ -790,7 +790,7 @@ api/main.py
<li>ASRSKG 网关 audio endpoint 404 或渠道不可用。</li>
<li>Translate本身 text 通,但产品流里依赖 ASR 段落。</li>
<li>Rewrite需要 SKG 产品信息模板和目标脚本结构。</li>
<li>Video Gensora/video endpoint 未通,Seedance/Kling/Veo3 外部 key 未接</li>
<li>Video Gen已接 OpenAI-compatible <code>/videos</code> 网关;前端可选 Seedance / Kling / Veo 3具体模型 ID 由 <code>VIDEO_MODEL_*</code> 环境变量映射</li>
<li>Compose还没做本地 ffmpeg 字幕/TTS 合成。</li>
</ul>
</div>
@@ -832,14 +832,14 @@ api/main.py
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-13 · 分镜编排增加快速生成视频任务</h3>
<h3>2026-05-13 · 分镜编排接入真实生视频任务</h3>
<span class="tag violet">StoryboardWorkbench</span>
<span class="tag rose">VideoGenNode</span>
</header>
<div class="body">
<p><strong>问题:</strong>4 图槽已经粘贴参考图后,用户需要快速交付可用于视频生成 prompt,并在 Video Gen 节点看到结果承载</p>
<p><strong>改动:</strong>分镜编排明细区增加“快速生成视频”按钮,自动根据 4 图槽、时长和改造目标生成 SKG 产品视频 prompt生成的任务卡展示在 <code>VideoGenNode</code> 上方hover 可查看大图和 prompt点击卡片复制 prompt</p>
<p><strong>影响:</strong><code>web/components/storyboard-workbench.tsx</code><code>web/components/nodes/index.tsx</code><code>web/app/page.tsx</code><code>web/lib/api.ts</code>当前是前端快速交付承载,后续接 Seedance / Kling / Veo 3 时替换为真实视频 URL</p>
<p><strong>问题:</strong>4 图槽已经粘贴参考图后,用户要直接调用生视频 API而不是只生成 prompt 或图片任务</p>
<p><strong>改动:</strong>分镜编排明细区增加 Seedance / Kling / Veo 3 模型选择和“调用模型生成视频”按钮;后端新增 <code>/jobs/{job_id}/frames/{idx}/storyboard/video</code>,提交 <code>/videos</code> 网关后轮询并保存 MP4<code>VideoGenNode</code> 读取 <code>job.generated_videos</code> 展示排队、生成中、失败和完成视频</p>
<p><strong>影响:</strong><code>api/main.py</code><code>api/.env.example</code><code>web/components/storyboard-workbench.tsx</code><code>web/components/nodes/index.tsx</code><code>web/app/page.tsx</code><code>web/lib/api.ts</code>Sora 不再作为默认模型,真实模型 ID 通过 <code>VIDEO_MODEL_SEEDANCE</code><code>VIDEO_MODEL_KLING</code><code>VIDEO_MODEL_VEO3</code> 配置</p>
</div>
</article>
<article class="change">

View File

@@ -17,8 +17,8 @@ import { StoryboardBar } from "@/components/storyboard-bar"
import { StoryboardWorkbench } from "@/components/storyboard-workbench"
import {
addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
effectiveFrameUrl, resolveImageRefUrl,
type Job, type ImageRef, type StoryboardScene, type GeneratedVideoDraft,
generateStoryboardVideo,
type Job, type ImageRef, type StoryboardScene,
} from "@/lib/api"
import { VideoLightbox } from "@/components/video-lightbox"
@@ -77,7 +77,6 @@ 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
@@ -106,7 +105,6 @@ export default function Home() {
const handleSwitchJob = useCallback((id: string) => {
setActiveJobId(id)
setSelectedFrames(new Set())
setVideoDrafts([])
}, [])
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -217,14 +215,12 @@ export default function Home() {
toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`)
}, [])
const handleQuickGenerateVideo = useCallback((frameIdx: number, scene: StoryboardScene) => {
const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
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.`,
@@ -240,21 +236,25 @@ export default function Home() {
"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",
try {
toast.info(`已提交 ${model} 生视频 · 分镜 ${frameIdx + 1}`)
const updated = await generateStoryboardVideo(job.id, frameIdx, {
prompt,
duration,
subject_image: scene.subject_image ?? null,
scene_image: scene.scene_image ?? null,
product_image: scene.product_image ?? null,
action_image: scene.action_image ?? null,
model,
size: "720x1280",
})
setJob(updated)
void navigator.clipboard?.writeText(prompt).catch(() => {})
toast.success("视频任务已进入 Video Gen 节点")
} catch (e) {
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
}
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])
}, [job, setJob])
// URL ?job=xxx,yyy 自动恢复多个 job
useEffect(() => {
@@ -308,8 +308,9 @@ export default function Home() {
}
prevStatusRef.current = job.status
const runningVideo = !!job.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"]
if (TERMINAL.includes(job.status)) {
if (TERMINAL.includes(job.status) && !runningVideo) {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
return
}
@@ -320,7 +321,7 @@ export default function Home() {
} catch { /* silent */ }
}, 1500)
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [job?.id, job?.status])
}, [job?.id, job?.status, job?.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join("|")])
const nodeData: NodeData = useMemo(() => ({
job,
@@ -332,7 +333,6 @@ export default function Home() {
expandedFrame,
framePanelScale,
framePanelPinned,
videoDrafts,
onSubmitUrl: handleSubmit,
onUploadFile: handleUpload,
onAnalyze: handleAnalyze,
@@ -354,7 +354,7 @@ export default function Home() {
setWorkbenchOpen(true)
},
onCopyImage: handleCopyImage,
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, videoDrafts, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(

View File

@@ -623,8 +623,8 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
{/* ---- VideoGen — Kanban ---- */}
{key === "videogen" && (
<>
<KanbanCard tone="violet" tags={["SKG 网关"]} title="Sora 2">
<div className="text-[11px] text-[var(--text-soft)]">/v1/videos IT</div>
<KanbanCard tone="violet" tags={["SKG 网关"]} title="Seedance / Kling / Veo 3">
<div className="text-[11px] text-[var(--text-soft)]"> /v1/videos ID </div>
</KanbanCard>
<KanbanCard tone="violet" tags={["外部"]} title="Seedance">
<div className="text-[11px] text-[var(--text-soft)]"> · API key</div>

View File

@@ -8,8 +8,8 @@ import {
} from "lucide-react"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import {
type Job, type ImageRef, type GeneratedVideoDraft,
effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
type Job, type ImageRef,
apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
} from "@/lib/api"
import { FrameLightbox } from "@/components/lightbox"
@@ -23,7 +23,6 @@ export interface NodeData {
expandedFrame: number | null
framePanelScale?: number
framePanelPinned?: boolean
videoDrafts?: GeneratedVideoDraft[]
onSubmitUrl: (url: string) => void
onUploadFile: (file: File) => void
onAnalyze: () => void
@@ -943,22 +942,39 @@ export function StoryboardNode({ data, selected }: any) {
============================================================ */
export function VideoGenNode({ data, selected }: any) {
const d: NodeData = data
const drafts = d.videoDrafts ?? []
const status: NodeStatus = drafts.length > 0 ? "done" : "pending"
const videos = d.job?.generated_videos ?? []
const running = videos.some((v) => v.status === "queued" || v.status === "in_progress")
const completed = videos.filter((v) => v.status === "completed" && v.url)
const failed = videos.some((v) => v.status === "failed")
const status: NodeStatus = running ? "running" : completed.length > 0 ? "done" : failed ? "failed" : "pending"
const aspect = d.job && (d.job.width ?? 0) > 0 && (d.job.height ?? 0) > 0
? `${d.job.width}/${d.job.height}`
: "9/16"
const modelLabel = (model: string) => {
const m = model.toLowerCase()
if (m.includes("kling")) return "Kling"
if (m.includes("veo")) return "Veo 3"
if (m.includes("seedance")) return "Seedance"
return model || "Video"
}
return (
<div className="relative" style={{ width: 280 }}>
{drafts.length > 0 && (
{videos.length > 0 && (
<div
className="absolute left-0 right-0 grid grid-cols-3 gap-1.5"
style={{ bottom: "calc(100% + 12px)" }}
>
{drafts.slice(0, 6).map((v, i) => (
{videos.slice(0, 6).map((v, i) => {
const videoSrc = apiAssetUrl(v.url)
const posterSrc = apiAssetUrl(v.poster_url)
const ready = v.status === "completed" && !!videoSrc
const progress = Math.max(0, Math.min(100, v.progress || 0))
return (
<div
key={v.id}
className="group relative rounded-md border border-rose-300/55 transition shadow-lg hover:-translate-y-0.5 bg-black"
className={`group relative rounded-md border transition shadow-lg hover:-translate-y-0.5 bg-black ${
ready ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55"
}`}
style={{ aspectRatio: aspect }}
>
<button
@@ -967,17 +983,44 @@ export function VideoGenNode({ data, selected }: any) {
e.stopPropagation()
void navigator.clipboard?.writeText(v.prompt).catch(() => {})
}}
title={`${v.label} · 点击复制视频 prompt`}
title={`分镜 ${v.frame_idx + 1} · ${modelLabel(v.model)} · 点击复制 prompt`}
className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-black"
>
<img
src={v.poster_url}
alt={v.label}
className="absolute inset-0 w-full h-full object-cover"
/>
{ready ? (
<video
src={videoSrc}
poster={posterSrc}
muted
loop
playsInline
preload="metadata"
className="absolute inset-0 h-full w-full object-cover"
onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
onMouseLeave={(e) => {
const el = e.target as HTMLVideoElement
el.pause()
el.currentTime = 0
}}
/>
) : posterSrc ? (
<img src={posterSrc} alt="" className="absolute inset-0 h-full w-full object-cover opacity-75" />
) : (
<div className="absolute inset-0 bg-violet-950/50" />
)}
{!ready && (
<div className="absolute inset-0 flex items-center justify-center bg-black/35">
{v.status === "failed" ? (
<X className="h-4 w-4 text-rose-200" />
) : (
<Loader2 className="h-4 w-4 animate-spin text-white/85" />
)}
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-1.5 py-1 text-left">
<div className="truncate text-[9.5px] font-semibold text-white"> {i + 1}</div>
<div className="truncate text-[8.5px] font-mono text-white/60">{v.duration.toFixed(1)}s</div>
<div className="truncate text-[8.5px] font-mono text-white/60">
{ready ? `${v.duration.toFixed(0)}s` : v.status === "failed" ? "failed" : `${progress}%`}
</div>
</div>
</button>
<div
@@ -991,28 +1034,34 @@ export function VideoGenNode({ data, selected }: any) {
>
<div className="rounded-lg overflow-hidden border-2 border-rose-300/60 bg-black shadow-2xl" style={{ width: 300 }}>
<div style={{ aspectRatio: aspect }}>
<img src={v.poster_url} alt="" className="w-full h-full object-cover" />
{ready ? (
<video src={videoSrc} poster={posterSrc} muted loop autoPlay playsInline controls className="h-full w-full object-cover" />
) : posterSrc ? (
<img src={posterSrc} alt="" className="w-full h-full object-cover" />
) : (
<div className="h-full w-full bg-violet-950/60" />
)}
</div>
<div className="space-y-1 bg-black/90 px-2 py-1.5 text-white">
<div className="flex items-center justify-between gap-2 text-[10.5px]">
<span className="truncate">{v.label}</span>
<span className="shrink-0 font-mono text-white/55">{v.provider}</span>
<span className="truncate"> {v.frame_idx + 1}</span>
<span className="shrink-0 font-mono text-white/55">{modelLabel(v.model)} · {v.status}</span>
</div>
<div className="line-clamp-3 text-[9.5px] leading-snug text-white/55">
{v.prompt}
{v.status === "failed" ? (v.error || "生成失败") : v.prompt}
</div>
</div>
</div>
</div>
</div>
))}
)})}
</div>
)}
<NodeShell
type="ai" status={status}
icon={<Film className="h-4 w-4" />}
title="生成视频 · Video Gen"
subtitle={`STEP 7 · 首帧 + 动作 prompt${drafts.length > 0 ? ` · ${drafts.length} 个任务` : ""}`}
subtitle={`STEP 7 · 首帧 + 动作 prompt${videos.length > 0 ? ` · ${videos.length}视频任务` : ""}`}
selected={selected}
>
<div className="grid grid-cols-3 gap-1.5 text-[10.5px]">
@@ -1022,9 +1071,9 @@ export function VideoGenNode({ data, selected }: any) {
</div>
))}
</div>
{drafts.length > 0 && (
{videos.length > 0 && (
<div className="mt-2 rounded-md border border-rose-300/25 bg-rose-500/10 px-2 py-1.5 text-[10.5px] text-[var(--text-soft)]">
{drafts.length} prompt ·
{videos.length} · {completed.length} {running ? " · 生成中" : ""}
</div>
)}
</NodeShell>

View File

@@ -15,13 +15,19 @@ interface Props {
onJobUpdate?: (j: Job) => void
clipboard: ImageRef | null // 全局剪贴板page.tsx 提供)
focusedFrame: number | null
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene) => void
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
}
const emptyScene = (): StoryboardScene => ({
subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [],
})
const VIDEO_MODELS = [
{ value: "seedance", label: "Seedance" },
{ value: "kling", label: "Kling" },
{ value: "veo3", label: "Veo 3" },
] as const
export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame, onGenerateVideo }: Props) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
@@ -31,6 +37,8 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
const [saving, setSaving] = useState(false)
const [savedTick, setSavedTick] = useState(0)
const [panelHeight, setPanelHeight] = useState(320)
const [videoModel, setVideoModel] = useState<(typeof VIDEO_MODELS)[number]["value"]>("seedance")
const [generating, setGenerating] = useState(false)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Esc 关闭
@@ -115,6 +123,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
}
const hasVideoRefs = !!(form.subject_image || form.scene_image || form.product_image || form.action_image)
const currentModelLabel = VIDEO_MODELS.find((m) => m.value === videoModel)?.label ?? "Seedance"
return (
<div
@@ -271,23 +280,48 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
</div>
</section>
{/* 快速生成:先产出视频 prompt / 任务卡,结果显示到 Video Gen 节点 */}
{/* 快速生成:直接调用生视频 API,结果显示到 Video Gen 节点 */}
<section>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-[12.5px] font-semibold text-white"></div>
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-black/35 p-0.5">
{VIDEO_MODELS.map((m) => (
<button
key={m.value}
type="button"
onClick={() => setVideoModel(m.value)}
className={`h-6 rounded px-2 text-[10.5px] transition ${
videoModel === m.value
? "bg-violet-500 text-white shadow"
: "text-white/50 hover:bg-white/10 hover:text-white"
}`}
title={`使用 ${m.label} 生成视频`}
>
{m.label}
</button>
))}
</div>
</div>
<button
disabled={!hasVideoRefs || focusedIdx === null}
onClick={() => {
disabled={!hasVideoRefs || focusedIdx === null || generating}
onClick={async () => {
if (focusedIdx === null) return
queueSave(form)
onGenerateVideo?.(focusedIdx, form)
setGenerating(true)
try {
await onGenerateVideo?.(focusedIdx, form, videoModel)
} finally {
setGenerating(false)
}
}}
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 节点" : "先粘贴至少一张参考图"}
title={hasVideoRefs ? `调用 ${currentModelLabel} 生视频 API结果进入 Video Gen 节点` : "先粘贴至少一张参考图"}
>
<Wand2 className="h-4 w-4" />
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
{currentModelLabel}
</button>
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
prompt / Video Gen Seedance / Kling / Veo 3
4 API MP4 Video Gen
</div>
</section>
</div>

View File

@@ -69,16 +69,19 @@ export interface StoryboardScene {
reference_ids?: string[]
}
export interface GeneratedVideoDraft {
export interface GeneratedVideo {
id: string
provider_id?: string
frame_idx: number
label: string
prompt: string
provider: "Quick Prompt" | "Seedance" | "Kling" | "Veo 3"
poster_url: string
model: string
status: "queued" | "in_progress" | "completed" | "failed"
url?: string
poster_url?: string
duration: number
progress: number
error?: string
created_at: number
status: "ready" | "queued" | "failed"
}
// 把 ImageRef 解析成可显示的 src URL
@@ -139,9 +142,16 @@ export interface Job {
frames: KeyFrame[]
transcript: TranscriptSegment[]
storyboard_images?: StoryboardImage[]
generated_videos?: GeneratedVideo[]
error?: string
}
export function apiAssetUrl(path?: string | null): string {
if (!path) return ""
if (/^https?:\/\//i.test(path)) return path
return `${API_BASE}${path.startsWith("/") ? "" : "/"}${path}`
}
export async function createJob(tkUrl: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs`, {
method: "POST",
@@ -341,6 +351,32 @@ export async function updateStoryboard(
return res.json()
}
export async function generateStoryboardVideo(
jobId: string,
frameIdx: number,
body: {
prompt: string
duration?: number
subject_image?: ImageRef | null
scene_image?: ImageRef | null
product_image?: ImageRef | null
action_image?: ImageRef | null
model?: string
size?: string
},
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard/video`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`generateStoryboardVideo ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function deleteCutout(jobId: string, frameIdx: number, elementId: string, cutoutId: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutouts/${cutoutId}`, {
method: "DELETE",