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((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 { 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((resolve) => window.requestAnimationFrame(() => resolve())); } export async function renderVideoCanvasStoryboardComposition( project: VideoCanvasStoryboardCompositionProject, onProgress?: (progress: number) => void, ): Promise { 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((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(); } }