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", "type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 2 项未提交变更 · 最近提交auto-save 2026-05-13 20:18 (~4)", "message": "Codex 会话活跃 · 最近命令codex · 2 项未提交变更 · 最近提交auto-save 2026-05-13 20:18 (~4)",
"files_changed": 2 "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 TRANSLATE_MODEL=gemini-2.5-flash
REWRITE_MODEL=gemini-2.5-pro REWRITE_MODEL=gemini-2.5-pro
IMAGE_MODEL=gemini-3-pro-image-preview 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 JOBS_DIR=./jobs

View File

@@ -1584,6 +1584,66 @@ def video_seconds(duration: float) -> str:
return "12" 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: 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 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}"} headers = {"Authorization": f"Bearer {LLM_API_KEY}"}
try: try:
prepare_video_reference(ref_path, ref_img) prepare_video_reference(ref_path, ref_img)
update_generated_video(job_id, local_id, status="in_progress", progress=5) update_generated_video(job_id, local_id, status="in_progress", progress=5)
with httpx.Client(timeout=120) as client: with httpx.Client(timeout=120) as client:
with ref_img.open("rb") as fh: payload = {"model": model, "prompt": prompt, "size": size}
create = client.post( payload[VIDEO_DURATION_FIELD] = seconds
f"{base}/videos", with ref_img.open("rb") as fh:
headers=headers, create = client.post(
data={ f"{base}/videos",
"model": model, headers=headers,
"prompt": prompt, data=payload,
"seconds": seconds, files={"input_reference": ("reference.jpg", fh, "image/jpeg")},
"size": size, )
}, create.raise_for_status()
files={"input_reference": ("reference.jpg", fh, "image/jpeg")}, data = create.json()
) video_api_id = data.get("id") or provider_id or local_id
create.raise_for_status() status = normalize_video_status(data.get("status"))
data = create.json() progress = video_progress(data, 5)
video_api_id = data.get("id") or provider_id direct_url = video_url_from_response(data)
update_generated_video(job_id, local_id, provider_id=video_api_id, status=data.get("status", "queued"), progress=int(data.get("progress") or 5)) update_generated_video(job_id, local_id, provider_id=video_api_id, status=status, progress=progress)
status = data.get("status", "queued") deadline = time.time() + 420
progress = int(data.get("progress") or 5) while status in {"queued", "in_progress"} and time.time() < deadline:
deadline = time.time() + 420 time.sleep(8)
while status in {"queued", "in_progress"} and time.time() < deadline: poll = client.get(f"{base}/videos/{video_api_id}", headers=headers)
time.sleep(8) poll.raise_for_status()
poll = client.get(f"{base}/videos/{video_api_id}", headers=headers) pdata = poll.json()
poll.raise_for_status() status = normalize_video_status(pdata.get("status"))
pdata = poll.json() progress = video_progress(pdata, progress)
status = pdata.get("status", status) direct_url = video_url_from_response(pdata) or direct_url
progress = int(pdata.get("progress") or progress) update_generated_video(job_id, local_id, status=status, progress=progress)
update_generated_video(job_id, local_id, status=status, progress=progress)
if status != "completed": if status != "completed":
update_generated_video(job_id, local_id, status="failed", error=f"video status: {status}", progress=progress) update_generated_video(job_id, local_id, status="failed", error=f"video status: {status}", progress=progress)
return return
content = client.get(f"{base}/videos/{video_api_id}/content", headers=headers) download_generated_video(client, base, headers, video_api_id, direct_url, out_mp4)
content.raise_for_status() update_generated_video(
out_mp4.write_bytes(content.content) job_id,
update_generated_video( local_id,
job_id, status="completed",
local_id, progress=100,
status="completed", url=f"/jobs/{job_id}/storyboard-videos/{local_id}.mp4",
progress=100, error="",
url=f"/jobs/{job_id}/storyboard-videos/{local_id}.mp4", )
error="",
)
except Exception as e: 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) @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" poster = storyboard_ref_url(job_id, ref) or f"/jobs/{job_id}/frames/{idx}.jpg"
local_id = uuid.uuid4().hex[:12] 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)) seconds = video_seconds(float(req.duration or 4))
item = GeneratedVideo( item = GeneratedVideo(
id=local_id, id=local_id,
@@ -1686,6 +1743,14 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
return job 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) @app.put("/jobs/{job_id}/frames/{idx}/storyboard", response_model=Job)
def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job: def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job:
"""更新分镜的编排字段subject / product / scene / action / duration / reference_ids""" """更新分镜的编排字段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">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">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">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 class="step"><div class="num">8</div><h3>合成成品</h3><p>片段、字幕、配音、转场合成最终 mp4。当前未实现。</p></div>
</div> </div>
</section> </section>
@@ -760,9 +760,9 @@ api/main.py
</tr> </tr>
<tr> <tr>
<td><span class="tag green">Video Gen / Compose</span></td> <td><span class="tag green">Video Gen / Compose</span></td>
<td>未来生成视频和合成成品</td> <td>承载生视频任务状态和完成后的 MP4</td>
<td>当前只是占位,不要描述成已打通</td> <td>分镜工作台提交任务Video Gen 节点只展示任务和结果</td>
<td><code>VideoGenNode</code><code>ComposeNode</code>、未来模型接口</td> <td><code>VideoGenNode</code><code>/storyboard/video</code><code>generated_videos</code></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -790,7 +790,7 @@ api/main.py
<li>ASRSKG 网关 audio endpoint 404 或渠道不可用。</li> <li>ASRSKG 网关 audio endpoint 404 或渠道不可用。</li>
<li>Translate本身 text 通,但产品流里依赖 ASR 段落。</li> <li>Translate本身 text 通,但产品流里依赖 ASR 段落。</li>
<li>Rewrite需要 SKG 产品信息模板和目标脚本结构。</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> <li>Compose还没做本地 ffmpeg 字幕/TTS 合成。</li>
</ul> </ul>
</div> </div>
@@ -832,14 +832,14 @@ api/main.py
<div class="changelog"> <div class="changelog">
<article class="change"> <article class="change">
<header> <header>
<h3>2026-05-13 · 分镜编排增加快速生成视频任务</h3> <h3>2026-05-13 · 分镜编排接入真实生视频任务</h3>
<span class="tag violet">StoryboardWorkbench</span> <span class="tag violet">StoryboardWorkbench</span>
<span class="tag rose">VideoGenNode</span> <span class="tag rose">VideoGenNode</span>
</header> </header>
<div class="body"> <div class="body">
<p><strong>问题:</strong>4 图槽已经粘贴参考图后,用户需要快速交付可用于视频生成 prompt,并在 Video Gen 节点看到结果承载</p> <p><strong>问题:</strong>4 图槽已经粘贴参考图后,用户要直接调用生视频 API而不是只生成 prompt 或图片任务</p>
<p><strong>改动:</strong>分镜编排明细区增加“快速生成视频”按钮,自动根据 4 图槽、时长和改造目标生成 SKG 产品视频 prompt生成的任务卡展示在 <code>VideoGenNode</code> 上方hover 可查看大图和 prompt点击卡片复制 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>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><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> </div>
</article> </article>
<article class="change"> <article class="change">

View File

@@ -17,8 +17,8 @@ import { StoryboardBar } from "@/components/storyboard-bar"
import { StoryboardWorkbench } from "@/components/storyboard-workbench" import { StoryboardWorkbench } from "@/components/storyboard-workbench"
import { import {
addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
effectiveFrameUrl, resolveImageRefUrl, generateStoryboardVideo,
type Job, type ImageRef, type StoryboardScene, type GeneratedVideoDraft, type Job, type ImageRef, type StoryboardScene,
} from "@/lib/api" } from "@/lib/api"
import { VideoLightbox } from "@/components/video-lightbox" import { VideoLightbox } from "@/components/video-lightbox"
@@ -77,7 +77,6 @@ export default function Home() {
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null) const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
const [workbenchOpen, setWorkbenchOpen] = useState(false) const [workbenchOpen, setWorkbenchOpen] = useState(false)
const [clipboard, setClipboard] = useState<ImageRef | null>(null) const [clipboard, setClipboard] = useState<ImageRef | null>(null)
const [videoDrafts, setVideoDrafts] = useState<GeneratedVideoDraft[]>([])
const flowRef = useRef<any>(null) const flowRef = useRef<any>(null)
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active // 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
@@ -106,7 +105,6 @@ export default function Home() {
const handleSwitchJob = useCallback((id: string) => { const handleSwitchJob = useCallback((id: string) => {
setActiveJobId(id) setActiveJobId(id)
setSelectedFrames(new Set()) setSelectedFrames(new Set())
setVideoDrafts([])
}, []) }, [])
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null) const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -217,14 +215,12 @@ export default function Home() {
toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`) 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 if (!job) return
const frame = job.frames.find((f) => f.index === frameIdx) const frame = job.frames.find((f) => f.index === frameIdx)
if (!frame) return if (!frame) return
const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback 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 duration = scene.duration && scene.duration > 0 ? scene.duration : 5
const prompt = [ const prompt = [
`Vertical 9:16 short product video for SKG, ${duration.toFixed(1)} seconds.`, `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.", "High quality realistic commercial video, clean background, no captions, no platform UI, no TikTok watermark, no extra text.",
].join("\n") ].join("\n")
const draft: GeneratedVideoDraft = { try {
id: `quick-${frameIdx}-${Date.now().toString(36)}`, toast.info(`已提交 ${model} 生视频 · 分镜 ${frameIdx + 1}`)
frame_idx: frameIdx, const updated = await generateStoryboardVideo(job.id, frameIdx, {
label: `分镜 ${frameIdx + 1} · 快速视频`, prompt,
prompt, duration,
provider: "Quick Prompt", subject_image: scene.subject_image ?? null,
poster_url: posterUrl, scene_image: scene.scene_image ?? null,
duration, product_image: scene.product_image ?? null,
created_at: Date.now(), action_image: scene.action_image ?? null,
status: "ready", 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)) }, [job, setJob])
void navigator.clipboard?.writeText(prompt).catch(() => {})
toast.success("已生成视频 prompt · 已显示到 Video Gen 节点")
}, [job])
// URL ?job=xxx,yyy 自动恢复多个 job // URL ?job=xxx,yyy 自动恢复多个 job
useEffect(() => { useEffect(() => {
@@ -308,8 +308,9 @@ export default function Home() {
} }
prevStatusRef.current = job.status 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"] 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 } if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
return return
} }
@@ -320,7 +321,7 @@ export default function Home() {
} catch { /* silent */ } } catch { /* silent */ }
}, 1500) }, 1500)
return () => { if (pollRef.current) clearInterval(pollRef.current) } 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(() => ({ const nodeData: NodeData = useMemo(() => ({
job, job,
@@ -332,7 +333,6 @@ export default function Home() {
expandedFrame, expandedFrame,
framePanelScale, framePanelScale,
framePanelPinned, framePanelPinned,
videoDrafts,
onSubmitUrl: handleSubmit, onSubmitUrl: handleSubmit,
onUploadFile: handleUpload, onUploadFile: handleUpload,
onAnalyze: handleAnalyze, onAnalyze: handleAnalyze,
@@ -354,7 +354,7 @@ export default function Home() {
setWorkbenchOpen(true) setWorkbenchOpen(true)
}, },
onCopyImage: handleCopyImage, 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 // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const [nodes, setNodes, onNodesChange] = useNodesState<Node>( const [nodes, setNodes, onNodesChange] = useNodesState<Node>(

View File

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

View File

@@ -8,8 +8,8 @@ import {
} from "lucide-react" } from "lucide-react"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell" import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import { import {
type Job, type ImageRef, type GeneratedVideoDraft, type Job, type ImageRef,
effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl, apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
} from "@/lib/api" } from "@/lib/api"
import { FrameLightbox } from "@/components/lightbox" import { FrameLightbox } from "@/components/lightbox"
@@ -23,7 +23,6 @@ export interface NodeData {
expandedFrame: number | null expandedFrame: number | null
framePanelScale?: number framePanelScale?: number
framePanelPinned?: boolean framePanelPinned?: boolean
videoDrafts?: GeneratedVideoDraft[]
onSubmitUrl: (url: string) => void onSubmitUrl: (url: string) => void
onUploadFile: (file: File) => void onUploadFile: (file: File) => void
onAnalyze: () => void onAnalyze: () => void
@@ -943,22 +942,39 @@ export function StoryboardNode({ data, selected }: any) {
============================================================ */ ============================================================ */
export function VideoGenNode({ data, selected }: any) { export function VideoGenNode({ data, selected }: any) {
const d: NodeData = data const d: NodeData = data
const drafts = d.videoDrafts ?? [] const videos = d.job?.generated_videos ?? []
const status: NodeStatus = drafts.length > 0 ? "done" : "pending" 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 const aspect = d.job && (d.job.width ?? 0) > 0 && (d.job.height ?? 0) > 0
? `${d.job.width}/${d.job.height}` ? `${d.job.width}/${d.job.height}`
: "9/16" : "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 ( return (
<div className="relative" style={{ width: 280 }}> <div className="relative" style={{ width: 280 }}>
{drafts.length > 0 && ( {videos.length > 0 && (
<div <div
className="absolute left-0 right-0 grid grid-cols-3 gap-1.5" className="absolute left-0 right-0 grid grid-cols-3 gap-1.5"
style={{ bottom: "calc(100% + 12px)" }} 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 <div
key={v.id} 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 }} style={{ aspectRatio: aspect }}
> >
<button <button
@@ -967,17 +983,44 @@ export function VideoGenNode({ data, selected }: any) {
e.stopPropagation() e.stopPropagation()
void navigator.clipboard?.writeText(v.prompt).catch(() => {}) 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" className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-black"
> >
<img {ready ? (
src={v.poster_url} <video
alt={v.label} src={videoSrc}
className="absolute inset-0 w-full h-full object-cover" 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="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-[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> </div>
</button> </button>
<div <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 className="rounded-lg overflow-hidden border-2 border-rose-300/60 bg-black shadow-2xl" style={{ width: 300 }}>
<div style={{ aspectRatio: aspect }}> <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>
<div className="space-y-1 bg-black/90 px-2 py-1.5 text-white"> <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]"> <div className="flex items-center justify-between gap-2 text-[10.5px]">
<span className="truncate">{v.label}</span> <span className="truncate"> {v.frame_idx + 1}</span>
<span className="shrink-0 font-mono text-white/55">{v.provider}</span> <span className="shrink-0 font-mono text-white/55">{modelLabel(v.model)} · {v.status}</span>
</div> </div>
<div className="line-clamp-3 text-[9.5px] leading-snug text-white/55"> <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>
</div> </div>
</div> </div>
))} )})}
</div> </div>
)} )}
<NodeShell <NodeShell
type="ai" status={status} type="ai" status={status}
icon={<Film className="h-4 w-4" />} icon={<Film className="h-4 w-4" />}
title="生成视频 · Video Gen" title="生成视频 · Video Gen"
subtitle={`STEP 7 · 首帧 + 动作 prompt${drafts.length > 0 ? ` · ${drafts.length} 个任务` : ""}`} subtitle={`STEP 7 · 首帧 + 动作 prompt${videos.length > 0 ? ` · ${videos.length}视频任务` : ""}`}
selected={selected} selected={selected}
> >
<div className="grid grid-cols-3 gap-1.5 text-[10.5px]"> <div className="grid grid-cols-3 gap-1.5 text-[10.5px]">
@@ -1022,9 +1071,9 @@ export function VideoGenNode({ data, selected }: any) {
</div> </div>
))} ))}
</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)]"> <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> </div>
)} )}
</NodeShell> </NodeShell>

View File

@@ -15,13 +15,19 @@ interface Props {
onJobUpdate?: (j: Job) => void onJobUpdate?: (j: Job) => void
clipboard: ImageRef | null // 全局剪贴板page.tsx 提供) clipboard: ImageRef | null // 全局剪贴板page.tsx 提供)
focusedFrame: number | null focusedFrame: number | null
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene) => void onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
} }
const emptyScene = (): StoryboardScene => ({ const emptyScene = (): StoryboardScene => ({
subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [], 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) { export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame, onGenerateVideo }: Props) {
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), []) useEffect(() => setMounted(true), [])
@@ -31,6 +37,8 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [savedTick, setSavedTick] = useState(0) const [savedTick, setSavedTick] = useState(0)
const [panelHeight, setPanelHeight] = useState(320) 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) const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Esc 关闭 // 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 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 ( return (
<div <div
@@ -271,23 +280,48 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
</div> </div>
</section> </section>
{/* 快速生成:先产出视频 prompt / 任务卡,结果显示到 Video Gen 节点 */} {/* 快速生成:直接调用生视频 API,结果显示到 Video Gen 节点 */}
<section> <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 <button
disabled={!hasVideoRefs || focusedIdx === null} disabled={!hasVideoRefs || focusedIdx === null || generating}
onClick={() => { onClick={async () => {
if (focusedIdx === null) return if (focusedIdx === null) return
queueSave(form) 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" 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> </button>
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed"> <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> </div>
</section> </section>
</div> </div>

View File

@@ -69,16 +69,19 @@ export interface StoryboardScene {
reference_ids?: string[] reference_ids?: string[]
} }
export interface GeneratedVideoDraft { export interface GeneratedVideo {
id: string id: string
provider_id?: string
frame_idx: number frame_idx: number
label: string
prompt: string prompt: string
provider: "Quick Prompt" | "Seedance" | "Kling" | "Veo 3" model: string
poster_url: string status: "queued" | "in_progress" | "completed" | "failed"
url?: string
poster_url?: string
duration: number duration: number
progress: number
error?: string
created_at: number created_at: number
status: "ready" | "queued" | "failed"
} }
// 把 ImageRef 解析成可显示的 src URL // 把 ImageRef 解析成可显示的 src URL
@@ -139,9 +142,16 @@ export interface Job {
frames: KeyFrame[] frames: KeyFrame[]
transcript: TranscriptSegment[] transcript: TranscriptSegment[]
storyboard_images?: StoryboardImage[] storyboard_images?: StoryboardImage[]
generated_videos?: GeneratedVideo[]
error?: string 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> { export async function createJob(tkUrl: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs`, { const res = await fetch(`${API_BASE}/jobs`, {
method: "POST", method: "POST",
@@ -341,6 +351,32 @@ export async function updateStoryboard(
return res.json() 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> { 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}`, { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutouts/${cutoutId}`, {
method: "DELETE", method: "DELETE",