auto-save 2026-05-13 20:45 (~6)

This commit is contained in:
2026-05-13 20:45:53 +08:00
parent 66f2495296
commit 700fa24992
6 changed files with 91 additions and 2 deletions

View File

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

View File

@@ -1785,6 +1785,28 @@ def get_storyboard_video(job_id: str, video_id: str):
return FileResponse(p, media_type="video/mp4")
@app.delete("/jobs/{job_id}/storyboard-videos/{video_id}", response_model=Job)
def delete_storyboard_video(job_id: str, video_id: str) -> Job:
"""删除 Video Gen 节点里的一个视频任务(成功/失败/排队都可删)。"""
job = JOBS.get(job_id)
if not job:
raise HTTPException(404, "job not found")
before = len(job.generated_videos)
removed = next((v for v in job.generated_videos if v.id == video_id), None)
kept = [v for v in job.generated_videos if v.id != video_id]
if len(kept) == before:
raise HTTPException(404, "generated video not found")
out_dir = job_dir(job_id) / "storyboard_videos" / video_id
if out_dir.exists():
try:
shutil.rmtree(out_dir)
except OSError:
pass
msg = f"删除视频任务 · 分镜 {removed.frame_idx + 1}" if removed else "删除视频任务"
update(job, generated_videos=kept, message=msg)
return job
@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

@@ -830,6 +830,18 @@ api/main.py
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-13 · Video Gen 卡片增加复制和删除</h3>
<span class="tag rose">VideoGenNode</span>
<span class="tag blue">API</span>
</header>
<div class="body">
<p><strong>问题:</strong>Video Gen 节点上方失败/完成任务卡只有整卡点击复制,不够明确;失败任务也无法从界面清掉。</p>
<p><strong>改动:</strong>每张视频任务卡左上角增加复制 prompt 按钮,右上角增加删除任务按钮;后端新增 <code>DELETE /jobs/{job_id}/storyboard-videos/{video_id}</code>,删除 <code>generated_videos</code> 记录并清理本地任务目录。</p>
<p><strong>影响:</strong><code>web/components/nodes/index.tsx</code><code>web/app/page.tsx</code><code>web/lib/api.ts</code><code>api/main.py</code></p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-13 · 分镜编排接入真实生视频任务</h3>

View File

@@ -17,7 +17,7 @@ import { StoryboardBar } from "@/components/storyboard-bar"
import { StoryboardWorkbench } from "@/components/storyboard-workbench"
import {
addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
generateStoryboardVideo,
deleteGeneratedVideo, generateStoryboardVideo,
type Job, type ImageRef, type StoryboardScene,
} from "@/lib/api"
import { VideoLightbox } from "@/components/video-lightbox"
@@ -210,6 +210,17 @@ export default function Home() {
}
}, [activeJobId, setJob])
const handleDeleteVideo = useCallback(async (videoId: string) => {
if (!activeJobId) return
try {
const updated = await deleteGeneratedVideo(activeJobId, videoId)
setJob(updated)
toast.success("视频任务已删除")
} catch (e) {
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [activeJobId, setJob])
const handleCopyImage = useCallback((ref: ImageRef) => {
setClipboard(ref)
toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`)
@@ -348,13 +359,14 @@ export default function Home() {
onJobUpdate: setJob as any,
onDeleteFrame: handleDeleteFrame,
onDeleteGenerated: handleDeleteGenerated,
onDeleteVideo: handleDeleteVideo,
onOpenStoryboard: (idx: number) => setStoryboardFrame(idx),
onOpenWorkbench: (idx?: number) => {
if (typeof idx === "number") setStoryboardFrame(idx)
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, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleDeleteVideo, handleCopyImage])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(

View File

@@ -5,7 +5,9 @@ import { type NodeProps, useReactFlow } from "@xyflow/react"
import {
Link2, Upload, Download, Scissors, Image as ImageIcon,
Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid, Pin, Maximize2,
Copy, Trash2,
} from "lucide-react"
import { toast } from "sonner"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import {
type Job, type ImageRef,
@@ -39,6 +41,7 @@ export interface NodeData {
onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开
onDeleteFrame?: (idx: number) => void // 删整张关键帧
onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图
onDeleteVideo?: (videoId: string) => void // 删 Video Gen 任务
onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板
onOpenWorkbench?: (frameIdx?: number) => void // 展开顶部分镜编排内嵌面板
onCopyImage?: (ref: ImageRef) => void // 复制图片到全局剪贴板(粘贴到分镜头编排插槽)
@@ -982,6 +985,7 @@ export function VideoGenNode({ data, selected }: any) {
onClick={(e) => {
e.stopPropagation()
void navigator.clipboard?.writeText(v.prompt).catch(() => {})
toast.success("已复制视频 prompt")
}}
title={`分镜 ${v.frame_idx + 1} · ${modelLabel(v.model)} · 点击复制 prompt`}
className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-black"
@@ -1023,6 +1027,29 @@ export function VideoGenNode({ data, selected }: any) {
</div>
</div>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void navigator.clipboard?.writeText(v.prompt).catch(() => {})
toast.success("已复制视频 prompt")
}}
className="absolute left-1 top-1 z-[70] h-5 w-5 rounded-full bg-violet-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center transition hover:bg-violet-400 hover:scale-110"
title="复制视频 prompt"
>
<Copy className="h-2.5 w-2.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
d.onDeleteVideo?.(v.id)
}}
className="absolute right-1 top-1 z-[70] h-5 w-5 rounded-full bg-rose-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center transition hover:bg-rose-400 hover:scale-110"
title="删除这个视频任务"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
<div
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-150 z-[60]"
style={{

View File

@@ -377,6 +377,15 @@ export async function generateStoryboardVideo(
return res.json()
}
export async function deleteGeneratedVideo(jobId: string, videoId: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/storyboard-videos/${videoId}`, { method: "DELETE" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`deleteGeneratedVideo ${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",