Files
20260512-skg-tk/web/app/page.tsx

163 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<Job | null>(null)
const [submitting, setSubmitting] = useState(false)
const [selected, setSelected] = useState<Set<number>>(new Set())
const videoRef = useRef<HTMLVideoElement>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const transcribeTriggeredRef = useRef<string | null>(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 (
<main className="relative min-h-screen text-white overflow-x-hidden">
<div className="ambient-glow" />
<div className="relative z-10 mx-auto max-w-6xl px-6 py-10 space-y-8">
{/* Header */}
<header className="space-y-2">
<div className="text-xs uppercase tracking-[0.3em] text-white/40">SKG · AI Material Pipeline</div>
<h1 className="font-serif text-4xl md:text-5xl leading-tight">
TK
<span className="text-white/40 ml-3 text-xl font-sans tracking-tight">/ Verification Prototype</span>
</h1>
<p className="text-white/50 text-sm max-w-2xl">
TikTok + Gemini / /
</p>
</header>
{/* URL 输入 */}
<section>
<UrlInput loading={submitting || (job !== null && job.status !== "transcribed" && job.status !== "failed")} onSubmit={handleSubmit} />
</section>
{job && (
<>
{/* 状态条 */}
<section>
<JobStatusBar job={job} />
</section>
{/* 视频预览 + 关键帧 */}
{job.video_url && (
<section className="grid grid-cols-1 lg:grid-cols-[1fr_2fr] gap-6">
<div className="glass-card overflow-hidden">
<video
ref={videoRef}
src={videoUrl(job.id)}
controls
className="w-full aspect-video bg-black"
/>
<div className="px-4 py-2.5 text-[11px] font-mono text-white/40 border-t border-white/10">
{job.width}×{job.height} · {job.duration?.toFixed(1)}s
</div>
</div>
<div>
<div className="mb-6">
<div className="font-serif text-lg mb-1"> · Keyframes</div>
<div className="text-xs text-white/40"> 10 </div>
</div>
<KeyframeGallery frames={job.frames} selected={selected} onToggle={toggleFrame} />
</div>
</section>
)}
{/* 双语转录 */}
{(job.frames.length > 0 || job.transcript.length > 0) && (
<section>
<div className="mb-4">
<div className="font-serif text-lg mb-1"> · Transcript</div>
<div className="text-xs text-white/40"></div>
</div>
<TranscriptPanel
segments={job.transcript}
loading={job.status === "transcribing"}
onSeek={handleSeek}
/>
</section>
)}
</>
)}
{!job && (
<section className="text-center py-16">
<div className="font-serif text-2xl text-white/30"> TikTok </div>
</section>
)}
{/* Footer */}
<footer className="pt-8 pb-4 text-[11px] text-white/30 font-mono flex items-center justify-between border-t border-white/5">
<div>SKG TK · MVP 1-4</div>
<div>4290 · {process.env.NEXT_PUBLIC_API_BASE ?? "localhost:4291"}</div>
</footer>
</div>
<Toaster theme="dark" position="top-right" />
</main>
)
}