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 /jobs | createJob | 提交 TK 链接,后台开始下载,停在 downloaded 等用户点解析。 |
| 上传视频 | POST /jobs/upload | uploadJob | 保存 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}/describe | describeFrame | 写入 frame.description,后续可从 objects 加候选元素。 |
| 清洗水印 | POST /frames/{idx}/cleanup | cleanupFrame | 支持全图和区域清洗,生成 cleaned 待应用版本。 |
@@ -723,7 +723,7 @@ api/main.py
| 输入 Input |
- 创建/上传任务,显示视频就绪;每个视频缩略图上方都有绑定自己的自动抽帧快捷工具条,可快速选目标、张数并多次追加;单击视频缩略图打开画布内抽帧面板。 |
+ 创建/上传任务,显示视频就绪;每个视频缩略图上方都有绑定自己的自动抽帧快捷工具条,可快速选目标、张数、精度并多次追加;单击视频缩略图打开画布内抽帧面板。 |
不要自动一路跑到 ASR 或生图;用户需要控制解析节奏。 |
page.tsx、InputNode、VideoFramePanelNode、api/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.py、web/lib/api.ts、web/app/page.tsx、web/components/nodes/index.tsx、docs/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.tsx、web/components/nodes/index.tsx、docs/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} 张
))}
+