ai-chatbot/lib/video-canvas/storyboard-composition-render.ts

354 lines
11 KiB
TypeScript

import {
getVideoCanvasCompositionClipDuration,
getVideoCanvasCompositionDuration,
type VideoCanvasStoryboardCompositionClip,
type VideoCanvasStoryboardCompositionProject,
} from "@/lib/video-canvas/storyboard-composition";
type WebkitAudioWindow = Window &
typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
};
type LoadedCompositionClip = {
objectUrl: string;
video: HTMLVideoElement;
};
export type VideoCanvasCompositionRenderResult = {
blob: Blob;
durationSeconds: number;
height: number;
width: number;
};
function getRecorderMimeType() {
const candidates = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
];
return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) || "";
}
function getCanvasVideoSourceUrl(mediaUrl: string) {
const parsedUrl = new URL(mediaUrl, window.location.origin);
return parsedUrl.origin === window.location.origin
? parsedUrl.toString()
: `/api/canvas/video-source?url=${encodeURIComponent(parsedUrl.toString())}`;
}
function waitForMediaEvent(
target: HTMLMediaElement,
eventName: "loadedmetadata" | "seeked",
) {
return new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(
() => reject(new Error(`视频 ${eventName} 超时`)),
20_000,
);
const cleanup = () => {
window.clearTimeout(timeout);
target.removeEventListener(eventName, handleSuccess);
target.removeEventListener("error", handleError);
};
const handleSuccess = () => {
cleanup();
resolve();
};
const handleError = () => {
cleanup();
reject(new Error("视频素材无法解码"));
};
target.addEventListener(eventName, handleSuccess, { once: true });
target.addEventListener("error", handleError, { once: true });
});
}
async function loadCompositionClip(
clip: VideoCanvasStoryboardCompositionClip,
): Promise<LoadedCompositionClip> {
const response = await fetch(getCanvasVideoSourceUrl(clip.mediaUrl), {
credentials: "include",
});
if (!response.ok) throw new Error(`视频素材加载失败:${clip.title}`);
const objectUrl = URL.createObjectURL(await response.blob());
const video = document.createElement("video");
video.playsInline = true;
video.preload = "auto";
video.src = objectUrl;
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
await waitForMediaEvent(video, "loadedmetadata");
}
return { objectUrl, video };
}
export function getVideoCanvasCompositionOutputSize(
sourceWidth: number,
sourceHeight: number,
maximumDimension = 1280,
) {
const width = Math.max(2, sourceWidth || 1280);
const height = Math.max(2, sourceHeight || 720);
const scale = Math.min(1, maximumDimension / Math.max(width, height));
return {
height: Math.max(2, Math.round((height * scale) / 2) * 2),
width: Math.max(2, Math.round((width * scale) / 2) * 2),
};
}
function drawVideoContained(
context: CanvasRenderingContext2D,
video: HTMLVideoElement,
width: number,
height: number,
) {
context.fillStyle = "#000";
context.fillRect(0, 0, width, height);
const sourceWidth = Math.max(1, video.videoWidth);
const sourceHeight = Math.max(1, video.videoHeight);
const scale = Math.min(width / sourceWidth, height / sourceHeight);
const drawWidth = sourceWidth * scale;
const drawHeight = sourceHeight * scale;
context.drawImage(
video,
(width - drawWidth) / 2,
(height - drawHeight) / 2,
drawWidth,
drawHeight,
);
}
function drawTransition(
context: CanvasRenderingContext2D,
previousFrame: HTMLCanvasElement,
clip: VideoCanvasStoryboardCompositionClip,
elapsedSeconds: number,
width: number,
height: number,
) {
if (!(clip.transition && elapsedSeconds < 0.5)) return;
const progress = Math.max(0, Math.min(1, elapsedSeconds / 0.5));
if (clip.transition === "fade") {
context.save();
context.globalAlpha = 1 - progress;
context.drawImage(previousFrame, 0, 0, width, height);
context.restore();
return;
}
const midpointProgress = progress < 0.5 ? progress * 2 : (1 - progress) * 2;
if (progress < 0.5) {
context.save();
context.globalAlpha = 1 - progress * 2;
context.drawImage(previousFrame, 0, 0, width, height);
context.restore();
}
context.save();
context.globalAlpha = midpointProgress;
context.fillStyle = clip.transition === "fade-white" ? "#fff" : "#000";
context.fillRect(0, 0, width, height);
context.restore();
}
function drawSubtitles(
context: CanvasRenderingContext2D,
project: VideoCanvasStoryboardCompositionProject,
timelineSeconds: number,
width: number,
height: number,
) {
const active = project.subtitles.filter(
(subtitle) =>
timelineSeconds >= subtitle.startSeconds &&
timelineSeconds <= subtitle.endSeconds &&
subtitle.text.trim(),
);
if (!active.length) return;
const fontSize = Math.max(18, Math.round(width / 38));
context.save();
context.font = `600 ${fontSize}px sans-serif`;
context.textAlign = "center";
context.textBaseline = "middle";
active.slice(0, 2).forEach((subtitle, index) => {
const text = subtitle.text.trim();
const metrics = context.measureText(text);
const boxWidth = Math.min(width - 48, metrics.width + 36);
const y = height - 54 - index * (fontSize + 20);
context.fillStyle = "rgba(0,0,0,0.62)";
context.fillRect((width - boxWidth) / 2, y - fontSize, boxWidth, fontSize + 18);
context.fillStyle = "#fff";
context.fillText(text, width / 2, y);
});
context.restore();
}
function nextAnimationFrame() {
return new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
}
export async function renderVideoCanvasStoryboardComposition(
project: VideoCanvasStoryboardCompositionProject,
onProgress?: (progress: number) => void,
): Promise<VideoCanvasCompositionRenderResult> {
if (!project.clips.length) throw new Error("请先添加视频片段");
if (typeof MediaRecorder === "undefined") {
throw new Error("当前浏览器不支持成片导出");
}
const loadedClips: LoadedCompositionClip[] = [];
const AudioContextCtor =
window.AudioContext || (window as WebkitAudioWindow).webkitAudioContext;
const audioContext = AudioContextCtor ? new AudioContextCtor() : null;
const audioDestination = audioContext?.createMediaStreamDestination() || null;
let outputStream: MediaStream | null = null;
let recorder: MediaRecorder | null = null;
try {
for (const clip of project.clips) {
loadedClips.push(await loadCompositionClip(clip));
}
const firstVideo = loadedClips[0].video;
const { width, height } = getVideoCanvasCompositionOutputSize(
firstVideo.videoWidth,
firstVideo.videoHeight,
);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("成片渲染画布创建失败");
const previousFrame = document.createElement("canvas");
previousFrame.width = width;
previousFrame.height = height;
const previousContext = previousFrame.getContext("2d");
if (!previousContext) throw new Error("转场画布创建失败");
if (audioContext && audioDestination) {
for (const loaded of loadedClips) {
audioContext.createMediaElementSource(loaded.video).connect(audioDestination);
}
await audioContext.resume();
}
const canvasStream = canvas.captureStream(30);
outputStream = new MediaStream([
...canvasStream.getVideoTracks(),
...(audioDestination?.stream.getAudioTracks() || []),
]);
const mimeType = getRecorderMimeType();
recorder = new MediaRecorder(
outputStream,
mimeType ? { mimeType, videoBitsPerSecond: 5_000_000 } : undefined,
);
const chunks: BlobPart[] = [];
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size) chunks.push(event.data);
});
const completed = new Promise<Blob>((resolve, reject) => {
recorder?.addEventListener(
"error",
() => reject(new Error("成片编码失败")),
{ once: true },
);
recorder?.addEventListener(
"stop",
() =>
resolve(
new Blob(chunks, {
type: recorder?.mimeType || "video/webm",
}),
),
{ once: true },
);
});
const totalDuration = getVideoCanvasCompositionDuration(project);
let timelineOffset = 0;
context.fillStyle = "#000";
context.fillRect(0, 0, width, height);
previousContext.drawImage(canvas, 0, 0);
recorder.start(200);
for (let index = 0; index < project.clips.length; index += 1) {
const clip = project.clips[index];
const video = loadedClips[index].video;
const sourceDuration = Number.isFinite(video.duration)
? video.duration
: clip.durationSeconds;
const startSeconds = Math.min(
Math.max(0, clip.inPointSeconds),
Math.max(0, sourceDuration - 0.05),
);
const endSeconds = Math.min(
sourceDuration,
Math.max(startSeconds + 0.05, clip.outPointSeconds),
);
const timelineDuration = Math.max(
0.05,
(endSeconds - startSeconds) / clip.speed,
);
video.currentTime = startSeconds;
if (startSeconds > 0) await waitForMediaEvent(video, "seeked");
video.playbackRate = clip.speed;
await video.play();
const startedAt = performance.now();
while ((performance.now() - startedAt) / 1000 < timelineDuration) {
const elapsedSeconds = (performance.now() - startedAt) / 1000;
drawVideoContained(context, video, width, height);
drawTransition(
context,
previousFrame,
clip,
elapsedSeconds,
width,
height,
);
drawSubtitles(
context,
project,
timelineOffset + elapsedSeconds,
width,
height,
);
onProgress?.(
Math.min(0.99, (timelineOffset + elapsedSeconds) / totalDuration),
);
await nextAnimationFrame();
}
video.pause();
drawVideoContained(context, video, width, height);
drawSubtitles(
context,
project,
timelineOffset + timelineDuration,
width,
height,
);
previousContext.clearRect(0, 0, width, height);
previousContext.drawImage(canvas, 0, 0);
timelineOffset += getVideoCanvasCompositionClipDuration(clip);
}
recorder.stop();
const blob = await completed;
if (!blob.size) throw new Error("成片导出结果为空");
onProgress?.(1);
return { blob, durationSeconds: totalDuration, height, width };
} finally {
for (const loaded of loadedClips) {
loaded.video.pause();
loaded.video.removeAttribute("src");
loaded.video.load();
URL.revokeObjectURL(loaded.objectUrl);
}
if (recorder?.state !== "inactive") recorder?.stop();
outputStream?.getTracks().forEach((track) => track.stop());
if (audioContext?.state !== "closed") await audioContext?.close();
}
}