diff --git a/.memory/worklog.json b/.memory/worklog.json index f3c50d0..561e758 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -3173,6 +3173,19 @@ "type": "session-heartbeat", "message": "Claude 会话活跃 · 最近命令:claude · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 04:10 (~4)", "files_changed": 3 + }, + { + "ts": "2026-05-14T04:15:56+08:00", + "type": "commit", + "message": "auto-save 2026-05-14 04:15 (~4)", + "hash": "b52642b", + "files_changed": 4 + }, + { + "ts": "2026-05-13T20:18:50Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 6 项未提交变更 · 最近提交:auto-save 2026-05-14 04:15 (~4)", + "files_changed": 6 } ] } diff --git a/api/main.py b/api/main.py index 3173cfa..c93767e 100644 --- a/api/main.py +++ b/api/main.py @@ -607,6 +607,7 @@ async def pipeline_analyze( frame_count: int = KEYFRAME_COUNT, target: FrameExtractTarget = "balanced", mode: FrameExtractMode = "replace", + quality: FrameExtractQuality = "accurate", ) -> None: """阶段 2:拆音轨 + 抽关键帧。ASR/翻译是独立文案轨,不阻塞视觉素材流。""" job = JOBS[job_id] @@ -629,11 +630,11 @@ async def pipeline_analyze( n = max(1, min(int(frame_count), 20)) target_label = FRAME_TARGET_LABELS.get(target, FRAME_TARGET_LABELS["balanced"]) + quality_label = FRAME_QUALITY_LABELS.get(quality, FRAME_QUALITY_LABELS["accurate"]) duration = max(float(job.duration or 1.0), 0.1) - scan_fps = min(2.0, max(0.02, 180.0 / duration)) - estimated_scan_count = max(1, int(duration * scan_fps)) + scan_fps, scan_width, metric_width, estimated_scan_count = _scan_profile(duration, quality) - update(job, message=f"低清扫描候选 · {target_label} · 约 {estimated_scan_count} 帧…", progress=45) + update(job, message=f"本地{quality_label}扫描 · {target_label} · 约 {estimated_scan_count} 帧…", progress=45) frames_dir = d / "frames" replacing = mode == "replace" existing_frames = list(job.frames) if not replacing else [] @@ -648,7 +649,7 @@ async def pipeline_analyze( # 1) 低分辨率、低帧率扫描。扫描图只用于候选评分,最终不直接作为关键帧。 run([ "ffmpeg", "-y", "-i", str(mp4), - "-vf", f"fps={scan_fps:.4f},scale=360:-2", + "-vf", f"fps={scan_fps:.4f},scale={scan_width}:-2", "-q:v", "4", str(scan_dir / "s_%05d.jpg"), ]) @@ -660,7 +661,7 @@ async def pipeline_analyze( candidates: list[dict] = [] for i, p in enumerate(scan_paths): t = min(i / scan_fps, max(duration - 0.05, 0.0)) - item = _frame_metrics(p, i, t) + item = _frame_metrics(p, i, t, metric_width) if item: candidates.append(item) if not candidates: @@ -668,7 +669,7 @@ async def pipeline_analyze( # 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) + update(job, message=f"{quality_label}筛选 · {target_label} · {n} / {len(candidates)} 张…", progress=60) chosen = _select_keyframes(candidates, selection_count, target) # 3) 只对最终选中的时间点,从原视频抽高质量关键帧。 @@ -708,7 +709,7 @@ async def pipeline_analyze( status="frames_extracted", frames=merged_frames, progress=70, - message=f"已按「{target_label}」{action_label} {len(renamed)} 张关键帧 · 共 {len(merged_frames)} 张", + message=f"已按「{quality_label} · {target_label}」{action_label} {len(renamed)} 张关键帧 · 共 {len(merged_frames)} 张", ) except Exception as e: @@ -1083,13 +1084,14 @@ async def trigger_analyze( frames: int = KEYFRAME_COUNT, target: FrameExtractTarget = "balanced", mode: FrameExtractMode = "replace", + quality: FrameExtractQuality = "accurate", ) -> 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, mode) + bg.add_task(pipeline_analyze, job_id, frames, target, mode, quality) return job diff --git a/docs/source-analysis.html b/docs/source-analysis.html index c9e0469..0be467b 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=&mode=analyzeJob拆轨 + 目标化抽关键帧。target 支持综合、清晰主体、转场变化、表情瞬间、动作峰值;mode=append 追加新关键帧,mode=replace 重抽覆盖。 + 解析视频POST /jobs/{id}/analyze?frames=&target=&mode=&quality=analyzeJob拆轨 + 目标化抽关键帧。target 支持综合、清晰主体、转场变化、表情瞬间、动作峰值;mode=append 追加新关键帧;quality 支持快速、精细、极准本地扫描。 手动加帧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 @@ -817,6 +817,18 @@ api/main.py

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-14 · 自动抽帧增加本地精度模式

+ Input + Performance +
+
+

问题:当前抽帧默认偏轻量,用户机器是 Apple M2 Max、12 核 CPU、38 核 GPU、64GB 内存,足以承受更密集的本地候选扫描;但需要在 UI 里提示算力档位,避免长视频误用重模式。

+

改动:后端 /jobs/{id}/analyze 新增 quality 参数:快速为 2fps/360px,精细为 8fps/720px,极准为 12fps/960px;高精度模式还会提高本地评分图分辨率,保留竖屏比例,不再把竖屏压扁到 16:9。每个缩略图工具条新增精度选择。

+

影响:api/main.pyweb/lib/api.tsweb/app/page.tsxweb/components/nodes/index.tsxdocs/source-analysis.html。实测本机 64.5s、1080×1920、60fps 视频,12fps/960px 扫描 774 张候选约 2.61s;重型 ffmpeg 场景/模糊滤镜约 21.63s,因此继续使用本地 PIL/NumPy 评分更划算。

+
+

2026-05-14 · 每个输入视频缩略图绑定自己的抽帧工具条

@@ -825,7 +837,7 @@ api/main.py

问题:统一放在缩略图浮条上方的抽帧工具条仍然不够明确,用户无法一眼判断当前会抽哪一个视频。

-

改动:抽帧目标、张数和抽帧按钮改为渲染在每个视频缩略图正上方,并且每个 job 独立保存目标和张数设置。点击某个缩略图上方的抽帧按钮时,前端直接把该 jobId 传给 analyzeJob,同时切换 active job 并进入该 job 的进度轮询。

+

改动:抽帧目标、张数、精度和抽帧按钮改为渲染在每个视频缩略图正上方,并且每个 job 独立保存目标、张数和精度设置。点击某个缩略图上方的抽帧按钮时,前端直接把该 jobId 传给 analyzeJob,同时切换 active job 并进入该 job 的进度轮询。

影响:web/app/page.tsxweb/components/nodes/index.tsxdocs/source-analysis.html。后续与输入视频相关的快捷操作都应优先贴近对应缩略图,不再依赖全局当前选择的心智。

diff --git a/web/app/page.tsx b/web/app/page.tsx index 73de92b..33df0f6 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 FrameExtractMode, type FrameExtractTarget, + type Job, type ImageRef, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget, } from "@/lib/api" const NODE_TYPES = { @@ -41,6 +41,11 @@ const FRAME_TARGET_LABELS: Record = { expression: "表情瞬间", motion: "动作峰值", } +const FRAME_QUALITY_LABELS: Record = { + fast: "快速", + accurate: "精细", + ultra: "极准", +} // 合并 input + download + split 为一个节点 // 分叉:上路 input → visual lab ↘ @@ -94,6 +99,7 @@ export default function Home() { const [analyzing, setAnalyzing] = useState(false) const [frameTargets, setFrameTargets] = useState>({}) const [frameCounts, setFrameCounts] = useState>({}) + const [frameQualities, setFrameQualities] = useState>({}) const [selectedFrames, setSelectedFrames] = useState>(new Set()) const [expandedFrame, setExpandedFrame] = useState(null) const [framePanelScale, setFramePanelScale] = useState(1) @@ -172,17 +178,18 @@ export default function Home() { if (!targetJob) return const frameTarget = frameTargets[jobId] ?? "balanced" const frameCount = frameCounts[jobId] ?? 5 + const frameQuality = frameQualities[jobId] ?? "accurate" const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace") setActiveJobId(jobId) setAnalyzing(true) if (mode === "replace") setSelectedFrames(new Set()) try { - await analyzeJob(jobId, frameCount, frameTarget, mode) - toast.info(`${mode === "append" ? "追加抽帧" : "开始解析"}:${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张`) + await analyzeJob(jobId, frameCount, frameTarget, mode, frameQuality) + toast.info(`${mode === "append" ? "追加抽帧" : "开始解析"}:${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张`) setJobs((prev) => prev.map((item) => item.id === jobId ? { ...item, status: "splitting", - message: `${mode === "append" ? "追加抽帧中" : "拆轨中"} · ${FRAME_TARGET_LABELS[frameTarget]}…`, + message: `${mode === "append" ? "追加抽帧中" : "拆轨中"} · ${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]}…`, progress: 30, } : item)) } catch (e) { @@ -190,7 +197,7 @@ export default function Home() { } finally { setAnalyzing(false) } - }, [jobs, frameCounts, frameTargets]) + }, [jobs, frameCounts, frameQualities, frameTargets]) const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => { if (!job) return @@ -205,6 +212,10 @@ export default function Home() { setFrameCounts((prev) => ({ ...prev, [jobId]: Math.max(1, Math.min(20, count)) })) }, []) + const handleFrameQualityChange = useCallback((jobId: string, quality: FrameExtractQuality) => { + setFrameQualities((prev) => ({ ...prev, [jobId]: quality })) + }, []) + const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => { try { const updated = await addManualFrame(jobId, t) @@ -529,6 +540,7 @@ export default function Home() { analyzing, frameTargets, frameCounts, + frameQualities, selectedFrames, expandedFrame, framePanelScale, @@ -543,6 +555,7 @@ export default function Home() { onAnalyzeJob: handleAnalyzeJob, onFrameTargetChange: handleFrameTargetChange, onFrameCountChange: handleFrameCountChange, + onFrameQualityChange: handleFrameQualityChange, onToggleFrame: handleToggleFrame, onExpandFrame: setExpandedFrame, onOpenFramePanel: handleOpenFramePanel, @@ -572,7 +585,7 @@ export default function Home() { onCopyImage: handleCopyImage, pinnedNodes, onToggleNodePin: handleToggleNodePin, - }), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin]) + }), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, 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 e3c9d54..4550256 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 FrameExtractMode, type FrameExtractTarget, + type Job, type ImageRef, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget, apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl, } from "@/lib/api" import { FrameLightbox } from "@/components/lightbox" @@ -31,6 +31,7 @@ export interface NodeData { analyzing: boolean frameTargets: Record frameCounts: Record + frameQualities: Record selectedFrames: Set expandedFrame: number | null framePanelScale?: number @@ -45,6 +46,7 @@ export interface NodeData { onAnalyzeJob: (jobId: string, options?: { mode?: FrameExtractMode }) => void onFrameTargetChange: (jobId: string, target: FrameExtractTarget) => void onFrameCountChange: (jobId: string, count: number) => void + onFrameQualityChange: (jobId: string, quality: FrameExtractQuality) => void onToggleFrame: (idx: number) => void onExpandFrame: (idx: number) => void onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板 @@ -132,6 +134,11 @@ const FRAME_TARGET_OPTIONS: Array<{ value: FrameExtractTarget; label: string; hi { value: "motion", label: "动作峰值", hint: "动作变化更明显" }, ] const FRAME_COUNT_OPTIONS = [3, 5, 8, 12] +const FRAME_QUALITY_OPTIONS: Array<{ value: FrameExtractQuality; label: string; hint: string }> = [ + { value: "fast", label: "快速", hint: "2fps / 360px,长视频省电" }, + { value: "accurate", label: "精细", hint: "8fps / 720px,M2 Max 轻松可用" }, + { value: "ultra", label: "极准", hint: "12fps / 960px,本机约 3 秒扫描 1 分钟视频" }, +] function canvasThumbnailAnchor(root: HTMLDivElement | null, target: HTMLElement) { if (!root) return { x: 160, y: 0 } @@ -410,23 +417,28 @@ function DeleteConfirmDialog({ function FrameExtractQuickBar({ target, count, + quality, disabled, running, hasFrames, onTargetChange, onCountChange, + onQualityChange, onAnalyze, }: { target: FrameExtractTarget count: number + quality: FrameExtractQuality disabled: boolean running: boolean hasFrames: boolean onTargetChange: (target: FrameExtractTarget) => void onCountChange: (count: number) => void + onQualityChange: (quality: FrameExtractQuality) => void onAnalyze: () => void }) { const option = FRAME_TARGET_OPTIONS.find((item) => item.value === target) ?? FRAME_TARGET_OPTIONS[0] + const qualityOption = FRAME_QUALITY_OPTIONS.find((item) => item.value === quality) ?? FRAME_QUALITY_OPTIONS[1] return (
{item} 张 ))} +