diff --git a/.memory/worklog.json b/.memory/worklog.json index 24adfe4..e6c985a 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -3134,6 +3134,19 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 03:53 (~5)", "files_changed": 3 + }, + { + "ts": "2026-05-14T03:59:22+08:00", + "type": "commit", + "message": "auto-save 2026-05-14 03:59 (~3)", + "hash": "b95706a", + "files_changed": 3 + }, + { + "ts": "2026-05-13T20:03:12Z", + "type": "session-heartbeat", + "message": "Claude 会话活跃 · 最近命令:claude · 4 项未提交变更 · 最近提交:auto-save 2026-05-14 03:59 (~3)", + "files_changed": 4 } ] } diff --git a/api/main.py b/api/main.py index 5ce7858..1d81476 100644 --- a/api/main.py +++ b/api/main.py @@ -89,6 +89,7 @@ JobStatus = Literal[ KEYFRAME_COUNT = int(os.getenv("KEYFRAME_COUNT", "5")) FrameExtractTarget = Literal["balanced", "subject", "transition", "expression", "motion"] +FrameExtractMode = Literal["replace", "append"] FRAME_TARGET_LABELS: dict[FrameExtractTarget, str] = { "balanced": "综合关键帧", "subject": "清晰主体", @@ -582,6 +583,7 @@ async def pipeline_analyze( job_id: str, frame_count: int = KEYFRAME_COUNT, target: FrameExtractTarget = "balanced", + mode: FrameExtractMode = "replace", ) -> None: """阶段 2:拆音轨 + 抽关键帧。ASR/翻译是独立文案轨,不阻塞视觉素材流。""" job = JOBS[job_id] @@ -591,13 +593,16 @@ async def pipeline_analyze( if not mp4.exists(): raise RuntimeError("source.mp4 不存在,先完成下载") - update(job, status="splitting", message="ffmpeg 拆分音轨…", progress=35) wav = d / "audio.wav" - run([ - "ffmpeg", "-y", "-i", str(mp4), - "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", - str(wav), - ]) + if wav.exists(): + update(job, status="splitting", message="复用音轨 · 准备抽帧…", progress=35) + else: + update(job, status="splitting", message="ffmpeg 拆分音轨…", progress=35) + run([ + "ffmpeg", "-y", "-i", str(mp4), + "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", + str(wav), + ]) n = max(1, min(int(frame_count), 20)) target_label = FRAME_TARGET_LABELS.get(target, FRAME_TARGET_LABELS["balanced"]) @@ -607,7 +612,9 @@ async def pipeline_analyze( update(job, message=f"低清扫描候选 · {target_label} · 约 {estimated_scan_count} 帧…", progress=45) frames_dir = d / "frames" - if frames_dir.exists(): + replacing = mode == "replace" + existing_frames = list(job.frames) if not replacing else [] + if replacing and frames_dir.exists(): shutil.rmtree(frames_dir) frames_dir.mkdir(parents=True) scan_dir = d / "frame_scan" @@ -637,15 +644,23 @@ async def pipeline_analyze( raise RuntimeError("候选帧评分失败") # 2) 目标化筛选:pHash 去重 + 清晰度 / 中心细节 / 转场变化 / 动作强度 + 时序分桶。 + selection_count = n if replacing else min(len(candidates), max(n * 4, n + len(existing_frames) + 2)) update(job, message=f"{target_label}筛选 {n} / {len(candidates)} 张…", progress=60) - chosen = _select_keyframes(candidates, n, target) + chosen = _select_keyframes(candidates, selection_count, target) # 3) 只对最终选中的时间点,从原视频抽高质量关键帧。 renamed: list[KeyFrame] = [] chosen_sorted = sorted(chosen, key=lambda it: float(it["timestamp"])) - for i, item in enumerate(chosen_sorted): - dst = frames_dir / f"{i:03d}.jpg" + existing_timestamps = [float(f.timestamp) for f in existing_frames] + next_idx = max((int(f.index) for f in existing_frames), default=-1) + 1 + for item in chosen_sorted: + if len(renamed) >= n: + break t = float(item["timestamp"]) + if not replacing and any(abs(t - old) < 0.35 for old in existing_timestamps): + continue + idx = next_idx + len(renamed) + dst = frames_dir / f"{idx:03d}.jpg" run([ "ffmpeg", "-y", "-ss", f"{t:.3f}", "-i", str(mp4), "-frames:v", "1", @@ -653,20 +668,24 @@ async def pipeline_analyze( str(dst), ]) renamed.append(KeyFrame( - index=i, + index=idx, timestamp=round(t, 2), - url=f"/jobs/{job_id}/frames/{i}.jpg", + url=f"/jobs/{job_id}/frames/{idx}.jpg", )) + existing_timestamps.append(t) # 4) 清理扫描目录 shutil.rmtree(scan_dir, ignore_errors=True) + merged_frames = sorted(existing_frames + renamed, key=lambda f: f.timestamp) + action_label = "追加" if not replacing else "抽取" + update( job, status="frames_extracted", - frames=renamed, + frames=merged_frames, progress=70, - message=f"已按「{target_label}」抽取 {len(renamed)} 张关键帧 · 可继续清洗 / 提取元素 / 分镜编排", + message=f"已按「{target_label}」{action_label} {len(renamed)} 张关键帧 · 共 {len(merged_frames)} 张", ) except Exception as e: @@ -1040,13 +1059,14 @@ async def trigger_analyze( bg: BackgroundTasks, frames: int = KEYFRAME_COUNT, target: FrameExtractTarget = "balanced", + mode: FrameExtractMode = "replace", ) -> Job: job = JOBS.get(job_id) if not job: raise HTTPException(404, "job not found") if job.status not in {"downloaded", "frames_extracted", "transcribed", "failed"}: raise HTTPException(409, f"status must be downloaded/failed, got {job.status}") - bg.add_task(pipeline_analyze, job_id, frames, target) + bg.add_task(pipeline_analyze, job_id, frames, target, mode) return job diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 990017c..a7e28c5 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -701,7 +701,7 @@ api/main.py 创建任务POST /jobscreateJob提交 TK 链接,后台开始下载,停在 downloaded 等用户点解析。 上传视频POST /jobs/uploaduploadJob保存 source.mp4,然后同样进入下载完成状态。 删除输入视频DELETE /jobs/{id}deleteJob从任务队列、URL 和磁盘 jobs/<id> 目录移除整个 job,包括源视频、关键帧、元素提取图和生成视频。 - 解析视频POST /jobs/{id}/analyze?frames=&target=analyzeJob拆轨 + 目标化抽关键帧。target 支持综合、清晰主体、转场变化、表情瞬间、动作峰值;当前不自动跑 ASR,避免 audio 阻塞视觉管线。 + 解析视频POST /jobs/{id}/analyze?frames=&target=&mode=analyzeJob拆轨 + 目标化抽关键帧。target 支持综合、清晰主体、转场变化、表情瞬间、动作峰值;mode=append 追加新关键帧,mode=replace 重抽覆盖。 手动加帧POST /jobs/{id}/frames?t=addManualFrame按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。 Vision 识别POST /frames/{idx}/describedescribeFrame写入 frame.description,后续可从 objects 加候选元素。 清洗水印POST /frames/{idx}/cleanupcleanupFrame支持全图和区域清洗,生成 cleaned 待应用版本。 @@ -723,7 +723,7 @@ api/main.py 输入 Input - 创建/上传任务,显示视频就绪,选择抽帧目标并触发解析;单击视频缩略图打开画布内抽帧面板。 + 创建/上传任务,显示视频就绪;视频缩略图上方提供自动抽帧快捷工具条,可快速选目标、张数并多次追加;单击视频缩略图打开画布内抽帧面板。 不要自动一路跑到 ASR 或生图;用户需要控制解析节奏。 page.tsxInputNodeVideoFramePanelNodeapi/main.py diff --git a/web/app/page.tsx b/web/app/page.tsx index 590f54d..d21beab 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -19,7 +19,7 @@ import { ThemeToggle } from "@/components/theme-toggle" import { addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage, deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, - type Job, type ImageRef, type StoryboardScene, type FrameExtractTarget, + type Job, type ImageRef, type StoryboardScene, type FrameExtractMode, type FrameExtractTarget, } from "@/lib/api" const NODE_TYPES = { @@ -93,6 +93,7 @@ export default function Home() { const [submitting, setSubmitting] = useState(false) const [analyzing, setAnalyzing] = useState(false) const [frameTarget, setFrameTarget] = useState("balanced") + const [frameCount, setFrameCount] = useState(5) const [selectedFrames, setSelectedFrames] = useState>(new Set()) const [expandedFrame, setExpandedFrame] = useState(null) const [framePanelScale, setFramePanelScale] = useState(1) @@ -166,21 +167,27 @@ export default function Home() { } }, [addJob]) - const handleAnalyze = useCallback(async () => { + const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => { if (!job) return + const mode = options?.mode ?? (job.frames.length > 0 ? "append" : "replace") setAnalyzing(true) - setSelectedFrames(new Set()) + if (mode === "replace") setSelectedFrames(new Set()) try { - await analyzeJob(job.id, 5, frameTarget) - toast.info(`开始解析:拆轨 → ${FRAME_TARGET_LABELS[frameTarget]}抽帧。声音文案轨单独处理`) + await analyzeJob(job.id, frameCount, frameTarget, mode) + toast.info(`${mode === "append" ? "追加抽帧" : "开始解析"}:${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张`) // 乐观更新本地状态,让轮询 useEffect 重新启动 - setJob((prev) => prev ? { ...prev, status: "splitting", message: `拆轨中 · ${FRAME_TARGET_LABELS[frameTarget]}…`, progress: 30 } : prev) + setJob((prev) => prev ? { + ...prev, + status: "splitting", + message: `${mode === "append" ? "追加抽帧中" : "拆轨中"} · ${FRAME_TARGET_LABELS[frameTarget]}…`, + progress: 30, + } : prev) } catch (e) { toast.error("解析触发失败:" + (e instanceof Error ? e.message : String(e))) } finally { setAnalyzing(false) } - }, [job?.id, frameTarget]) + }, [job?.id, job?.frames.length, frameCount, frameTarget]) const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => { try { @@ -505,6 +512,7 @@ export default function Home() { submitting, analyzing, frameTarget, + frameCount, selectedFrames, expandedFrame, framePanelScale, @@ -517,6 +525,7 @@ export default function Home() { onUploadFile: handleUpload, onAnalyze: handleAnalyze, onFrameTargetChange: setFrameTarget, + onFrameCountChange: setFrameCount, onToggleFrame: handleToggleFrame, onExpandFrame: setExpandedFrame, onOpenFramePanel: handleOpenFramePanel, @@ -546,7 +555,7 @@ export default function Home() { onCopyImage: handleCopyImage, pinnedNodes, onToggleNodePin: handleToggleNodePin, - }), [job, jobs, activeJobId, submitting, analyzing, frameTarget, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin]) + }), [job, jobs, activeJobId, submitting, analyzing, frameTarget, frameCount, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, 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 2ca7ffb..14b32a7 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -16,7 +16,7 @@ import { toast } from "sonner" import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell" import { HoverPreview } from "./hover-preview" import { - type Job, type ImageRef, type FrameExtractTarget, + type Job, type ImageRef, type FrameExtractMode, type FrameExtractTarget, apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl, } from "@/lib/api" import { FrameLightbox } from "@/components/lightbox" @@ -30,6 +30,7 @@ export interface NodeData { submitting: boolean analyzing: boolean frameTarget: FrameExtractTarget + frameCount: number selectedFrames: Set expandedFrame: number | null framePanelScale?: number @@ -40,8 +41,9 @@ export interface NodeData { videoPanelDock?: CanvasPanelDock onSubmitUrl: (url: string) => void onUploadFile: (file: File) => void - onAnalyze: () => void + onAnalyze: (options?: { mode?: FrameExtractMode }) => void onFrameTargetChange: (target: FrameExtractTarget) => void + onFrameCountChange: (count: number) => void onToggleFrame: (idx: number) => void onExpandFrame: (idx: number) => void onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板 @@ -128,6 +130,7 @@ const FRAME_TARGET_OPTIONS: Array<{ value: FrameExtractTarget; label: string; hi { value: "expression", label: "表情瞬间", hint: "人物 / 动物表情倾向" }, { value: "motion", label: "动作峰值", hint: "动作变化更明显" }, ] +const FRAME_COUNT_OPTIONS = [3, 5, 8, 12] function canvasThumbnailAnchor(root: HTMLDivElement | null, target: HTMLElement) { if (!root) return { x: 160, y: 0 } @@ -317,14 +320,17 @@ function ThumbnailScrollRail({ function FloatingThumbnailStrip({ children, label, + toolbar, }: { children: ReactNode label?: string + toolbar?: ReactNode }) { const scrollRef = useRef(null) return (
+ {toolbar &&
{toolbar}
}
{children}
@@ -400,6 +406,75 @@ function DeleteConfirmDialog({ ) } +function FrameExtractQuickBar({ + target, + count, + disabled, + running, + hasFrames, + onTargetChange, + onCountChange, + onAnalyze, +}: { + target: FrameExtractTarget + count: number + disabled: boolean + running: boolean + hasFrames: boolean + onTargetChange: (target: FrameExtractTarget) => void + onCountChange: (count: number) => void + onAnalyze: () => void +}) { + const option = FRAME_TARGET_OPTIONS.find((item) => item.value === target) ?? FRAME_TARGET_OPTIONS[0] + + return ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + +
+ {FRAME_COUNT_OPTIONS.map((item) => ( + + ))} +
+ +
+ ) +} + /* ============================================================ 1. InputNode — TK 链接 / 上传 ============================================================ */ @@ -429,17 +504,29 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an const hasVideo = !!job?.video_url const isDownloading = job?.status === "downloading" || job?.status === "created" const isAnalyzing = !!job && ["splitting", "transcribing"].includes(job.status) - const isDone = job?.status === "transcribed" const hasFrames = (job?.frames.length ?? 0) > 0 const inputLocked = isDownloading || d.submitting - const activeFrameTarget = FRAME_TARGET_OPTIONS.find((option) => option.value === d.frameTarget) ?? FRAME_TARGET_OPTIONS[0] return (
{/* 多视频缩略图浮条 — 「+」在最左,job 按时间倒序(最新靠左高亮),统一高度 64,宽度按视频原比例,一行横滚。 浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */} {d.jobs.length > 0 && ( - + d.onAnalyze({ mode: hasFrames ? "append" : "replace" })} + /> + ) : null} + > {/* + 再上传一个(放在最前面) */}
-