354 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|