112 lines
4.0 KiB
TypeScript

"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<string, string> }>({
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<HTMLMediaElement, { source: MediaElementAudioSourceNode; gain: GainNode }>;
}>();
useEffect(() => {
setFailed(false);
const controller = new AbortController(),
urls: Record<string, string> = {};
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));
},
};
}