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", "type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 4 项未提交变更 · 最近提交auto-save 2026-05-13 20:34 (~2)", "message": "Codex 会话活跃 · 最近命令codex · 4 项未提交变更 · 最近提交auto-save 2026-05-13 20:34 (~2)",
"files_changed": 4 "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") 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) @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

@@ -830,6 +830,18 @@ api/main.py
<h2>变更记录</h2> <h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p> <p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog"> <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"> <article class="change">
<header> <header>
<h3>2026-05-13 · 分镜编排接入真实生视频任务</h3> <h3>2026-05-13 · 分镜编排接入真实生视频任务</h3>

View File

@@ -17,7 +17,7 @@ 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,
generateStoryboardVideo, deleteGeneratedVideo, generateStoryboardVideo,
type Job, type ImageRef, type StoryboardScene, type Job, type ImageRef, type StoryboardScene,
} from "@/lib/api" } from "@/lib/api"
import { VideoLightbox } from "@/components/video-lightbox" import { VideoLightbox } from "@/components/video-lightbox"
@@ -210,6 +210,17 @@ export default function Home() {
} }
}, [activeJobId, setJob]) }, [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) => { const handleCopyImage = useCallback((ref: ImageRef) => {
setClipboard(ref) setClipboard(ref)
toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`) toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`)
@@ -348,13 +359,14 @@ export default function Home() {
onJobUpdate: setJob as any, onJobUpdate: setJob as any,
onDeleteFrame: handleDeleteFrame, onDeleteFrame: handleDeleteFrame,
onDeleteGenerated: handleDeleteGenerated, onDeleteGenerated: handleDeleteGenerated,
onDeleteVideo: handleDeleteVideo,
onOpenStoryboard: (idx: number) => setStoryboardFrame(idx), onOpenStoryboard: (idx: number) => setStoryboardFrame(idx),
onOpenWorkbench: (idx?: number) => { onOpenWorkbench: (idx?: number) => {
if (typeof idx === "number") setStoryboardFrame(idx) if (typeof idx === "number") setStoryboardFrame(idx)
setWorkbenchOpen(true) setWorkbenchOpen(true)
}, },
onCopyImage: handleCopyImage, 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 // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const [nodes, setNodes, onNodesChange] = useNodesState<Node>( const [nodes, setNodes, onNodesChange] = useNodesState<Node>(

View File

@@ -5,7 +5,9 @@ import { type NodeProps, useReactFlow } from "@xyflow/react"
import { import {
Link2, Upload, Download, Scissors, Image as ImageIcon, Link2, Upload, Download, Scissors, Image as ImageIcon,
Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid, Pin, Maximize2, Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid, Pin, Maximize2,
Copy, Trash2,
} from "lucide-react" } from "lucide-react"
import { toast } from "sonner"
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 Job, type ImageRef,
@@ -39,6 +41,7 @@ export interface NodeData {
onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开 onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开
onDeleteFrame?: (idx: number) => void // 删整张关键帧 onDeleteFrame?: (idx: number) => void // 删整张关键帧
onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图 onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图
onDeleteVideo?: (videoId: string) => void // 删 Video Gen 任务
onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板 onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板
onOpenWorkbench?: (frameIdx?: number) => void // 展开顶部分镜编排内嵌面板 onOpenWorkbench?: (frameIdx?: number) => void // 展开顶部分镜编排内嵌面板
onCopyImage?: (ref: ImageRef) => void // 复制图片到全局剪贴板(粘贴到分镜头编排插槽) onCopyImage?: (ref: ImageRef) => void // 复制图片到全局剪贴板(粘贴到分镜头编排插槽)
@@ -982,6 +985,7 @@ export function VideoGenNode({ data, selected }: any) {
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
void navigator.clipboard?.writeText(v.prompt).catch(() => {}) void navigator.clipboard?.writeText(v.prompt).catch(() => {})
toast.success("已复制视频 prompt")
}} }}
title={`分镜 ${v.frame_idx + 1} · ${modelLabel(v.model)} · 点击复制 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"
@@ -1023,6 +1027,29 @@ export function VideoGenNode({ data, selected }: any) {
</div> </div>
</div> </div>
</button> </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 <div
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-150 z-[60]" className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-150 z-[60]"
style={{ style={{

View File

@@ -377,6 +377,15 @@ export async function generateStoryboardVideo(
return res.json() 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> { 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",