"use client" import { useCallback, useEffect, useRef, useState } from "react" import { Toaster, toast } from "sonner" import { UrlInput } from "@/components/url-input" import { JobStatusBar } from "@/components/job-status" import { KeyframeGallery } from "@/components/keyframe-gallery" import { TranscriptPanel } from "@/components/transcript-panel" import { createJob, getJob, triggerTranscribe, videoUrl, type Job } from "@/lib/api" export default function Home() { const [job, setJob] = useState(null) const [submitting, setSubmitting] = useState(false) const [selected, setSelected] = useState>(new Set()) const videoRef = useRef(null) const pollRef = useRef | null>(null) const transcribeTriggeredRef = useRef(null) const handleSubmit = useCallback(async (url: string) => { setSubmitting(true) setSelected(new Set()) transcribeTriggeredRef.current = null try { const created = await createJob(url) setJob(created) toast.success(`已创建任务 ${created.id.slice(0, 8)}`) } catch (e) { toast.error("提交失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubmitting(false) } }, []) // 轮询 job 状态 useEffect(() => { if (!job) return if (job.status === "transcribed" || job.status === "failed") { if (pollRef.current) clearInterval(pollRef.current) return } pollRef.current = setInterval(async () => { try { const latest = await getJob(job.id) setJob(latest) } catch { // silent } }, 1500) return () => { if (pollRef.current) clearInterval(pollRef.current) } }, [job?.id, job?.status]) // 抽帧完成后自动触发 ASR useEffect(() => { if (!job) return if (job.status !== "frames_extracted") return if (transcribeTriggeredRef.current === job.id) return transcribeTriggeredRef.current = job.id triggerTranscribe(job.id).catch((e) => toast.error("启动转录失败:" + e.message)) }, [job?.id, job?.status]) const toggleFrame = (idx: number) => { setSelected((prev) => { const next = new Set(prev) if (next.has(idx)) next.delete(idx) else if (next.size < 10) next.add(idx) return next }) } const handleSeek = (sec: number) => { if (videoRef.current) { videoRef.current.currentTime = sec videoRef.current.play().catch(() => {}) } } return (
{/* Header */}
SKG · AI Material Pipeline

TK 二创工作台 / Verification Prototype

粘贴 TikTok 链接 → 自动抽取关键帧 + Gemini 双语转录 → 后续接入文案改写 / 生图 / 生视频。

{/* URL 输入 */}
{job && ( <> {/* 状态条 */}
{/* 视频预览 + 关键帧 */} {job.video_url && (
关键帧 · Keyframes
自动抽取,点击勾选最多 10 张作为生图参考
)} {/* 双语转录 */} {(job.frames.length > 0 || job.transcript.length > 0) && (
双语转录 · Transcript
点击段落跳转视频时间点
)} )} {!job && (
↑ 粘贴一条 TikTok 链接开始
)} {/* Footer */}
SKG TK 二创验证 · MVP 第一冲刺(步骤 1-4)
4290 · {process.env.NEXT_PUBLIC_API_BASE ?? "localhost:4291"}
) }