auto-save 2026-05-13 10:44 (~4)
This commit is contained in:
@@ -1250,6 +1250,13 @@
|
||||
"type": "session-heartbeat",
|
||||
"message": "Claude 会话活跃 · 最近命令:claude · 5 项未提交变更 · 最近提交:auto-save 2026-05-13 10:33 (~2)",
|
||||
"files_changed": 5
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T10:38:52+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-13 10:38 (~5)",
|
||||
"hash": "98d4ecb",
|
||||
"files_changed": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
69
api/main.py
69
api/main.py
@@ -1172,3 +1172,72 @@ def get_cutout(job_id: str, idx: int, element_id: str):
|
||||
if not p.exists():
|
||||
raise HTTPException(404, "cutout not found")
|
||||
return FileResponse(p, media_type="image/png")
|
||||
|
||||
|
||||
# ---------- 删除:关键帧 / 单张生成图 ----------
|
||||
|
||||
@app.delete("/jobs/{job_id}/frames/{idx}", response_model=Job)
|
||||
def delete_frame(job_id: str, idx: int) -> Job:
|
||||
"""删除整张关键帧,清理所有附属文件(原图 / 干净版 / 元素抠图 / 生成图)"""
|
||||
job = JOBS.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "job not found")
|
||||
target = next((f for f in job.frames if f.index == idx), None)
|
||||
if not target:
|
||||
raise HTTPException(404, "frame not found")
|
||||
|
||||
d = job_dir(job_id)
|
||||
# 删文件 — 静默错误,文件可能不存在
|
||||
paths = [
|
||||
d / "frames" / f"{idx:03d}.jpg",
|
||||
d / "cleaned" / f"{idx:03d}.jpg",
|
||||
]
|
||||
for p in paths:
|
||||
if p.exists():
|
||||
try: p.unlink()
|
||||
except OSError: pass
|
||||
# 该帧的所有元素抠图(命名前缀 {idx:03d}_)
|
||||
elements_dir = d / "elements"
|
||||
if elements_dir.exists():
|
||||
for p in elements_dir.glob(f"{idx:03d}_*.png"):
|
||||
try: p.unlink()
|
||||
except OSError: pass
|
||||
# 该帧的所有生成图
|
||||
gen_dir = d / "gen"
|
||||
if gen_dir.exists():
|
||||
for p in gen_dir.glob(f"{idx:03d}_*.jpg"):
|
||||
try: p.unlink()
|
||||
except OSError: pass
|
||||
|
||||
new_frames = [f for f in job.frames if f.index != idx]
|
||||
update(job, frames=new_frames, message=f"删除分镜 {idx + 1}")
|
||||
return job
|
||||
|
||||
|
||||
@app.delete("/jobs/{job_id}/frames/{idx}/gen/{gen_id}", response_model=Job)
|
||||
def delete_generated(job_id: str, idx: int, gen_id: str) -> Job:
|
||||
"""删除该 frame 的某张生成图(文件 + 列表)"""
|
||||
job = JOBS.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "job not found")
|
||||
frame = next((f for f in job.frames if f.index == idx), None)
|
||||
if not frame:
|
||||
raise HTTPException(404, "frame not found")
|
||||
|
||||
p = job_dir(job_id) / "gen" / f"{idx:03d}_{gen_id}.jpg"
|
||||
if p.exists():
|
||||
try: p.unlink()
|
||||
except OSError: pass
|
||||
|
||||
new_frames = []
|
||||
found = False
|
||||
for f in job.frames:
|
||||
if f.index == idx:
|
||||
before = len(f.generated_images)
|
||||
f.generated_images = [g for g in f.generated_images if g.id != gen_id]
|
||||
found = len(f.generated_images) < before
|
||||
new_frames.append(f)
|
||||
if not found:
|
||||
raise HTTPException(404, "generated image not found")
|
||||
update(job, frames=new_frames, message=f"删除生成图 · 分镜 {idx + 1}")
|
||||
return job
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRef, useState } from "react"
|
||||
import { type NodeProps } from "@xyflow/react"
|
||||
import {
|
||||
Link2, Upload, Download, Scissors, Image as ImageIcon,
|
||||
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus,
|
||||
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus, X,
|
||||
} from "lucide-react"
|
||||
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
||||
import { type Job, frameUrl, videoUrl, generatedImageUrl } from "@/lib/api"
|
||||
@@ -27,6 +27,8 @@ export interface NodeData {
|
||||
onSwitchJob: (id: string) => void
|
||||
onJobUpdate: (j: Job) => void
|
||||
onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开
|
||||
onDeleteFrame?: (idx: number) => void // 删整张关键帧
|
||||
onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图
|
||||
}
|
||||
|
||||
/* ---- 状态映射工具 ---- */
|
||||
@@ -354,10 +356,8 @@ export function KeyframeNode({ data, selected }: any) {
|
||||
{frames.map((f) => {
|
||||
const isSel = d.selectedFrames.has(f.index)
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={f.index}
|
||||
onClick={(e) => { e.stopPropagation(); d.onExpandFrame(f.index) }}
|
||||
title={`第 ${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · 点击放大`}
|
||||
className={`group relative rounded-md border transition shadow-lg hover:-translate-y-0.5 ${
|
||||
isSel
|
||||
? "border-emerald-400 ring-2 ring-emerald-400/60"
|
||||
@@ -368,6 +368,11 @@ export function KeyframeNode({ data, selected }: any) {
|
||||
? `${d.job.width}/${d.job.height}`
|
||||
: "16/9",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); d.onExpandFrame(f.index) }}
|
||||
title={`第 ${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · 点击放大`}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={frameUrl(jobId, f.index)}
|
||||
@@ -428,6 +433,22 @@ export function KeyframeNode({ data, selected }: any) {
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/* 删除按钮:hover 时右上角浮出 */}
|
||||
{d.onDeleteFrame && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`删除分镜 ${f.index + 1}(${f.timestamp.toFixed(1)}s)?相关清洗 / 抠图 / 生成图都会一并清除。`)) {
|
||||
d.onDeleteFrame?.(f.index)
|
||||
}
|
||||
}}
|
||||
title="删除该关键帧"
|
||||
className="absolute top-1 right-1 h-5 w-5 rounded-full bg-black/70 backdrop-blur text-white/80 hover:bg-rose-500 hover:text-white inline-flex items-center justify-center opacity-0 group-hover:opacity-100 transition z-[70]"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -595,16 +616,19 @@ export function ImageGenNode({ data, selected }: any) {
|
||||
style={{ bottom: "calc(100% + 12px)" }}
|
||||
>
|
||||
{previews.map((p) => (
|
||||
<button
|
||||
<div
|
||||
key={p.frameIdx}
|
||||
onClick={(e) => { e.stopPropagation(); d.onOpenPanel?.("imagegen") }}
|
||||
title={`分镜 ${p.frameIdx + 1} · ${p.total} 张${p.hasSelected ? " · 已选用" : ""} · 打开「生图」面板`}
|
||||
className={`group relative rounded-md border transition shadow-lg hover:-translate-y-0.5 ${
|
||||
p.hasSelected
|
||||
? "border-emerald-400 ring-2 ring-emerald-400/60"
|
||||
: "border-pink-300/40 dark:border-pink-300/30"
|
||||
}`}
|
||||
style={{ aspectRatio: aspect }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); d.onOpenPanel?.("imagegen") }}
|
||||
title={`分镜 ${p.frameIdx + 1} · ${p.total} 张${p.hasSelected ? " · 已选用" : ""} · 打开「生图」面板`}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={generatedImageUrl(job.id, p.frameIdx, p.gen.id)}
|
||||
@@ -650,6 +674,22 @@ export function ImageGenNode({ data, selected }: any) {
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/* 删除按钮:hover 时右上角浮出 */}
|
||||
{d.onDeleteGenerated && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`删除分镜 ${p.frameIdx + 1} 的这张生成图?`)) {
|
||||
d.onDeleteGenerated?.(p.frameIdx, p.gen.id)
|
||||
}
|
||||
}}
|
||||
title="删除该生成图"
|
||||
className="absolute top-1 right-1 h-5 w-5 rounded-full bg-black/70 backdrop-blur text-white/80 hover:bg-rose-500 hover:text-white inline-flex items-center justify-center opacity-0 group-hover:opacity-100 transition z-[70]"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -242,6 +242,24 @@ export async function deleteElement(jobId: string, frameIdx: number, elementId:
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deleteFrame(jobId: string, frameIdx: number): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}`, { method: "DELETE" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`deleteFrame ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deleteGeneratedImage(jobId: string, frameIdx: number, genId: string): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/gen/${genId}`, { method: "DELETE" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`deleteGen ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function cutoutElement(jobId: string, frameIdx: number, elementId: string): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutout`, { method: "POST" })
|
||||
if (!res.ok) {
|
||||
|
||||
Reference in New Issue
Block a user