"use client"; import { useEffect, useRef, useState } from "react"; import type { EditPlan } from "@/lib/video-canvas/smart-edit/contract"; /** Only amplified cross-origin sources need a same-origin copy for Web Audio. */ export function usePreviewGain(plan: EditPlan, onError: (message: string) => void) { const remote = [ ...new Set( [...plan.clips, ...(plan.audioClips || [])] .filter((clip) => clip.gain > 1) .map((clip) => plan.assets.find((asset) => asset.id === clip.assetId)!.url) .filter( (url) => typeof window !== "undefined" && new URL(url, window.location.href).origin !== window.location.origin, ), ), ].sort(); const key = JSON.stringify(remote); const [copies, setCopies] = useState<{ key: string; urls: Record }>({ key: "", urls: {}, }); const [attempt, setAttempt] = useState(0), [failed, setFailed] = useState(false); const error = useRef(onError); error.current = onError; const graph = useRef<{ context: AudioContext; nodes: Map; }>(); useEffect(() => { setFailed(false); const controller = new AbortController(), urls: Record = {}; void Promise.all( (JSON.parse(key) as string[]).map(async (url) => { const response = await fetch("/api/canvas/download-image", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, name: "剪辑音量预览" }), signal: controller.signal, }); if (!response.ok) throw Error("无法读取音量预览素材,请检查链接后重试"); const blob = await response.blob(); if (!blob.size) throw Error("音量预览素材为空"); if (!controller.signal.aborted) urls[url] = URL.createObjectURL(blob); }), ) .then(() => { if (!controller.signal.aborted) setCopies({ key, urls }); }) .catch((e) => { if (!controller.signal.aborted) { setFailed(true); error.current(`${e.message},可点击播放重试`); } }); return () => { controller.abort(); Object.values(urls).forEach(URL.revokeObjectURL); }; }, [key, attempt]); useEffect( () => () => { void graph.current?.context.close(); graph.current = undefined; }, [], ); return { retry() { if (failed) setAttempt((value) => value + 1); }, source(url: string) { return copies.key === key ? copies.urls[url] || url : url; }, ready(url: string) { return !remote.includes(url) || (copies.key === key && Boolean(copies.urls[url])); }, retain(elements: HTMLMediaElement[]) { for (const [element, node] of graph.current?.nodes || []) if (!elements.includes(element)) { element.pause(); node.source.disconnect(); node.gain.disconnect(); graph.current!.nodes.delete(element); } }, volume(element: HTMLMediaElement, gain: number, playing: boolean) { let node = graph.current?.nodes.get(element); if (gain > 1 && !node) { graph.current ||= { context: new AudioContext(), nodes: new Map() }; const source = graph.current.context.createMediaElementSource(element), amplifier = graph.current.context.createGain(); source.connect(amplifier); amplifier.connect(graph.current.context.destination); node = { source, gain: amplifier }; graph.current.nodes.set(element, node); } if (node) { element.volume = 1; node.gain.gain.value = Math.max(0, gain); if (playing && graph.current?.context.state === "suspended") void graph.current.context .resume() .catch(() => error.current("请再次点击播放以启用音频预览")); } else element.volume = Math.min(1, Math.max(0, gain)); }, }; }