Files
20260512-skg-tk/web/components/audio-strip.tsx
2026-05-14 10:53:54 +08:00

256 lines
10 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 { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"
import { ChevronDown, ChevronUp, GripHorizontal, Mic2, Volume2 } from "lucide-react"
import { apiAssetUrl, sourceAudioUrl, type Job, type TranscriptSegment } from "@/lib/api"
const STORAGE_KEY = "skg.audio-strip.height"
const MIN_HEIGHT = 132
const MAX_HEIGHT = 420
const DEFAULT_HEIGHT = 236
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function fallbackPeaks(count: number, seedText: string) {
let seed = 0
for (let i = 0; i < seedText.length; i++) seed = (seed * 31 + seedText.charCodeAt(i)) % 9973
return Array.from({ length: count }, (_, i) => {
const wave = Math.sin((i + seed) * 0.43) * 0.35 + Math.sin((i + seed) * 0.11) * 0.25
const pulse = ((i + seed) % 9) / 18
return clamp(0.22 + Math.abs(wave) + pulse, 0.18, 1)
})
}
function slicePeaks(peaks: number[], start: number, end: number, duration: number, count = 56) {
if (peaks.length === 0 || duration <= 0 || end <= start) return fallbackPeaks(count, `${start}-${end}`)
const from = clamp(Math.floor((start / duration) * peaks.length), 0, peaks.length - 1)
const to = clamp(Math.ceil((end / duration) * peaks.length), from + 1, peaks.length)
const source = peaks.slice(from, to)
return Array.from({ length: count }, (_, i) => {
const a = Math.floor((i / count) * source.length)
const b = Math.max(a + 1, Math.floor(((i + 1) / count) * source.length))
return Math.max(...source.slice(a, b), 0.12)
})
}
function Waveform({ peaks, active = false }: { peaks: number[]; active?: boolean }) {
return (
<div className="flex h-10 items-center gap-[2px] rounded-md border border-white/10 bg-black/20 px-2">
{peaks.map((p, i) => (
<div
key={i}
className={active ? "bg-emerald-300/80" : "bg-violet-300/65"}
style={{
width: 3,
height: `${Math.round(8 + p * 28)}px`,
borderRadius: 999,
opacity: 0.42 + p * 0.45,
}}
/>
))}
</div>
)
}
function SegmentCard({
segment,
peaks,
duration,
}: {
segment: TranscriptSegment
peaks: number[]
duration: number
}) {
const segDuration = Math.max(1.2, segment.end - segment.start)
const width = clamp(180 + segDuration * 42, 220, 520)
const segPeaks = slicePeaks(peaks, segment.start, segment.end, duration)
return (
<article
className="shrink-0 rounded-lg border border-white/10 bg-white/[0.045] p-3 shadow-[0_12px_30px_-22px_rgba(0,0,0,0.8)]"
style={{ width }}
>
<div className="mb-2 flex items-center justify-between gap-3">
<span className="font-mono text-[10px] text-[var(--text-faint)]">
{segment.start.toFixed(1)}s to {segment.end.toFixed(1)}s
</span>
<span className="rounded-full border border-white/10 px-2 py-0.5 text-[9.5px] uppercase tracking-widest text-[var(--text-faint)]">
#{segment.index + 1}
</span>
</div>
<div className="space-y-2">
{segment.en && (
<div>
<div className="mb-1 text-[9.5px] uppercase tracking-widest text-violet-200/70">English</div>
<p className="line-clamp-3 text-[12px] leading-relaxed text-[var(--text-strong)]">{segment.en}</p>
</div>
)}
<div>
<div className="mb-1 text-[9.5px] uppercase tracking-widest text-emerald-200/75"></div>
<p className="line-clamp-3 text-[12.5px] leading-relaxed text-[var(--text-strong)]">
{segment.zh || <span className="text-[var(--text-faint)] italic">...</span>}
</p>
</div>
<Waveform peaks={segPeaks} />
</div>
</article>
)
}
async function decodeWaveform(url: string, targetPeaks = 1800) {
const res = await fetch(url)
if (!res.ok) throw new Error(`audio ${res.status}`)
const arrayBuffer = await res.arrayBuffer()
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!AudioContextClass) throw new Error("AudioContext unavailable")
const ctx = new AudioContextClass()
try {
const buffer = await ctx.decodeAudioData(arrayBuffer.slice(0))
const data = buffer.getChannelData(0)
const bucket = Math.max(1, Math.floor(data.length / targetPeaks))
let maxPeak = 0.01
const raw: number[] = []
for (let i = 0; i < targetPeaks; i++) {
const start = i * bucket
const end = Math.min(data.length, start + bucket)
let peak = 0
for (let j = start; j < end; j++) peak = Math.max(peak, Math.abs(data[j] || 0))
raw.push(peak)
maxPeak = Math.max(maxPeak, peak)
}
return raw.map((p) => clamp(p / maxPeak, 0.08, 1))
} finally {
void ctx.close().catch(() => {})
}
}
export function AudioStrip({ job }: { job: Job | null }) {
const [collapsed, setCollapsed] = useState(false)
const [height, setHeight] = useState(DEFAULT_HEIGHT)
const [peaks, setPeaks] = useState<number[]>([])
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null)
const transcript = job?.transcript ?? []
const audioScript = job?.audio_script
const voiceUrl = apiAssetUrl(audioScript?.voice_url)
const hasAudio = !!job && (transcript.length > 0 || !!audioScript?.rewritten_text || job.status === "transcribing")
const duration = useMemo(() => {
const lastTranscriptEnd = transcript.reduce((max, s) => Math.max(max, s.end || 0), 0)
return Math.max(job?.duration ?? 0, lastTranscriptEnd, 1)
}, [job?.duration, transcript])
useEffect(() => {
if (typeof window === "undefined") return
const stored = Number(window.localStorage.getItem(STORAGE_KEY) || "")
if (Number.isFinite(stored) && stored > 0) setHeight(clamp(stored, MIN_HEIGHT, MAX_HEIGHT))
}, [])
useEffect(() => {
let cancelled = false
setPeaks([])
if (!job?.id || !hasAudio) return
decodeWaveform(sourceAudioUrl(job.id))
.then((next) => {
if (!cancelled) setPeaks(next)
})
.catch(() => {
if (!cancelled) setPeaks(fallbackPeaks(1800, `${job.id}-${transcript.length}`))
})
return () => {
cancelled = true
}
}, [job?.id, hasAudio, transcript.length])
if (!hasAudio || !job) return null
const startDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault()
dragRef.current = { startY: e.clientY, startHeight: height }
const onMove = (ev: PointerEvent) => {
if (!dragRef.current) return
const next = clamp(dragRef.current.startHeight + (dragRef.current.startY - ev.clientY), MIN_HEIGHT, MAX_HEIGHT)
setHeight(next)
}
const onUp = () => {
if (dragRef.current) {
try { window.localStorage.setItem(STORAGE_KEY, String(height)) } catch {}
}
dragRef.current = null
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
}
return (
<aside
className="pointer-events-auto absolute inset-x-4 bottom-4 z-40 overflow-hidden rounded-xl border border-white/12 bg-[rgba(10,13,28,0.88)] shadow-[0_24px_80px_-28px_rgba(0,0,0,0.85)] backdrop-blur-xl"
style={{ height: collapsed ? 48 : height }}
>
<div
className="flex h-4 cursor-ns-resize items-center justify-center border-b border-white/8 bg-white/[0.035]"
onPointerDown={startDrag}
title="拖拽调整音频条高度"
>
<GripHorizontal className="h-3.5 w-3.5 text-white/45" />
</div>
<div className="flex h-8 items-center justify-between gap-3 border-b border-white/8 px-3">
<div className="flex min-w-0 items-center gap-2">
<Mic2 className="h-3.5 w-3.5 shrink-0 text-violet-200" />
<span className="truncate text-[12px] font-semibold text-white/90"> · / / </span>
<span className="rounded-full border border-white/10 px-2 py-0.5 text-[10px] text-white/45">{transcript.length || 0} </span>
</div>
<div className="flex items-center gap-2">
{voiceUrl && (
<div className="hidden items-center gap-1.5 text-[10px] text-emerald-200/80 sm:flex">
<Volume2 className="h-3.5 w-3.5" />
MiniMax ready
</div>
)}
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-white/10 text-white/65 transition hover:bg-white/10 hover:text-white"
title={collapsed ? "展开音频条" : "收起音频条"}
>
{collapsed ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
</div>
</div>
{!collapsed && (
<div className="grid h-[calc(100%-48px)] grid-cols-[minmax(0,1fr)_300px] gap-3 p-3 max-lg:grid-cols-1">
<div className="min-w-0 overflow-x-auto overflow-y-hidden pb-1">
{transcript.length > 0 ? (
<div className="flex h-full items-stretch gap-3">
{transcript.map((segment) => (
<SegmentCard key={segment.index} segment={segment} peaks={peaks} duration={duration} />
))}
</div>
) : (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-white/12 text-[12px] text-white/45">
</div>
)}
</div>
<div className="min-h-0 overflow-y-auto rounded-lg border border-emerald-300/20 bg-emerald-300/[0.07] p-3 max-lg:hidden">
<div className="mb-2 text-[10px] uppercase tracking-widest text-emerald-100/70"> · SKG </div>
<p className="text-[12.5px] leading-relaxed text-white/90">
{audioScript?.rewritten_text || "等待转录完成后生成适合 SKG 产品视频的口播文案。"}
</p>
{voiceUrl && (
<audio controls src={voiceUrl} className="mt-3 h-8 w-full" />
)}
{audioScript?.product_brief && (
<div className="mt-3 border-t border-white/10 pt-2 text-[11px] leading-relaxed text-white/55">
{audioScript.product_brief}
</div>
)}
</div>
</div>
)}
</aside>
)
}