diff --git a/.memory/worklog.json b/.memory/worklog.json
index d1bb45e..1ce08fc 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -2966,6 +2966,19 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 02:47 (~1)",
"files_changed": 3
+ },
+ {
+ "ts": "2026-05-14T02:53:06+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-14 02:52 (~4)",
+ "hash": "3ab9da0",
+ "files_changed": 4
+ },
+ {
+ "ts": "2026-05-13T18:53:11Z",
+ "type": "session-heartbeat",
+ "message": "Claude 会话活跃 · 最近命令:claude · 2 项未提交变更 · 最近提交:auto-save 2026-05-14 02:52 (~4)",
+ "files_changed": 2
}
]
}
diff --git a/api/main.py b/api/main.py
index d6e0451..230ae5f 100644
--- a/api/main.py
+++ b/api/main.py
@@ -989,6 +989,19 @@ def get_job(job_id: str) -> Job:
return job
+@app.delete("/jobs/{job_id}")
+def delete_job(job_id: str) -> dict[str, bool | str]:
+ d = (JOBS_DIR / job_id).resolve()
+ if JOBS_DIR not in d.parents:
+ raise HTTPException(400, "invalid job id")
+ job = JOBS.pop(job_id, None)
+ if not job and not d.exists():
+ raise HTTPException(404, "job not found")
+ if d.exists():
+ shutil.rmtree(d)
+ return {"ok": True, "id": job_id}
+
+
@app.post("/jobs/{job_id}/transcribe", response_model=Job)
async def trigger_transcribe(job_id: str, bg: BackgroundTasks) -> Job:
job = JOBS.get(job_id)
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 645b04e..37c7a00 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -699,6 +699,7 @@ api/main.py
| 历史列表 | GET /jobs | listJobs | 所有 job 精简列表(id/url/status/thumbnail/mtime…),按 state.json mtime 倒序。前端 URL 无 ?job= 时拉它回填全部历史;带 limit 可截断。 |
| 创建任务 | POST /jobs | createJob | 提交 TK 链接,后台开始下载,停在 downloaded 等用户点解析。 |
| 上传视频 | POST /jobs/upload | uploadJob | 保存 source.mp4,然后同样进入下载完成状态。 |
+ | 删除输入视频 | DELETE /jobs/{id} | deleteJob | 从任务队列、URL 和磁盘 jobs/<id> 目录移除整个 job,包括源视频、关键帧、元素提取图和生成视频。 |
| 解析视频 | POST /jobs/{id}/analyze | analyzeJob | 拆轨 + 抽关键帧。当前不自动跑 ASR,避免 audio 阻塞视觉管线。 |
| 手动加帧 | POST /jobs/{id}/frames?t= | addManualFrame | 按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。 |
| Vision 识别 | POST /frames/{idx}/describe | describeFrame | 写入 frame.description,后续可从 objects 加候选元素。 |
@@ -815,6 +816,18 @@ api/main.py
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-14 · 输入视频缩略图支持删除整个 job
+ Input
+ API
+
+
+
问题:画布顶部输入视频缩略图只有切换和预览,没有删除入口;用户清理视频队列时只能删关键帧或生成视频任务,不能删除整个输入视频。
+
改动:新增 DELETE /jobs/{id},前端新增 deleteJob 和 onDeleteJob;Input 缩略图右上角常驻删除按钮,确认后移除该 job、清理 URL 参数,并删除本地 jobs/<id> 目录。
+
影响:api/main.py、web/lib/api.ts、web/app/page.tsx、web/components/nodes/index.tsx、docs/source-analysis.html。
+
+
2026-05-14 · 大图预览改为尺寸感知 Fit / 1:1
diff --git a/web/app/page.tsx b/web/app/page.tsx
index e24e647..ad2da43 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -15,7 +15,7 @@ import {
} from "@/components/nodes"
import { ThemeToggle } from "@/components/theme-toggle"
import {
- addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteFrame, deleteGeneratedImage,
+ addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo,
type Job, type ImageRef, type StoryboardScene,
} from "@/lib/api"
@@ -233,6 +233,29 @@ export default function Home() {
}
}, [activeJobId, setJob])
+ const handleDeleteJob = useCallback(async (jobId: string) => {
+ try {
+ await deleteJob(jobId)
+ setJobs((prev) => {
+ const idx = prev.findIndex((x) => x.id === jobId)
+ const next = prev.filter((x) => x.id !== jobId)
+ if (activeJobId === jobId) {
+ const fallback = next[idx] ?? next[idx - 1] ?? next[next.length - 1] ?? null
+ setActiveJobId(fallback?.id ?? null)
+ setSelectedFrames(new Set())
+ setExpandedFrame(null)
+ setVideoLightboxOpen(false)
+ setStoryboardFrame(null)
+ setWorkbenchOpen(false)
+ }
+ return next
+ })
+ toast.success("输入视频已删除")
+ } catch (e) {
+ toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
+ }
+ }, [activeJobId])
+
const handleDeleteCutout = useCallback(async (frameIdx: number, elementId: string, cutoutId: string) => {
if (!activeJobId) return
try {
@@ -372,9 +395,12 @@ export default function Home() {
// 写回 URL(所有 jobs id 用 , 分隔)
useEffect(() => {
- if (jobs.length === 0) return
const url = new URL(window.location.href)
- url.searchParams.set("job", jobs.map((j) => j.id).join(","))
+ if (jobs.length > 0) {
+ url.searchParams.set("job", jobs.map((j) => j.id).join(","))
+ } else {
+ url.searchParams.delete("job")
+ }
window.history.replaceState({}, "", url.toString())
}, [jobs.length])
@@ -454,6 +480,7 @@ export default function Home() {
onOpenVideoLightbox: () => setVideoLightboxOpen(true),
onSwitchJob: handleSwitchJob,
onJobUpdate: setJob as any,
+ onDeleteJob: handleDeleteJob,
onDeleteFrame: handleDeleteFrame,
onDeleteGenerated: handleDeleteGenerated,
onDeleteVideo: handleDeleteVideo,
@@ -466,7 +493,7 @@ export default function Home() {
onCopyImage: handleCopyImage,
pinnedNodes,
onToggleNodePin: handleToggleNodePin,
- }), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin])
+ }), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
const savedSizes = useMemo(() => loadNodeSizes(), [])
diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx
index f4be8c9..caec67a 100644
--- a/web/components/nodes/index.tsx
+++ b/web/components/nodes/index.tsx
@@ -44,6 +44,7 @@ export interface NodeData {
onOpenVideoLightbox: () => void
onSwitchJob: (id: string) => void
onJobUpdate: (j: Job) => void
+ onDeleteJob?: (id: string) => void
onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开
onDeleteFrame?: (idx: number) => void // 删整张关键帧
onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图
@@ -411,6 +412,21 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
{ready ? `${j.duration.toFixed(1)}s` : "…"}
+ {d.onDeleteJob && (
+
+ )}
)
})}
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 8c9b2b8..0ef54af 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -220,6 +220,15 @@ export async function getJob(id: string): Promise {
return res.json()
}
+export async function deleteJob(id: string): Promise<{ ok: boolean; id: string }> {
+ const res = await fetch(`${API_BASE}/jobs/${id}`, { method: "DELETE" })
+ if (!res.ok) {
+ const txt = await res.text().catch(() => "")
+ throw new Error(`deleteJob ${res.status} ${txt.slice(0, 200)}`)
+ }
+ return res.json()
+}
+
export interface JobSummary {
id: string
url: string