112 lines
4.0 KiB
TypeScript
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));
|
|
},
|
|
};
|
|
}
|