From 4935e34eb063012ade982a9858d5fc6689a12b2d Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 14 May 2026 04:32:27 +0800 Subject: [PATCH] auto-save 2026-05-14 04:32 (~5) --- .memory/worklog.json | 13 +++++++ api/main.py | 69 +++++++++++++++++++++++++++++++--- web/app/page.tsx | 34 ++++++++++++----- web/components/nodes/index.tsx | 7 ++-- web/lib/api.ts | 4 +- 5 files changed, 106 insertions(+), 21 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index ecf17cf..ea6e0b6 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -3199,6 +3199,19 @@ "type": "session-heartbeat", "message": "Claude 会话活跃 · 最近命令:claude · 4 项未提交变更 · 最近提交:auto-save 2026-05-14 04:21 (~6)", "files_changed": 4 + }, + { + "ts": "2026-05-14T04:26:56+08:00", + "type": "commit", + "message": "auto-save 2026-05-14 04:26 (~5)", + "hash": "8f2b8d3", + "files_changed": 5 + }, + { + "ts": "2026-05-13T20:28:50Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 04:26 (~5)", + "files_changed": 1 } ] } diff --git a/api/main.py b/api/main.py index 189173e..176f391 100644 --- a/api/main.py +++ b/api/main.py @@ -90,7 +90,8 @@ JobStatus = Literal[ KEYFRAME_COUNT = int(os.getenv("KEYFRAME_COUNT", "5")) FrameExtractTarget = Literal["balanced", "subject", "transition", "expression", "motion"] FrameExtractMode = Literal["replace", "append"] -FrameExtractQuality = Literal["fast", "accurate", "ultra"] +FrameExtractQuality = Literal["auto", "fast", "accurate", "ultra"] +AnalyzeTask = tuple[str, int, FrameExtractTarget, FrameExtractMode, FrameExtractQuality] FRAME_TARGET_LABELS: dict[FrameExtractTarget, str] = { "balanced": "综合关键帧", "subject": "清晰主体", @@ -99,6 +100,7 @@ FRAME_TARGET_LABELS: dict[FrameExtractTarget, str] = { "motion": "动作峰值", } FRAME_QUALITY_LABELS: dict[FrameExtractQuality, str] = { + "auto": "自动", "fast": "快速", "accurate": "精细", "ultra": "极准", @@ -221,6 +223,8 @@ class Job(BaseModel): JOBS: dict[str, Job] = {} +ANALYZE_QUEUE: list[AnalyzeTask] = [] +ANALYZE_WORKER_RUNNING = False def job_dir(job_id: str) -> Path: @@ -441,6 +445,30 @@ def _frame_metrics(img_path: Path, idx: int, timestamp: float, metric_width: int } +def _physical_memory_gb() -> float: + try: + page_size = os.sysconf("SC_PAGE_SIZE") + pages = os.sysconf("SC_PHYS_PAGES") + return float(page_size * pages) / (1024 ** 3) + except Exception: + return 0.0 + + +def _resolve_frame_quality(duration: float, quality: FrameExtractQuality) -> FrameExtractQuality: + if quality != "auto": + return quality + cores = os.cpu_count() or 4 + memory_gb = _physical_memory_gb() + strong_machine = cores >= 10 and (memory_gb == 0.0 or memory_gb >= 32) + if strong_machine and duration <= 180: + return "ultra" + if strong_machine and duration <= 600: + return "accurate" + if cores >= 8 and duration <= 240: + return "accurate" + return "fast" + + def _scan_profile(duration: float, quality: FrameExtractQuality) -> tuple[float, int, int, int]: """返回 scan_fps / scan_width / metric_width / estimated_count。""" if quality == "ultra": @@ -607,7 +635,7 @@ async def pipeline_analyze( frame_count: int = KEYFRAME_COUNT, target: FrameExtractTarget = "balanced", mode: FrameExtractMode = "replace", - quality: FrameExtractQuality = "accurate", + quality: FrameExtractQuality = "auto", ) -> None: """阶段 2:拆音轨 + 抽关键帧。ASR/翻译是独立文案轨,不阻塞视觉素材流。""" job = JOBS[job_id] @@ -630,9 +658,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, scan_width, metric_width, estimated_scan_count = _scan_profile(duration, quality) + effective_quality = _resolve_frame_quality(duration, quality) + effective_quality_label = FRAME_QUALITY_LABELS.get(effective_quality, FRAME_QUALITY_LABELS["accurate"]) + quality_label = f"自动·{effective_quality_label}" if quality == "auto" else effective_quality_label + scan_fps, scan_width, metric_width, estimated_scan_count = _scan_profile(duration, effective_quality) update(job, message=f"本地{quality_label}扫描 · {target_label} · 约 {estimated_scan_count} 帧…", progress=45) frames_dir = d / "frames" @@ -716,6 +746,24 @@ async def pipeline_analyze( update(job, status="failed", error=str(e), message="解析失败") +async def analyze_queue_worker() -> None: + global ANALYZE_WORKER_RUNNING + ANALYZE_WORKER_RUNNING = True + try: + while ANALYZE_QUEUE: + job_id, frames, target, mode, quality = ANALYZE_QUEUE.pop(0) + if job_id not in JOBS: + continue + await pipeline_analyze(job_id, frames, target, mode, quality) + if ANALYZE_QUEUE: + for pos, (queued_job_id, *_rest) in enumerate(ANALYZE_QUEUE, start=1): + queued_job = JOBS.get(queued_job_id) + if queued_job: + update(queued_job, status="splitting", progress=30, message=f"排队等待抽帧 · 前方 {pos - 1} 个任务") + finally: + ANALYZE_WORKER_RUNNING = False + + # ---------- Gemini ASR + 翻译 ---------- def _transcribe_sync(wav: Path) -> list[dict]: @@ -1084,14 +1132,23 @@ async def trigger_analyze( frames: int = KEYFRAME_COUNT, target: FrameExtractTarget = "balanced", mode: FrameExtractMode = "replace", - quality: FrameExtractQuality = "accurate", + quality: FrameExtractQuality = "auto", ) -> 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, quality) + ANALYZE_QUEUE.append((job_id, frames, target, mode, quality)) + position = len(ANALYZE_QUEUE) + update( + job, + status="splitting", + progress=30, + message="排队等待抽帧" if ANALYZE_WORKER_RUNNING or position > 1 else "准备抽帧…", + ) + if not ANALYZE_WORKER_RUNNING: + bg.add_task(analyze_queue_worker) return job diff --git a/web/app/page.tsx b/web/app/page.tsx index a77512e..e524400 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -42,6 +42,7 @@ const FRAME_TARGET_LABELS: Record = { motion: "动作峰值", } const FRAME_QUALITY_LABELS: Record = { + auto: "自动", fast: "快速", accurate: "精细", ultra: "极准", @@ -178,7 +179,7 @@ export default function Home() { if (!targetJob) return const frameTarget = frameTargets[jobId] ?? "balanced" const frameCount = frameCounts[jobId] ?? 5 - const frameQuality = frameQualities[jobId] ?? "ultra" + const frameQuality = frameQualities[jobId] ?? "auto" const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace") setActiveJobId(jobId) setAnalyzing(true) @@ -497,30 +498,43 @@ export default function Home() { }) }, [job?.id, job?.frames]) - // 轮询 Job(downloaded / transcribed / failed 三态停止) + // 轮询 Job:任一视频在下载 / 抽帧 / 生视频时都继续轮询,支持多个抽帧任务排队。 const prevStatusRef = useRef(null) useEffect(() => { - if (!job) return + if (jobs.length === 0) return // 状态切到 downloaded 时提示用户点解析(仅一次) - if (job.status === "downloaded" && prevStatusRef.current !== "downloaded") { + if (job?.status === "downloaded" && prevStatusRef.current !== "downloaded") { toast.info("📥 视频已就绪 — 请点 Input 节点里的「点这里开始解析」按钮", { duration: 6000 }) } - prevStatusRef.current = job.status + prevStatusRef.current = job?.status ?? null - const runningVideo = !!job.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress") const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"] - if (TERMINAL.includes(job.status) && !runningVideo) { + const runningIds = jobs + .filter((item) => { + const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress") + return runningVideo || !TERMINAL.includes(item.status) + }) + .map((item) => item.id) + + if (runningIds.length === 0) { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } return } pollRef.current = setInterval(async () => { try { - const latest = await getJob(job.id) - setJob(latest) + const latestJobs = await Promise.all(runningIds.map((id) => getJob(id).catch(() => null))) + const byId = new Map(latestJobs.filter((item): item is Job => !!item).map((item) => [item.id, item])) + if (byId.size > 0) { + setJobs((prev) => prev.map((item) => byId.get(item.id) ?? item)) + } } catch { /* silent */ } }, 1500) return () => { if (pollRef.current) clearInterval(pollRef.current) } - }, [job?.id, job?.status, job?.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join("|")]) + }, [ + job?.id, + job?.status, + jobs.map((item) => `${item.id}:${item.status}:${item.progress}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}`).join("|"), + ]) const [pinnedNodes, setPinnedNodes] = useState>(() => new Set(loadNodePins())) const handleToggleNodePin = useCallback((id: string) => { diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx index 0d08844..5a0766b 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -135,6 +135,7 @@ const FRAME_TARGET_OPTIONS: Array<{ value: FrameExtractTarget; label: string; hi ] const FRAME_COUNT_OPTIONS = [3, 5, 8, 12] const FRAME_QUALITY_OPTIONS: Array<{ value: FrameExtractQuality; label: string; hint: string }> = [ + { value: "auto", label: "自动", hint: "按电脑性能和视频时长自动选择" }, { value: "fast", label: "快速", hint: "2fps / 360px,长视频省电" }, { value: "accurate", label: "精细", hint: "8fps / 720px,M2 Max 轻松可用" }, { value: "ultra", label: "极准", hint: "12fps / 960px,本机约 3 秒扫描 1 分钟视频" }, @@ -438,7 +439,7 @@ function FrameExtractQuickBar({ 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] + const qualityOption = FRAME_QUALITY_OPTIONS.find((item) => item.value === quality) ?? FRAME_QUALITY_OPTIONS[0] const [settingsOpen, setSettingsOpen] = useState(false) return ( @@ -569,7 +570,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an const toolWidth = Math.max(148, thumbNaturalWidth) const target = d.frameTargets[j.id] ?? "balanced" const count = d.frameCounts[j.id] ?? 5 - const quality = d.frameQualities[j.id] ?? "ultra" + const quality = d.frameQualities[j.id] ?? "auto" const jHasFrames = j.frames.length > 0 const jRunning = ["splitting", "transcribing"].includes(j.status) return ( @@ -583,7 +584,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an target={target} count={count} quality={quality} - disabled={jRunning || d.analyzing} + disabled={jRunning} running={jRunning} hasFrames={jHasFrames} onTargetChange={(next) => d.onFrameTargetChange(j.id, next)} diff --git a/web/lib/api.ts b/web/lib/api.ts index fc44eb4..bd4901b 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -130,7 +130,7 @@ export interface KeyFrame { export type FrameExtractTarget = "balanced" | "subject" | "transition" | "expression" | "motion" export type FrameExtractMode = "replace" | "append" -export type FrameExtractQuality = "fast" | "accurate" | "ultra" +export type FrameExtractQuality = "auto" | "fast" | "accurate" | "ultra" export interface TranscriptSegment { index: number @@ -268,7 +268,7 @@ export async function analyzeJob( frames = 5, target: FrameExtractTarget = "balanced", mode: FrameExtractMode = "replace", - quality: FrameExtractQuality = "accurate", + quality: FrameExtractQuality = "auto", ): Promise { const qs = new URLSearchParams({ frames: String(frames), target, mode, quality }) const res = await fetch(`${API_BASE}/jobs/${id}/analyze?${qs.toString()}`, { method: "POST" })