-
-
-
{
- const f = e.target.files?.[0]
- if (f) data.onUploadFile(f)
- e.target.value = ""
- }}
- />
+ const TILES: Array<{ key: string; title: string; type: ColType; icon: ReactNode; step: number }> = [
+ { key: "input", title: "输入", type: "input", icon:
, step: 1 },
+ { key: "download", title: "下载", type: "process", icon:
, step: 2 },
+ { key: "split", title: "拆分", type: "process", icon:
, step: 3 },
+ { key: "keyframe", title: "关键帧", type: "ai", icon:
, step: 4 },
+ { key: "asr", title: "转录", type: "ai", icon:
, step: 5 },
+ { key: "translate", title: "翻译", type: "ai", icon:
, step: 6 },
+ { key: "rewrite", title: "改写", type: "ai", icon:
, step: 7 },
+ { key: "imagegen", title: "生图", type: "ai", icon:
, step: 8 },
+ { key: "videogen", title: "生视频", type: "ai", icon:
, step: 9 },
+ { key: "compose", title: "合成", type: "output", icon:
, step: 10 },
+ ]
+
+ // 单选展开:toggle 同一 key = 收起;点其他 key = 切换
+ const toggleTile = (key: string) => setExpanded((prev) => (prev.has(key) ? new Set() : new Set([key])))
+ const closeTile = (_key: string) => setExpanded(new Set())
+
+ return (
+
+ {/* Tile Bar — 一直显示 */}
+
+ {TILES.map((t) => {
+ const state = colState[t.key]
+ const isOpen = expanded.has(t.key)
+ return (
+
+ )
- {/* 2. Download */}
-
-
-
-
-
- {job?.url.startsWith("upload://") ? "本地上传 · 跳过下载" : "TikTok / yt-dlp 兼容站点"}
+ function renderSection(key: string): ReactNode {
+ return (
+ <>
+
+ {/* ---- Input ---- */}
+ {key === "input" && (
+
+
+ 链接 / 上传
+ setUrl(e.target.value)}
+ placeholder="粘贴 TikTok 链接"
+ disabled={isDownloading || data.submitting}
+ className="w-full text-[12px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/10 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40"
+ />
+
+ data.onSubmitUrl(url.trim())}
+ className="flex-1 text-[11.5px] py-1.5 rounded-md bg-white text-black hover:bg-white/90 disabled:opacity-30 inline-flex items-center justify-center gap-1"
+ >
+ {(data.submitting || isDownloading) && }
+ {isDownloading ? "下载中" : "提交"}
+
+ fileRef.current?.click()}
+ className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-white/[0.06] border border-white/15 hover:bg-white/[0.12] inline-flex items-center gap-1 disabled:opacity-30"
+ >
+ 上传
+
+ {
+ const f = e.target.files?.[0]
+ if (f) data.onUploadFile(f)
+ e.target.value = ""
+ }}
+ />
+
+
+ {hasVideo && (
+
+ 下一步
+
+ {(isAnalyzing || data.analyzing) ? <> 解析中…> : hasFrames ? "重新解析" : "▶ 开始解析"}
+
+ {job && (
+
+ {job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : job.url}
+
+ )}
+
+ )}
-
- {hasVideo && job && (
- <>
-
+ )}
+
+ {/* ---- Download ---- */}
+ {key === "download" && hasVideo && job && (
+
+
+
+
+ 元数据
+
+
分辨率
{job.width}×{job.height}
+
时长
{job.duration.toFixed(1)}s
+
来源
{job.url.startsWith("upload://") ? "上传" : "yt-dlp"}
+
+
+
+
+ )}
+ {key === "download" && !hasVideo && (
+ {isDownloading ? "yt-dlp 下载中…" : "等待提交"}
+ )}
+
+ {/* ---- Split ---- */}
+ {key === "split" && (
+
+
+ 视频流
+ → 关键帧抽取
+ ffmpeg fast seek + Laplacian 评分
+
+
+ 音频流
+ → ASR (16kHz mono wav)
+ ffmpeg -vn -ac 1 -ar 16000
+
+
+ )}
+
+ {/* ---- Keyframe ---- */}
+ {key === "keyframe" && (
+
+ {hasVideo && job && (
+
+ {
+ const t = videoRef.current?.currentTime ?? videoT
+ setAddingFrame(true)
+ try { await data.onAddManualFrame(t) } finally { setAddingFrame(false) }
+ }}
+ className="w-full text-[12px] py-2 rounded-md border border-dashed border-emerald-400/40 bg-emerald-400/5 hover:bg-emerald-400/10 text-emerald-300 disabled:opacity-50 inline-flex items-center justify-center gap-1.5"
+ >
+ {addingFrame ? : }
+ + 把视频 {videoT.toFixed(1)}s 加为关键帧
+
+
+ )}
+ {!hasFrames ? (
+
等待解析后抽取(默认 5 张)
+ ) : (
+
+ {job!.frames.map((f) => {
+ const isSel = data.selectedFrames.has(f.index)
+ return (
+
+ { e.stopPropagation(); data.onExpandFrame(f.index) }}
+ className="block w-full aspect-video bg-black relative overflow-hidden"
+ >
+
+ #{f.index + 1}
+ {f.timestamp.toFixed(1)}s
+
+
+ 分镜 {f.index + 1}
+ { e.stopPropagation(); data.onToggleFrame(f.index) }}
+ className={`text-[10px] px-1.5 py-0.5 rounded inline-flex items-center gap-0.5 ${isSel ? "bg-emerald-500 text-white" : "bg-white/10 text-[var(--text-soft)] border border-white/10"}`}
+ >
+
+ {isSel ? "已选" : "选用"}
+
+
+
+ )
+ })}
+
+ )}
+
+ )}
+
+ {/* ---- ASR / Translate ---- */}
+ {(key === "asr" || key === "translate") && (
+ !hasTranscript ? (
+
+ {colState.asr === "running" ? "Gemini 转录中…" : "等待关键帧抽取"}
+
+ ) : (
+
+ {job!.transcript.map((s) => (
+
+
+ {s.start.toFixed(1)}s → {s.end.toFixed(1)}s
+
+
+ EN{s.en}
+
+
+ ZH{s.zh || 翻译中…}
+
+
+ ))}
+
+ )
+ )}
+
+ {/* ---- Rewrite ---- */}
+ {key === "rewrite" && (
+
+
+ 产品信息
+
-
-
-
分辨率
-
{job.width}×{job.height}
+
模型 & 状态
+
模型:gemini-2.5-pro
+
下一冲刺接入
+
+
+ )}
+
+ {/* ---- ImageGen ---- */}
+ {key === "imagegen" && (
+
+
+
+
+
推荐
+
nano-banana-pro
+
Gemini 3 Pro Image
-
-
时长
-
{job.duration.toFixed(1)}s
+
+
备选
+
gpt-image-2
+
OpenAI
- >
+ {data.selectedFrames.size === 0 ? (
+
在「关键帧」里勾选后启动
+ ) : (
+
+ {Array.from({ length: data.selectedFrames.size }).map((_, i) => (
+
+ #{i + 1} 待生
+
+ ))}
+
+ )}
+
)}
-
-
- {/* 3. Split */}
-
-
-
-
- 视频流
- → 关键帧抽取
-
-
- 音频流
- → ASR (16kHz mono wav)
-
-
-
-
- {/* 4. Keyframe — 5 张缩略图 */}
-
-
-
- {hasVideo && job && (
-
- {
- const t = videoRef.current?.currentTime ?? videoT
- setAddingFrame(true)
- try { await data.onAddManualFrame(t) } finally { setAddingFrame(false) }
- }}
- className="w-full text-[11px] py-1.5 rounded-md border border-dashed border-emerald-400/40 bg-emerald-400/5 hover:bg-emerald-400/10 text-emerald-300 disabled:opacity-50 inline-flex items-center justify-center gap-1"
- >
- {addingFrame ? : }
- + 把视频 {videoT.toFixed(1)}s 加为关键帧
-
-
- )}
- {!hasFrames ? (
-
-
- 等待解析后抽取(默认 5 张)
-
-
- ) : (
- job!.frames.map((f) => {
- const isSel = data.selectedFrames.has(f.index)
- return (
-
- { e.stopPropagation(); data.onExpandFrame(f.index) }}
- className="block w-full aspect-video bg-black relative overflow-hidden"
- >
-
- #{f.index + 1}
- {f.timestamp.toFixed(1)}s
-
-
- 分镜 {f.index + 1}
- { e.stopPropagation(); data.onToggleFrame(f.index) }}
- className={`text-[10px] px-1.5 py-0.5 rounded inline-flex items-center gap-0.5 ${isSel ? "bg-emerald-500 text-white" : "bg-white/10 text-[var(--text-soft)] border border-white/10"}`}
- >
-
- {isSel ? "已选" : "选用"}
-
-
+ {/* ---- VideoGen ---- */}
+ {key === "videogen" && (
+
+ {["Sora 2 · SKG", "Seedance · 外部", "Kling · 外部"].map((m) => (
+
+ {m}
- )
- })
- )}
-
-
-
- {/* 5. ASR */}
-
-
-
- {!hasTranscript ? (
-
-
- {colState.asr === "running" ? "Gemini 转录中…" : "等待关键帧抽取"}
-
-
- ) : (
- job!.transcript.map((s) => (
-
-
- {s.start.toFixed(1)}s → {s.end.toFixed(1)}s
-
- {s.en}
-
- ))
- )}
-
-
-
- {/* 6. Translate */}
-
-
-
- {!hasZh ? (
-
- 等待 ASR 完成
-
- ) : (
- job!.transcript.map((s) => (
-
-
- {s.start.toFixed(1)}s → {s.end.toFixed(1)}s
-
- {s.zh || 翻译中…}
-
- ))
- )}
-
-
-
- {/* 7. Rewrite */}
-
-
-
-
- 产品信息
-
-
-
-
- 模型:gemini-2.5-pro
-
- 下一冲刺接入
-
-
-
-
- {/* 8. ImageGen */}
-
-
0 ? `${data.selectedFrames.size} 张待生` : "选关键帧"}`} state={colState.imagegen} />
-
-
-
-
- {data.selectedFrames.size === 0 ? (
-
-
- 选关键帧后,每张 → 1 张生成图
-
-
- ) : (
- Array.from({ length: data.selectedFrames.size }).map((_, i) => (
-
- 生成图 #{i + 1} · 待启动
-
- ))
- )}
-
-
-
- {/* 9. VideoGen */}
-
-
-
-
- 模型
-
- {["Sora 2 · SKG 网关", "Seedance · 外部 API", "Kling · 外部 API"].map((m) => (
-
{m}
))}
-
-
-
- 按改写文案生成视频片段
-
-
-
-
+ )}
- {/* 10. Compose */}
-
-
-
-
- 成品视频 · 待合成
-
-
-
- 视频片段 + 字幕 / TTS
-
本地 ffmpeg · 零 API
+ {/* ---- Compose ---- */}
+ {key === "compose" && (
+
+
+ 成品视频 · 待合成
+
+
+
+ 视频片段 + 字幕 / TTS
+
+ 本地 ffmpeg · 零 API
+
-
+ )}
+
-
+ )}
)
}