From e45ac3fea99385f8ea31625ad910e7f06923e4c0 Mon Sep 17 00:00:00 2001 From: kang Date: Wed, 13 May 2026 13:08:37 +0800 Subject: [PATCH] auto-save 2026-05-13 13:08 (~5) --- .memory/worklog.json | 13 +++ api/main.py | 66 ++++++-------- web/components/lightbox.tsx | 70 ++++++--------- web/components/nodes/index.tsx | 153 +++++++++++++-------------------- web/lib/api.ts | 9 +- 5 files changed, 127 insertions(+), 184 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index 06bf826..fa6a35a 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1516,6 +1516,19 @@ "type": "session-heartbeat", "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 12:57 (~2)", "files_changed": 1 + }, + { + "ts": "2026-05-13T13:03:04+08:00", + "type": "commit", + "message": "auto-save 2026-05-13 13:02 (~1)", + "hash": "b6c9e0c", + "files_changed": 1 + }, + { + "ts": "2026-05-13T05:07:38Z", + "type": "session-heartbeat", + "message": "Claude 会话活跃 · 最近命令:claude · 5 项未提交变更 · 最近提交:auto-save 2026-05-13 13:02 (~1)", + "files_changed": 5 } ] } diff --git a/api/main.py b/api/main.py index 1710c24..4e46914 100644 --- a/api/main.py +++ b/api/main.py @@ -1261,14 +1261,11 @@ def delete_element(job_id: str, idx: int, element_id: str) -> Job: return job -class CutoutReq(BaseModel): - background: Literal["white", "black"] = "white" - - @app.post("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutout", response_model=Job) -def cutout_element(job_id: str, idx: int, element_id: str, req: CutoutReq | None = None) -> Job: - """单元素抠图:调 nano-banana image edit,输出纯白底 / 纯黑底元素图。 - 注:Gemini 输出 JPEG 不支持真 alpha,因此用纯色背景。""" +def cutout_element(job_id: str, idx: int, element_id: str) -> Job: + """元素裁切:直接 PIL crop 原帧 region 区域(不调模型,原汁原味保留表情/形体)。 + 只支持 region 模式元素;vision auto / 纯手动文字元素没有坐标,无法裁切。""" + from PIL import Image as _PILImage job = JOBS.get(job_id) if not job: raise HTTPException(404, "job not found") @@ -1278,48 +1275,38 @@ def cutout_element(job_id: str, idx: int, element_id: str, req: CutoutReq | None el = next((e for e in frame.elements if e.id == element_id), None) if not el: raise HTTPException(404, "element not found") + if not el.region: + raise HTTPException(400, "该元素没有坐标,无法裁切(仅画框元素支持)") - # 优先用 cleaned 版作 reference(已去掉 logo / 水印干扰),fallback 原图 + # 优先用 cleaned 版作 source cleaned_path = job_dir(job_id) / "cleaned" / f"{idx:03d}.jpg" src = cleaned_path if cleaned_path.exists() else job_dir(job_id) / "frames" / f"{idx:03d}.jpg" if not src.exists(): raise HTTPException(404, "source frame file missing") - background = (req.background if req else "white") or "white" - bg_phrase = f"pure {background}" - - target = (el.name_en or el.name_zh).strip() - region_phrase = _region_to_phrase(el.region) if el.region else "" - if region_phrase: - prompt = ( - f"Extract whatever is in the {region_phrase} part of the image as a standalone asset. " - f"Place it on a {bg_phrase} background, isolated, no other objects." - ) - else: - position_hint = f" Located in the {el.position} area." if el.position else "" - prompt = ( - f"Extract the {target} from this image as a standalone asset.{position_hint} " - f"Place it on a {bg_phrase} background, isolated, no other objects." - ) - # 模型轮换:nano-banana-pro (首选) → gemini-2.5-flash-image (兜底确认可用) - # gemini-3.1-flash-image-preview 不支持 i2i (404),剔除 - models = [ - IMAGE_MODEL, - "gemini-2.5-flash-image", - ] try: - img_bytes, _mode = _image_edit_call( - src, prompt, models=models, fallback_text=False, max_attempts=3, - ) - except RuntimeError as e: - raise HTTPException(500, f"cutout failed: {e}") + im = _PILImage.open(src).convert("RGB") + W, H = im.size + r = el.region + x = max(0.0, min(1.0, float(r.get("x", 0)))) + y = max(0.0, min(1.0, float(r.get("y", 0)))) + w = max(0.0, min(1.0 - x, float(r.get("w", 0)))) + h = max(0.0, min(1.0 - y, float(r.get("h", 0)))) + left, top = int(x * W), int(y * H) + right, bottom = int((x + w) * W), int((y + h) * H) + if right - left < 4 or bottom - top < 4: + raise HTTPException(400, "region 太小,无法裁切") + cropped = im.crop((left, top, right, bottom)) + except HTTPException: + raise + except Exception as e: + raise HTTPException(500, f"crop failed: {e}") out_dir = job_dir(job_id) / "elements" out_dir.mkdir(parents=True, exist_ok=True) - # 实际是 JPEG bytes,文件用 .jpg 真名 out_path = out_dir / f"{idx:03d}_{element_id}.jpg" - out_path.write_bytes(img_bytes) - # 旧版的 .png 文件(错命名为 .png 的 JPEG)也清理掉 + cropped.save(out_path, format="JPEG", quality=92) + # 清理旧版的 .png(旧版"抠图"产物) old_png = out_dir / f"{idx:03d}_{element_id}.png" if old_png.exists(): try: old_png.unlink() @@ -1331,9 +1318,8 @@ def cutout_element(job_id: str, idx: int, element_id: str, req: CutoutReq | None for e in f.elements: if e.id == element_id: e.cutout_id = element_id - e.cutout_background = background new_frames.append(f) - update(job, frames=new_frames, message=f"抠图完成 · {el.name_zh}({background} 底)") + update(job, frames=new_frames, message=f"裁切完成 · {el.name_zh}") return job diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx index 3b4e9a3..e150e30 100644 --- a/web/components/lightbox.tsx +++ b/web/components/lightbox.tsx @@ -220,14 +220,14 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o } } - const handleCutout = async (id: string, background: "white" | "black" = "white") => { + const handleCutout = async (id: string) => { setCuttingId(id) try { - const updated = await cutoutElement(jobId, f.index, id, background) + const updated = await cutoutElement(jobId, f.index, id) onJobUpdate?.(updated) - toast.success(`抠图完成(${background === "white" ? "白底" : "黑底"})`) + toast.success("裁切完成") } catch (e) { - toast.error("抠图失败:" + (e instanceof Error ? e.message : String(e))) + toast.error("裁切失败:" + (e instanceof Error ? e.message : String(e))) } finally { setCuttingId(null) } @@ -609,29 +609,29 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o {elements.map((e) => { const isCutting = cuttingId === e.id const hasCutout = !!e.cutout_id - const bg = e.cutout_background ?? "white" + const hasRegion = !!e.region return (
- {/* 抠图缩略图 / 占位(真实底色:white/black) */} + {/* 裁切缩略图 / 占位 */}
{hasCutout ? ( {e.name_zh} ) : ( @@ -645,10 +645,8 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o {e.source === "auto" && ( auto )} - {hasCutout && ( - - {bg} - + {e.source === "region" && ( + box )}
@@ -656,38 +654,24 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
- {/* 底色 toggle(小) — 选 white/black 后点抠图 */} -
+ {/* 裁切按钮 — 只在有 region 时可用 */} + {hasRegion ? ( - -
- - {/* 抠图状态指示(不再是按钮,因为 W/B toggle 即触发) */} - {isCutting ? ( - - 抠图中 - - ) : !hasCutout ? ( - 未抠 - ) : null} + > + {isCutting ? : } + {isCutting ? "裁切中" : hasCutout ? "重裁" : "裁切"} + + ) : ( + 仅文字 + )} {/* 删除 */} - {/* 删除按钮:hover 时右上角浮出 */} - {d.onDeleteGenerated && ( - - )} ))} @@ -698,23 +665,23 @@ export function ImageGenNode({ data, selected }: any) { type="ai" status={status} icon={} title="分镜头编排 · Storyboard" - subtitle={`STEP 6 · 接元素 + 场景 ${totalGens > 0 ? `· ${totalGens} 张` : ""}`} + subtitle={`STEP 6 · 接元素 + 场景${totalElements > 0 ? ` · ${totalElements} 个元素` : ""}`} width={IMAGEGEN_WIDTH} selected={selected} > - {totalGens > 0 ? ( + {totalElements > 0 ? (
- 已生成 {totalGens} 张 · 选用 {selectedCount}/{previews.length} + 素材:{totalElements} 个元素 + 干净版场景
- 上方缩略图点击展开编排 · 多视角 / 风格融合 / 布局调整在此完成 + 上方缩略图点击进入编排 · 多视角 / 风格融合 / 布局在此完成(Phase 2)
) : (
编排素材待接入
- 关键帧节点提取的元素 + 干净版场景图 → 这里做多视角 / 风格融合 / 分镜布局 + 到关键帧节点画框 → 裁切元素 → 这里聚合所有素材做分镜头编排
)} diff --git a/web/lib/api.ts b/web/lib/api.ts index d9db5be..89b1bb5 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -301,16 +301,9 @@ export async function deleteGeneratedImage(jobId: string, frameIdx: number, genI return res.json() } -export async function cutoutElement( - jobId: string, - frameIdx: number, - elementId: string, - background: "white" | "black" = "white", -): Promise { +export async function cutoutElement(jobId: string, frameIdx: number, elementId: string): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutout`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ background }), }) if (!res.ok) { const txt = await res.text().catch(() => "")