auto-save 2026-05-17 13:13 (~6)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
RULES.md
1
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 不入库
|
||||
|
||||
## 规则
|
||||
|
||||
102
api/main.py
102
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"(?<![.\d])(\d{2,5})x(\d{2,5})(?![.\d])", line)
|
||||
if m:
|
||||
streams.append({"codec_type": "video", "width": int(m.group(1)), "height": int(m.group(2))})
|
||||
return {"streams": streams, "format": {"duration": str(duration)}}
|
||||
|
||||
|
||||
def ffprobe_meta(mp4: Path) -> 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(
|
||||
|
||||
@@ -587,7 +587,7 @@
|
||||
<tbody>
|
||||
<tr><td><code>web/next.config.mjs</code></td><td>Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 <code>eslint</code> 顶层配置,避免本地 dev 出现配置 Issue 提示。</td></tr>
|
||||
<tr><td><code>web/app/globals.css</code></td><td>全局主题变量、登录页视觉样式、ReactFlow 样式引用,以及本地开发态 <code>nextjs-portal</code> 遮挡隐藏规则。</td></tr>
|
||||
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态:jobs、activeJobId、按 job 隔离的音频条/生成任务状态;主渲染为全屏素材输入列 + 音频解析工作表;“开始”编排状态只负责在下载完成后自动触发 <code>triggerTranscribe</code>,不再默认触发抽帧、Vision 扫描或分镜初稿保存。</td></tr>
|
||||
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 音频解析工作表;“开始”编排状态只负责在下载完成后自动触发 <code>triggerTranscribe</code>,不再默认触发抽帧、Vision 扫描或分镜初稿保存;底部吸附音频条不再从主界面渲染。</td></tr>
|
||||
<tr><td><code>web/components/ad-recreation-board.tsx</code></td><td>信息流广告音频解析工作表:左侧素材输入;右侧展示视频下载状态、原文案/中文翻译、讲话人/节奏/背景音分析和逐句时间轴。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。</td></tr>
|
||||
<tr><td><code>web/app/login/page.tsx</code></td><td>生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。</td></tr>
|
||||
<tr><td><code>web/app/login/layout.tsx</code></td><td>登录路由专属 layout:覆盖全站默认网页标题和描述为空,避免 <code>/login</code> 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。</td></tr>
|
||||
@@ -596,7 +596,7 @@
|
||||
<tr><td><code>web/public/skg-logo-black.svg</code></td><td>从官网 <code>https://cn.skg.com/logo-black.svg</code> 获取的 SKG 官方黑色 SVG 字标;登录页通过 CSS 反相成白色玻璃标识使用。</td></tr>
|
||||
<tr><td><code>web/components/login/animated-login-characters.tsx</code></td><td>登录页四个几何动态角色组件:当前嵌入登录框顶部,去掉独立网格背景,保留鼠标眼神跟随、输入、显示密码、错误和成功状态反馈。</td></tr>
|
||||
<tr><td><code>web/components/nodes/index.tsx</code></td><td>旧 DAG 节点和深度素材面板定义仍保留,当前主界面不再把这些节点挂到画布上。</td></tr>
|
||||
<tr><td><code>web/components/audio-strip.tsx</code></td><td>底部吸附音频条:可拖拽调整高度;播放原音频时移动指针,逐个高亮英文/中文字幕节点和对应波形,并在右侧固定显示原文案、中文翻译、讲话人、节奏和背景音分析。</td></tr>
|
||||
<tr><td><code>web/components/audio-strip.tsx</code></td><td>旧底部吸附音频条组件:当前主界面不再渲染,音频文案、翻译、讲话人、节奏和背景音统一在右侧音频解析工作表里查看。</td></tr>
|
||||
<tr><td><code>web/components/lightbox.tsx</code></td><td>关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、纵向 6 行产品融合镜头工作表和审核。</td></tr>
|
||||
<tr><td><code>web/components/product-library-picker.tsx</code></td><td>SKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 <code>asset</code>。</td></tr>
|
||||
<tr><td><code>web/components/storyboard-bar.tsx</code></td><td>顶部分镜编排条:展示选入编排的关键帧,并作为唯一分镜导航。</td></tr>
|
||||
@@ -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 {
|
||||
<tr><td>删除输入视频</td><td><code>DELETE /jobs/{id}</code></td><td><code>deleteJob</code></td><td>从任务队列、URL 和磁盘 <code>jobs/<id></code> 目录移除整个 job,包括源视频、关键帧、元素提取图和生成视频。</td></tr>
|
||||
<tr><td>解析视频</td><td><code>POST /jobs/{id}/analyze?frames=&target=&mode=&quality=</code></td><td><code>analyzeJob</code></td><td>后续阶段保留的抽帧能力。默认 <code>frames=12</code>;<code>target</code> 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值。当前第一步主流程不自动调用该接口。</td></tr>
|
||||
<tr><td>音频文案轨</td><td><code>POST /jobs/{id}/transcribe</code></td><td><code>triggerTranscribe</code></td><td>若尚未拆轨,先从 <code>source.mp4</code> 提取 <code>audio.wav</code> 并回填 <code>source_audio_url</code>;随后用 ASR 提取原始文案,翻译成中文,写入 <code>audio_script.source_text</code>、<code>source_zh</code> 和逐句 <code>transcript</code>。再用 <code>ASR_FALLBACK_MODEL</code> 多模态音频分析讲话人、语速节奏、停顿、背景音乐/环境声/音效,写入 <code>speaker_profile</code>、<code>rhythm_profile</code>、<code>background_audio_profile</code>。当前第一步不默认生成 SKG 新口播和 MiniMax 配音。</td></tr>
|
||||
<tr><td>原始音频文件</td><td><code>GET /jobs/{id}/audio.wav</code></td><td><code>sourceAudioUrl</code></td><td>返回拆轨得到的 wav;底部 <code>AudioStrip</code> 拉取该文件,用 Web Audio API 解码并计算波形峰值。原音频播放器驱动时间轴,播放时全局指针和当前字幕节点内指针同步移动。</td></tr>
|
||||
<tr><td>原始音频文件</td><td><code>GET /jobs/{id}/audio.wav</code></td><td><code>sourceAudioUrl</code></td><td>返回拆轨得到的 wav;当前主界面不再渲染底部音频条,右侧音频解析工作表直接使用 <code>transcript</code> 和 <code>audio_script</code> 展示文字与声音分析结果。</td></tr>
|
||||
<tr><td>改写配音文件</td><td><code>GET /jobs/{id}/audio-script.mp3</code></td><td><code>apiAssetUrl(job.audio_script.voice_url)</code></td><td>后续新配音阶段保留的 MiniMax T2A 产物。当前第一步不默认生成该文件。</td></tr>
|
||||
<tr><td>手动加帧</td><td><code>POST /jobs/{id}/frames?t=</code></td><td><code>addManualFrame</code></td><td>按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。</td></tr>
|
||||
<tr><td>Vision 识别</td><td><code>POST /frames/{idx}/describe</code></td><td><code>describeFrame</code></td><td>写入 frame.description,后续可从 objects 加候选元素。</td></tr>
|
||||
@@ -863,8 +863,8 @@ SubjectAsset {
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag gray">音频条</span></td>
|
||||
<td>音频解析工作表顶部触发音频解析,底部 <code>AudioStrip</code> 负责原音频播放、字幕/翻译、波形和声音/背景音分析预览。</td>
|
||||
<td>当前第一步不要默认展示新配音播放器或把 MiniMax 配音当作已完成结果。</td>
|
||||
<td>音频解析工作表顶部触发音频解析,结果在右侧原文案、中文翻译、逐句时间轴和声音/背景音分析区展示;底部 <code>AudioStrip</code> 当前不渲染。</td>
|
||||
<td>当前第一步不要默认展示底部音频条、新配音播放器,或把 MiniMax 配音当作已完成结果。</td>
|
||||
<td><code>web/components/audio-strip.tsx</code>、<code>pipeline_transcribe</code>、<code>AudioScript</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -884,14 +884,14 @@ SubjectAsset {
|
||||
<h3>已通</h3>
|
||||
<ul>
|
||||
<li>TK 链接 / 上传创建 job。</li>
|
||||
<li>视频下载或本地保存,ffmpeg 抽关键帧。</li>
|
||||
<li>视频下载或本地保存;后端会检测可用 ffmpeg/ffprobe,PATH 版本不可用时可 fallback 到本机静态 ffmpeg,避免 Homebrew 动态库损坏导致素材输入失败。</li>
|
||||
<li>手动按时间戳加关键帧。</li>
|
||||
<li>关键帧清洗水印,全图或区域清洗。</li>
|
||||
<li>Vision 识别关键帧,输出 scene、objects、style、suggested_prompt,并作为主体候选来源。</li>
|
||||
<li>“开始”会在下载完成后自动触发音频处理,不再默认自动抽帧、Vision 扫描或保存分镜初稿。</li>
|
||||
<li>主体候选确认、改名、删除和主体资产包生成能力保留在底层旧面板和接口中,当前第一步主界面不主动展示。</li>
|
||||
<li>分镜工作台 4 图槽和改造说明自动保存。</li>
|
||||
<li>音频文案轨:点击开始或提取音频后提取原文案、中文翻译、讲话人、语速节奏、背景音乐/环境声/音效。底部音频条可播放原音频并用指针逐段对齐字幕节点。</li>
|
||||
<li>音频文案轨:点击开始或提取音频后提取原文案、中文翻译、讲话人、语速节奏、背景音乐/环境声/音效;结果集中在右侧工作表展示。</li>
|
||||
<li>nano-banana-pro image-to-image 生图。</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -941,6 +941,18 @@ SubjectAsset {
|
||||
<h2>变更记录</h2>
|
||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||
<div class="changelog">
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-17 · 修复素材输入元数据解析并移除底部音频条</h3>
|
||||
<span class="tag rose">UI</span>
|
||||
<span class="tag orange">Bugfix</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>粘贴 TK 链接后视频已经下载到 <code>source.mp4</code>,但本机 Homebrew <code>ffprobe</code>/<code>ffmpeg</code> 因缺少 <code>libx265.215.dylib</code> 直接崩溃,后端误显示为“下载失败”。同时用户不再需要底部音频展示。</p>
|
||||
<p><strong>改动:</strong><code>api/main.py</code> 新增媒体二进制选择逻辑,先验证 PATH 里的 <code>ffmpeg/ffprobe</code> 是否可执行,失败时回退到本机静态 <code>ffmpeg</code>;没有可用 <code>ffprobe</code> 时用 <code>ffmpeg -i</code> 解析时长和分辨率。下载阶段把“元数据解析失败”和“下载失败”区分开。<code>web/app/page.tsx</code> 不再导入和渲染 <code>AudioStrip</code>,<code>AdRecreationBoard</code> 移除“打开音轨”按钮。</p>
|
||||
<p><strong>影响:</strong><code>api/main.py</code>、<code>web/app/page.tsx</code>、<code>web/components/ad-recreation-board.tsx</code>、<code>RULES.md</code>、<code>docs/source-analysis.html</code>。后续音频预览如果需要恢复,应先明确是否仍放在右侧工作表,而不是默认恢复底部浮层。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-17 · 收窄为第一步音频解析</h3>
|
||||
|
||||
@@ -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<Job[]>([])
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
||||
const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId])
|
||||
const [audioStripJobId, setAudioStripJobId] = useState<string | null>(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<Record<string, FrameExtractTarget>>({})
|
||||
@@ -163,10 +159,6 @@ export default function Home() {
|
||||
const flowRef = useRef<any>(null)
|
||||
const lastVideoPanelFocusKey = useRef("")
|
||||
|
||||
useEffect(() => {
|
||||
setClientReady(true)
|
||||
}, [])
|
||||
|
||||
const setSelectedFramesForJob = useCallback((jobId: string, updater: Set<number> | ((prev: Set<number>) => Set<number>)) => {
|
||||
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<ReturnType<typeof setInterval> | 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() {
|
||||
<div className="absolute bottom-4 right-4 z-30 pointer-events-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{clientReady && <AudioStrip job={audioStripJob} open={!!audioStripJob} onClose={() => setAudioStripJobId(null)} />}
|
||||
<Toaster theme="system" position="top-center" />
|
||||
</main>
|
||||
</>
|
||||
|
||||
@@ -384,9 +384,6 @@ export function AdRecreationBoard({
|
||||
<Mic className="h-3.5 w-3.5" />
|
||||
解析音频
|
||||
</ActionButton>
|
||||
<ActionButton disabled={!job?.source_audio_url && !job?.audio_script?.voice_url} variant="ghost" onClick={() => data.onOpenAudioStrip?.(job?.id)}>
|
||||
打开音轨
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user