From 253e82a3227bbdaf8432a56d1e745452e11c2a51 Mon Sep 17 00:00:00 2001 From: kang Date: Sun, 17 May 2026 13:13:05 +0800 Subject: [PATCH] auto-save 2026-05-17 13:13 (~6) --- .memory/worklog.json | 26 +++---- RULES.md | 1 + api/main.py | 102 +++++++++++++++++++++++-- docs/source-analysis.html | 28 +++++-- web/app/page.tsx | 19 +---- web/components/ad-recreation-board.tsx | 3 - 6 files changed, 130 insertions(+), 49 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index 504e9f5..b616515 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,18 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "32eca89", - "message": "auto-save 2026-05-14 17:54 (~1)", - "ts": "2026-05-14T17:54:19+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 17:54 (~1)", - "ts": "2026-05-14T09:56:15Z", - "type": "session-heartbeat" - }, { "files_changed": 1, "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 17:54 (~1)", @@ -3269,6 +3256,19 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: narrow intake to audio-first workflow", "files_changed": 1 + }, + { + "ts": "2026-05-17T13:07:20+08:00", + "type": "commit", + "message": "auto-save 2026-05-17 13:06 (~2)", + "hash": "dab3e02", + "files_changed": 2 + }, + { + "ts": "2026-05-17T05:08:24Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 2 项未提交变更 · 最近提交:auto-save 2026-05-17 13:06 (~2)", + "files_changed": 2 } ] } diff --git a/RULES.md b/RULES.md index 434a952..db79c2c 100644 --- a/RULES.md +++ b/RULES.md @@ -62,6 +62,7 @@ - `MINIMAX_TTS_VOICE_POOL`:MiniMax 英文随机音色池;当前默认男声 `English_magnetic_voiced_man`、女声 `English_Upbeat_Woman`、成熟声 `English_MaturePartner`,供后续新配音阶段使用 - `POE_API_KEY` / `VIDEO_API_KEY`:视频生成通道 Key,只能放本地环境变量 - `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库 +- `FFMPEG_BIN` / `FFPROBE_BIN`:可选本地媒体二进制路径;本机 Homebrew ffmpeg 动态库损坏时,后端会自动跳过不可用的 PATH 版本并尝试本机静态 ffmpeg 备选,生产仍建议使用系统 ffmpeg/ffprobe - 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库 ## 规则 diff --git a/api/main.py b/api/main.py index ef9e9a2..9a69e03 100644 --- a/api/main.py +++ b/api/main.py @@ -552,7 +552,10 @@ def source_audio_url_for(job_id: str) -> str: def job_with_artifacts(job: Job) -> Job: - return job.model_copy(update={"source_audio_url": source_audio_url_for(job.id)}) + updates = {"source_audio_url": source_audio_url_for(job.id)} + if not job.video_url and (JOBS_DIR / job.id / "source.mp4").exists(): + updates["video_url"] = f"/jobs/{job.id}/video.mp4" + return job.model_copy(update=updates) def save_state(job: Job) -> None: @@ -847,7 +850,50 @@ def auth_logout(response: Response) -> dict: # ---------- Pipeline 实现 ---------- +def _binary_works(path: str) -> bool: + if not path: + return False + if os.path.sep in path and not Path(path).exists(): + return False + try: + res = subprocess.run([path, "-version"], capture_output=True, text=True, timeout=5) + return res.returncode == 0 + except Exception: + return False + + +def media_binary(name: Literal["ffmpeg", "ffprobe"]) -> str: + cached = _MEDIA_BIN_CACHE.get(name) + if cached: + return cached + env_bin = FFMPEG_BIN if name == "ffmpeg" else FFPROBE_BIN + candidates: list[str] = [] + if env_bin: + candidates.append(env_bin) + found = shutil.which(name) + if found: + candidates.append(found) + if name == "ffmpeg": + candidates.extend(LOCAL_FFMPEG_CANDIDATES) + for candidate in candidates: + if _binary_works(candidate): + _MEDIA_BIN_CACHE[name] = candidate + return candidate + raise RuntimeError(f"{name} 不可用,请配置 {name.upper()}_BIN 或修复本机 ffmpeg 安装") + + +def _normalize_media_cmd(cmd: list[str]) -> list[str]: + if not cmd: + return cmd + if cmd[0] == "ffmpeg": + return [media_binary("ffmpeg"), *cmd[1:]] + if cmd[0] == "ffprobe": + return [media_binary("ffprobe"), *cmd[1:]] + return cmd + + def run(cmd: list[str], cwd: Path | None = None) -> str: + cmd = _normalize_media_cmd(cmd) res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) if res.returncode != 0: # ffmpeg 把 banner 写 stderr,挑最后几行(真错误一般在末尾) @@ -1384,11 +1430,45 @@ def _score_transparent_human_frame(img_path: Path) -> TransparentHumanFrameScore return item +def _duration_from_text(text: str) -> float: + m = re.search(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)", text) + if not m: + return 0.0 + hours, minutes, seconds = m.groups() + return int(hours) * 3600 + int(minutes) * 60 + float(seconds) + + +def _ffmpeg_probe_text(path: Path) -> str: + ffmpeg = media_binary("ffmpeg") + res = subprocess.run([ffmpeg, "-hide_banner", "-i", str(path)], capture_output=True, text=True) + text = "\n".join(part for part in [res.stdout, res.stderr] if part) + if "Input #0" not in text: + tail = "\n".join(text.splitlines()[-12:]) + raise RuntimeError(f"ffmpeg 读取媒体失败:{tail}") + return text + + +def _ffmpeg_meta_fallback(path: Path) -> dict: + text = _ffmpeg_probe_text(path) + duration = _duration_from_text(text) + streams: list[dict] = [] + for line in text.splitlines(): + if " Video:" not in line: + continue + m = re.search(r"(? dict: - out = run([ - "ffprobe", "-v", "error", "-print_format", "json", "-show_streams", "-show_format", str(mp4), - ]) - return json.loads(out) + try: + out = run([ + "ffprobe", "-v", "error", "-print_format", "json", "-show_streams", "-show_format", str(mp4), + ]) + return json.loads(out) + except Exception: + return _ffmpeg_meta_fallback(mp4) def media_duration(path: Path) -> float: @@ -1398,13 +1478,17 @@ def media_duration(path: Path) -> float: ]) return float(json.loads(out).get("format", {}).get("duration") or 0) except Exception: - return 0.0 + try: + return _duration_from_text(_ffmpeg_probe_text(path)) + except Exception: + return 0.0 def pipeline_download(job_id: str) -> None: """阶段 1:仅下载(或上传跳过),落 source.mp4;前端开始流程会在 downloaded 后触发音频解析。""" job = JOBS[job_id] d = job_dir(job_id) + stage = "download" try: mp4 = d / "source.mp4" if mp4.exists(): @@ -1421,9 +1505,12 @@ def pipeline_download(job_id: str) -> None: if not mp4.exists(): raise RuntimeError("下载完成但找不到 source.mp4") + stage = "metadata" meta = ffprobe_meta(mp4) v_stream = next((s for s in meta["streams"] if s["codec_type"] == "video"), None) duration = float(meta["format"]["duration"]) + if duration <= 0: + raise RuntimeError("视频时长读取失败") update( job, status="downloaded", @@ -1436,7 +1523,8 @@ def pipeline_download(job_id: str) -> None: message=f"视频就绪 · {duration:.1f}s · 等待音频解析", ) except Exception as e: - update(job, status="failed", error=str(e), message="下载失败") + message = "视频元数据解析失败" if stage == "metadata" else "下载失败" + update(job, status="failed", error=str(e), message=message) def pipeline_analyze( diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 954e3d5..aebff42 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -587,7 +587,7 @@ web/next.config.mjsNext.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 web/app/globals.css全局主题变量、登录页视觉样式、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。 - web/app/page.tsx产品工作台主状态:jobs、activeJobId、按 job 隔离的音频条/生成任务状态;主渲染为全屏素材输入列 + 音频解析工作表;“开始”编排状态只负责在下载完成后自动触发 triggerTranscribe,不再默认触发抽帧、Vision 扫描或分镜初稿保存。 + web/app/page.tsx产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 音频解析工作表;“开始”编排状态只负责在下载完成后自动触发 triggerTranscribe,不再默认触发抽帧、Vision 扫描或分镜初稿保存;底部吸附音频条不再从主界面渲染。 web/components/ad-recreation-board.tsx信息流广告音频解析工作表:左侧素材输入;右侧展示视频下载状态、原文案/中文翻译、讲话人/节奏/背景音分析和逐句时间轴。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 web/app/login/page.tsx生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。 web/app/login/layout.tsx登录路由专属 layout:覆盖全站默认网页标题和描述为空,避免 /login 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。 @@ -596,7 +596,7 @@ web/public/skg-logo-black.svg从官网 https://cn.skg.com/logo-black.svg 获取的 SKG 官方黑色 SVG 字标;登录页通过 CSS 反相成白色玻璃标识使用。 web/components/login/animated-login-characters.tsx登录页四个几何动态角色组件:当前嵌入登录框顶部,去掉独立网格背景,保留鼠标眼神跟随、输入、显示密码、错误和成功状态反馈。 web/components/nodes/index.tsx旧 DAG 节点和深度素材面板定义仍保留,当前主界面不再把这些节点挂到画布上。 - web/components/audio-strip.tsx底部吸附音频条:可拖拽调整高度;播放原音频时移动指针,逐个高亮英文/中文字幕节点和对应波形,并在右侧固定显示原文案、中文翻译、讲话人、节奏和背景音分析。 + web/components/audio-strip.tsx旧底部吸附音频条组件:当前主界面不再渲染,音频文案、翻译、讲话人、节奏和背景音统一在右侧音频解析工作表里查看。 web/components/lightbox.tsx关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、纵向 6 行产品融合镜头工作表和审核。 web/components/product-library-picker.tsxSKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 assetweb/components/storyboard-bar.tsx顶部分镜编排条:展示选入编排的关键帧,并作为唯一分镜导航。 @@ -626,7 +626,7 @@ web/app/page.tsx -> 音频解析工作表:web/components/ad-recreation-board.tsx -> 开始:创建/激活 job → 下载完成后自动触发音频处理 -> 左侧素材输入列 + 右侧原文案/中文翻译/声音背景音分析/逐句时间轴 - -> 底部音频条:web/components/audio-strip.tsx(原音频播放 / 指针 / 英文 / 中文 / 波形 / 声音分析) + -> 底部音频条:不再渲染,音频结果集中到右侧工作表 -> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口) -> API 契约:web/lib/api.ts @@ -819,7 +819,7 @@ SubjectAsset { 删除输入视频DELETE /jobs/{id}deleteJob从任务队列、URL 和磁盘 jobs/<id> 目录移除整个 job,包括源视频、关键帧、元素提取图和生成视频。 解析视频POST /jobs/{id}/analyze?frames=&target=&mode=&quality=analyzeJob后续阶段保留的抽帧能力。默认 frames=12target 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值。当前第一步主流程不自动调用该接口。 音频文案轨POST /jobs/{id}/transcribetriggerTranscribe若尚未拆轨,先从 source.mp4 提取 audio.wav 并回填 source_audio_url;随后用 ASR 提取原始文案,翻译成中文,写入 audio_script.source_textsource_zh 和逐句 transcript。再用 ASR_FALLBACK_MODEL 多模态音频分析讲话人、语速节奏、停顿、背景音乐/环境声/音效,写入 speaker_profilerhythm_profilebackground_audio_profile。当前第一步不默认生成 SKG 新口播和 MiniMax 配音。 - 原始音频文件GET /jobs/{id}/audio.wavsourceAudioUrl返回拆轨得到的 wav;底部 AudioStrip 拉取该文件,用 Web Audio API 解码并计算波形峰值。原音频播放器驱动时间轴,播放时全局指针和当前字幕节点内指针同步移动。 + 原始音频文件GET /jobs/{id}/audio.wavsourceAudioUrl返回拆轨得到的 wav;当前主界面不再渲染底部音频条,右侧音频解析工作表直接使用 transcriptaudio_script 展示文字与声音分析结果。 改写配音文件GET /jobs/{id}/audio-script.mp3apiAssetUrl(job.audio_script.voice_url)后续新配音阶段保留的 MiniMax T2A 产物。当前第一步不默认生成该文件。 手动加帧POST /jobs/{id}/frames?t=addManualFrame按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。 Vision 识别POST /frames/{idx}/describedescribeFrame写入 frame.description,后续可从 objects 加候选元素。 @@ -863,8 +863,8 @@ SubjectAsset { 音频条 - 音频解析工作表顶部触发音频解析,底部 AudioStrip 负责原音频播放、字幕/翻译、波形和声音/背景音分析预览。 - 当前第一步不要默认展示新配音播放器或把 MiniMax 配音当作已完成结果。 + 音频解析工作表顶部触发音频解析,结果在右侧原文案、中文翻译、逐句时间轴和声音/背景音分析区展示;底部 AudioStrip 当前不渲染。 + 当前第一步不要默认展示底部音频条、新配音播放器,或把 MiniMax 配音当作已完成结果。 web/components/audio-strip.tsxpipeline_transcribeAudioScript @@ -884,14 +884,14 @@ SubjectAsset {

已通

@@ -941,6 +941,18 @@ SubjectAsset {

变更记录

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

+
+
+

2026-05-17 · 修复素材输入元数据解析并移除底部音频条

+ UI + Bugfix +
+
+

问题:粘贴 TK 链接后视频已经下载到 source.mp4,但本机 Homebrew ffprobe/ffmpeg 因缺少 libx265.215.dylib 直接崩溃,后端误显示为“下载失败”。同时用户不再需要底部音频展示。

+

改动:api/main.py 新增媒体二进制选择逻辑,先验证 PATH 里的 ffmpeg/ffprobe 是否可执行,失败时回退到本机静态 ffmpeg;没有可用 ffprobe 时用 ffmpeg -i 解析时长和分辨率。下载阶段把“元数据解析失败”和“下载失败”区分开。web/app/page.tsx 不再导入和渲染 AudioStripAdRecreationBoard 移除“打开音轨”按钮。

+

影响:api/main.pyweb/app/page.tsxweb/components/ad-recreation-board.tsxRULES.mddocs/source-analysis.html。后续音频预览如果需要恢复,应先明确是否仍放在右侧工作表,而不是默认恢复底部浮层。

+
+

2026-05-17 · 收窄为第一步音频解析

diff --git a/web/app/page.tsx b/web/app/page.tsx index af9d603..6c1d538 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -15,7 +15,6 @@ import { type NodeData, } from "@/components/nodes" import { ThemeToggle } from "@/components/theme-toggle" -import { AudioStrip } from "@/components/audio-strip" import { AdRecreationBoard } from "@/components/ad-recreation-board" import { addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage, @@ -130,12 +129,9 @@ const EDGES_RAW: Array<[string, string]> = [ export default function Home() { const { resolvedTheme } = useTheme() - const [clientReady, setClientReady] = useState(false) const [jobs, setJobs] = useState([]) const [activeJobId, setActiveJobId] = useState(null) const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId]) - const [audioStripJobId, setAudioStripJobId] = useState(null) - const audioStripJob = useMemo(() => jobs.find((j) => j.id === audioStripJobId) ?? null, [jobs, audioStripJobId]) const [submitting, setSubmitting] = useState(false) const [analyzing, setAnalyzing] = useState(false) const [frameTargets, setFrameTargets] = useState>({}) @@ -163,10 +159,6 @@ export default function Home() { const flowRef = useRef(null) const lastVideoPanelFocusKey = useRef("") - useEffect(() => { - setClientReady(true) - }, []) - const setSelectedFramesForJob = useCallback((jobId: string, updater: Set | ((prev: Set) => Set)) => { setSelectedFramesByJob((prev) => { const current = new Set(prev[jobId] ?? []) @@ -199,10 +191,6 @@ export default function Home() { const handleSwitchJob = useCallback((id: string) => { setActiveJobId(id) }, []) - const handleOpenAudioStrip = useCallback((jobId?: string) => { - const targetId = jobId ?? activeJobId - if (targetId) setAudioStripJobId(targetId) - }, [activeJobId]) const pollRef = useRef | null>(null) const handleSubmit = useCallback(async (url: string) => { @@ -227,7 +215,6 @@ export default function Home() { const created = await uploadJob(file) addJob(created) setProductionJobIds((prev) => new Set(prev).add(created.id)) - setAudioStripJobId(created.id) toast.success(`已上传 ${created.id.slice(0, 8)},下载完成后自动解析音频`) } catch (e) { toast.error("上传失败:" + (e instanceof Error ? e.message : String(e))) @@ -441,7 +428,6 @@ export default function Home() { const handleTranscribeAudio = useCallback(async (jobId?: string, options?: { silent?: boolean }) => { const targetId = jobId ?? activeJobId if (!targetId) return - setAudioStripJobId(targetId) const target = jobs.find((item) => item.id === targetId) if (!target) return if (!target.video_url) { @@ -538,7 +524,6 @@ export default function Home() { return } setProductionJobIds((prev) => new Set(prev).add(target.id)) - setAudioStripJobId(target.id) toast.success("已进入第一步:下载完成后自动解析音频文案、讲话人和背景音") if (target.video_url && ["downloaded", "frames_extracted", "transcribed", "failed"].includes(target.status)) { void handleTranscribeAudio(target.id, { silent: true }) @@ -898,10 +883,9 @@ export default function Home() { onCopyImage: handleCopyImage, onGenerateProductFusionVideo: handleGenerateProductFusionVideo, onTranscribeAudio: handleTranscribeAudio, - onOpenAudioStrip: handleOpenAudioStrip, pinnedNodes, onToggleNodePin: handleToggleNodePin, - }), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleStartProduction, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleCloseExpandedFrame, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, updateJobInList, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleOpenStoryboard, handleOpenWorkbench, clipboard, handleCopyImage, handleGenerateProductFusionVideo, handleTranscribeAudio, handleOpenAudioStrip, pinnedNodes, handleToggleNodePin]) + }), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleStartProduction, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleCloseExpandedFrame, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, updateJobInList, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleOpenStoryboard, handleOpenWorkbench, clipboard, handleCopyImage, handleGenerateProductFusionVideo, handleTranscribeAudio, pinnedNodes, handleToggleNodePin]) // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag) const savedSizes = useMemo(() => loadNodeSizes(), []) @@ -1145,7 +1129,6 @@ export default function Home() {
- {clientReady && setAudioStripJobId(null)} />} diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index efc810b..30e4c08 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -384,9 +384,6 @@ export function AdRecreationBoard({ 解析音频 - data.onOpenAudioStrip?.(job?.id)}> - 打开音轨 -