auto-save 2026-05-14 04:32 (~5)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
69
api/main.py
69
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
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ const FRAME_TARGET_LABELS: Record<FrameExtractTarget, string> = {
|
||||
motion: "动作峰值",
|
||||
}
|
||||
const FRAME_QUALITY_LABELS: Record<FrameExtractQuality, string> = {
|
||||
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<string | null>(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<Set<string>>(() => new Set(loadNodePins()))
|
||||
const handleToggleNodePin = useCallback((id: string) => {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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<Job> {
|
||||
const qs = new URLSearchParams({ frames: String(frames), target, mode, quality })
|
||||
const res = await fetch(`${API_BASE}/jobs/${id}/analyze?${qs.toString()}`, { method: "POST" })
|
||||
|
||||
Reference in New Issue
Block a user