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.mjs | Next.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.tsx | SKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 asset。 |
web/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=12;target 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值。当前第一步主流程不自动调用该接口。 |
| 音频文案轨 | POST /jobs/{id}/transcribe | triggerTranscribe | 若尚未拆轨,先从 source.mp4 提取 audio.wav 并回填 source_audio_url;随后用 ASR 提取原始文案,翻译成中文,写入 audio_script.source_text、source_zh 和逐句 transcript。再用 ASR_FALLBACK_MODEL 多模态音频分析讲话人、语速节奏、停顿、背景音乐/环境声/音效,写入 speaker_profile、rhythm_profile、background_audio_profile。当前第一步不默认生成 SKG 新口播和 MiniMax 配音。 |
- | 原始音频文件 | GET /jobs/{id}/audio.wav | sourceAudioUrl | 返回拆轨得到的 wav;底部 AudioStrip 拉取该文件,用 Web Audio API 解码并计算波形峰值。原音频播放器驱动时间轴,播放时全局指针和当前字幕节点内指针同步移动。 |
+ | 原始音频文件 | GET /jobs/{id}/audio.wav | sourceAudioUrl | 返回拆轨得到的 wav;当前主界面不再渲染底部音频条,右侧音频解析工作表直接使用 transcript 和 audio_script 展示文字与声音分析结果。 |
| 改写配音文件 | GET /jobs/{id}/audio-script.mp3 | apiAssetUrl(job.audio_script.voice_url) | 后续新配音阶段保留的 MiniMax T2A 产物。当前第一步不默认生成该文件。 |
| 手动加帧 | POST /jobs/{id}/frames?t= | addManualFrame | 按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。 |
| Vision 识别 | POST /frames/{idx}/describe | describeFrame | 写入 frame.description,后续可从 objects 加候选元素。 |
@@ -863,8 +863,8 @@ SubjectAsset {
| 音频条 |
- 音频解析工作表顶部触发音频解析,底部 AudioStrip 负责原音频播放、字幕/翻译、波形和声音/背景音分析预览。 |
- 当前第一步不要默认展示新配音播放器或把 MiniMax 配音当作已完成结果。 |
+ 音频解析工作表顶部触发音频解析,结果在右侧原文案、中文翻译、逐句时间轴和声音/背景音分析区展示;底部 AudioStrip 当前不渲染。 |
+ 当前第一步不要默认展示底部音频条、新配音播放器,或把 MiniMax 配音当作已完成结果。 |
web/components/audio-strip.tsx、pipeline_transcribe、AudioScript |
@@ -884,14 +884,14 @@ SubjectAsset {
已通
- TK 链接 / 上传创建 job。
- - 视频下载或本地保存,ffmpeg 抽关键帧。
+ - 视频下载或本地保存;后端会检测可用 ffmpeg/ffprobe,PATH 版本不可用时可 fallback 到本机静态 ffmpeg,避免 Homebrew 动态库损坏导致素材输入失败。
- 手动按时间戳加关键帧。
- 关键帧清洗水印,全图或区域清洗。
- Vision 识别关键帧,输出 scene、objects、style、suggested_prompt,并作为主体候选来源。
- “开始”会在下载完成后自动触发音频处理,不再默认自动抽帧、Vision 扫描或保存分镜初稿。
- 主体候选确认、改名、删除和主体资产包生成能力保留在底层旧面板和接口中,当前第一步主界面不主动展示。
- 分镜工作台 4 图槽和改造说明自动保存。
- - 音频文案轨:点击开始或提取音频后提取原文案、中文翻译、讲话人、语速节奏、背景音乐/环境声/音效。底部音频条可播放原音频并用指针逐段对齐字幕节点。
+ - 音频文案轨:点击开始或提取音频后提取原文案、中文翻译、讲话人、语速节奏、背景音乐/环境声/音效;结果集中在右侧工作表展示。
- nano-banana-pro image-to-image 生图。
@@ -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 不再导入和渲染 AudioStrip,AdRecreationBoard 移除“打开音轨”按钮。
+
影响:api/main.py、web/app/page.tsx、web/components/ad-recreation-board.tsx、RULES.md、docs/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)}>
- 打开音轨
-