diff --git a/.memory/worklog.json b/.memory/worklog.json index 93a2eca..74f90cb 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1270,6 +1270,13 @@ "type": "session-heartbeat", "message": "Claude 会话活跃 · 最近命令:claude · 2 项未提交变更 · 最近提交:auto-save 2026-05-13 10:44 (~4)", "files_changed": 2 + }, + { + "ts": "2026-05-13T10:49:59+08:00", + "type": "commit", + "message": "auto-save 2026-05-13 10:49 (~2)", + "hash": "99bcb80", + "files_changed": 2 } ] } diff --git a/api/main.py b/api/main.py index 20db6d1..1ac1756 100644 --- a/api/main.py +++ b/api/main.py @@ -79,7 +79,8 @@ class KeyFrame(BaseModel): timestamp: float url: str description: dict | None = None # vision 模型识别结果 {scene, objects, style, suggested_prompt} - cleaned_url: str | None = None # 清洗后干净版 → /jobs/{id}/frames/{idx}/cleaned.jpg + cleaned_url: str | None = None # 清洗后干净版(待应用)→ /jobs/{id}/frames/{idx}/cleaned.jpg + cleaned_applied: bool = False # 是否已用清洗版替换原图(替换后 cleaned_url=null) elements: list[KeyElement] = [] # 提取的元素清单(持久化) generated_images: list[GeneratedImage] = [] @@ -978,10 +979,36 @@ def describe_frame(job_id: str, idx: int) -> Job: # ---------- 清洗水印 / 元素提取(关键帧二阶段加工) ---------- +class CleanupReq(BaseModel): + # 可选 region:相对坐标 0-1,限制清洗范围 + region: dict | None = None # {"x": float, "y": float, "w": float, "h": float} + + +def _region_to_phrase(r: dict) -> str: + """把相对坐标矩形转成方位描述给 prompt 用""" + 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)))) + if w <= 0 or h <= 0: + return "" + cx, cy = x + w / 2, y + h / 2 + hpos = "left" if cx < 0.4 else "right" if cx > 0.6 else "center" + vpos = "top" if cy < 0.4 else "bottom" if cy > 0.6 else "middle" + quadrant = f"{vpos}-{hpos}" if hpos != "center" else vpos + x_pct = (int(x * 100), int((x + w) * 100)) + y_pct = (int(y * 100), int((y + h) * 100)) + return ( + f"the {quadrant} area of the image " + f"(roughly horizontal {x_pct[0]}%-{x_pct[1]}%, vertical {y_pct[0]}%-{y_pct[1]}%)" + ) + + @app.post("/jobs/{job_id}/frames/{idx}/cleanup", response_model=Job) -def cleanup_frame(job_id: str, idx: int) -> Job: +def cleanup_frame(job_id: str, idx: int, req: CleanupReq | None = None) -> Job: """调 nano-banana image edit 清洗关键帧:去水印 / @用户名 / 字幕 / 平台 logo。 - 输出干净版到 jobs//cleaned/.jpg,写回 frame.cleaned_url。""" + 输出干净版到 jobs//cleaned/.jpg,写回 frame.cleaned_url。 + 可选 region: 限定只清洗框内区域。""" import time as _time job = JOBS.get(job_id) if not job: @@ -993,14 +1020,17 @@ def cleanup_frame(job_id: str, idx: int) -> Job: if not frame_path.exists(): raise HTTPException(404, "frame file missing") - prompt = ( - "Clean this image by removing all overlay graphics that obstruct the main content: " - "watermarks, social media usernames or @handles, platform logos (TikTok, Instagram, etc.), " - "subtitles, captions, overlay text, sticker text, hashtags. " - "Keep all original scene elements (characters, props, background, lighting) intact. " - "The result should look like the same photograph with overlay UI removed — " - "natural, seamless, no visible patches or artifacts." - ) + region_phrase = _region_to_phrase(req.region) if (req and req.region) else "" + if region_phrase: + prompt = ( + f"Remove text overlays only within {region_phrase}: watermarks, usernames, captions, hashtags, " + "platform logos. Keep every other part of the image exactly unchanged." + ) + else: + prompt = ( + "Remove all text overlays from this image: watermarks, usernames, captions, hashtags, " + "platform logos. Keep the rest of the scene intact and natural." + ) try: img_bytes, _mode = _image_edit_call(frame_path, prompt, fallback_text=False, max_attempts=3) except RuntimeError as e: @@ -1015,6 +1045,7 @@ def cleanup_frame(job_id: str, idx: int) -> Job: for f in job.frames: if f.index == idx: f.cleaned_url = f"/jobs/{job_id}/frames/{idx}/cleaned.jpg?t={int(_time.time())}" + f.cleaned_applied = False # 重新清洗:重置"已应用"状态 new_frames.append(f) update(job, frames=new_frames, message=f"清洗完成 · 分镜 {idx + 1}") return job @@ -1028,6 +1059,48 @@ def get_cleaned_frame(job_id: str, idx: int): return FileResponse(p, media_type="image/jpeg") +@app.post("/jobs/{job_id}/frames/{idx}/cleanup/apply", response_model=Job) +def apply_cleaned(job_id: str, idx: int) -> Job: + """用清洗版替换原关键帧:物理覆盖 frames/{idx}.jpg ← cleaned/{idx}.jpg。 + 原图作备份 → orig/{idx}.jpg(首次替换时备份,后续替换跳过)。 + 替换后 frame.cleaned_url 清空(不再有"待应用"清洗版)""" + import shutil as _shutil + 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") + cleaned_path = job_dir(job_id) / "cleaned" / f"{idx:03d}.jpg" + if not cleaned_path.exists(): + raise HTTPException(404, "no cleaned version to apply") + frame_path = job_dir(job_id) / "frames" / f"{idx:03d}.jpg" + + # 首次替换:把原图备份到 orig/{idx}.jpg + orig_dir = job_dir(job_id) / "orig" + orig_dir.mkdir(parents=True, exist_ok=True) + orig_backup = orig_dir / f"{idx:03d}.jpg" + if not orig_backup.exists() and frame_path.exists(): + _shutil.copy2(frame_path, orig_backup) + + # 用 cleaned 覆盖 frames/ + _shutil.copy2(cleaned_path, frame_path) + # 删 cleaned 文件(已经"应用",不再是单独的待选版本) + try: + cleaned_path.unlink() + except OSError: + pass + + new_frames = [] + for f in job.frames: + if f.index == idx: + f.cleaned_url = None + f.cleaned_applied = True + new_frames.append(f) + update(job, frames=new_frames, message=f"已替换分镜 {idx + 1} 为清洗版") + return job + + class AddElementReq(BaseModel): name_zh: str name_en: str = "" diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx index 54772f9..d7f97ab 100644 --- a/web/components/lightbox.tsx +++ b/web/components/lightbox.tsx @@ -1,10 +1,10 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import { createPortal } from "react-dom" -import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle } from "lucide-react" +import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle, Crop } from "lucide-react" import { frameUrl, cleanedFrameUrl, cutoutUrl, - describeFrame, cleanupFrame, addElement, deleteElement, cutoutElement, + describeFrame, cleanupFrame, applyCleanedFrame, addElement, deleteElement, cutoutElement, type KeyFrame, type Job, } from "@/lib/api" import { toast } from "sonner" @@ -25,13 +25,25 @@ interface Props { export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, embedded = false }: Props) { const [describing, setDescribing] = useState(false) const [cleaning, setCleaning] = useState(false) + const [applying, setApplying] = useState(false) const [cuttingId, setCuttingId] = useState(null) const [addingZh, setAddingZh] = useState(false) - const [viewCleaned, setViewCleaned] = useState(true) // 默认显示干净版(若有) const [addInput, setAddInput] = useState("") const [mounted, setMounted] = useState(false) + // 画框模式 + 选区(相对坐标 0-1) + const [cropMode, setCropMode] = useState(false) + const [region, setRegion] = useState<{ x: number; y: number; w: number; h: number } | null>(null) + const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null) + const imgWrapRef = useRef(null) useEffect(() => setMounted(true), []) + // 切换分镜时清空选区 + useEffect(() => { + setCropMode(false) + setRegion(null) + setDragStart(null) + }, [activeIndex]) + useEffect(() => { if (activeIndex === null) return const onKey = (e: KeyboardEvent) => { @@ -59,7 +71,6 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o const desc = f.description const elements = f.elements ?? [] const hasCleaned = !!f.cleaned_url - const showCleaned = hasCleaned && viewCleaned const handleDescribe = async () => { setDescribing(true) @@ -79,8 +90,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o try { const updated = await cleanupFrame(jobId, f.index) onJobUpdate?.(updated) - setViewCleaned(true) - toast.success(`分镜 ${f.index + 1} 清洗完成`) + toast.success(`分镜 ${f.index + 1} 清洗完成 · 下方查看`) } catch (e) { toast.error("清洗失败:" + (e instanceof Error ? e.message : String(e))) } finally { @@ -88,6 +98,19 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o } } + const handleApplyCleaned = async () => { + setApplying(true) + try { + const updated = await applyCleanedFrame(jobId, f.index) + onJobUpdate?.(updated) + toast.success(`分镜 ${f.index + 1} 已替换为清洗版`) + } catch (e) { + toast.error("替换失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setApplying(false) + } + } + const handleAddElement = async (name_zh: string, name_en?: string, position?: string, source: "auto" | "manual" = "manual") => { const zh = name_zh.trim() if (!zh) return @@ -131,6 +154,8 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o const ts = f.cleaned_url.match(/t=(\d+)/)?.[1] return cleanedFrameUrl(jobId, f.index, ts) })() + // bust cache:替换后 frames/{idx}.jpg 内容已变,要刷新 + const mainSrc = `${frameUrl(jobId, f.index)}${f.cleaned_applied ? "?applied=1" : ""}` const content = (
- {/* 左侧大图 */} + {/* 左侧大图区 */}
+ {/* 上方:主图(已应用清洗 → 显示 "已替换"角标;否则显示原图) */}
{`frame - {/* 显示版本切换 + 状态标 */} - {hasCleaned && ( -
- - -
- )} +
+ {f.cleaned_applied ? "✨ 已替换为清洗版" : "原图"} +
+ {/* 下方:清洗版(有待应用版本时显示) */} + {hasCleaned && cleanedSrc && ( +
+
+
+ + 清洗结果 +
+ 待应用 +
+ {`cleaned + +
+ )} + {/* 清洗按钮 */}