| import type { PlayerRef } from "@remotion/player"; |
| import type { Project } from "../api/client"; |
| import { getSceneExportFrameSchedule } from "./sceneFrameSchedule"; |
| import { getTemplateConfig } from "../components/remotion/templateConfig"; |
|
|
| const EXPORT_CAPTURE_SCALE = 2; |
|
|
| function finitePositive(value: number, fallback: number): number { |
| return Number.isFinite(value) && value > 0 ? value : fallback; |
| } |
|
|
| function downloadBlob(blob: Blob, filename: string) { |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement("a"); |
| a.href = url; |
| a.download = filename; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| window.setTimeout(() => URL.revokeObjectURL(url), 2000); |
| } |
|
|
| function waitForPaint(): Promise<void> { |
| return new Promise((resolve) => |
| requestAnimationFrame(() => |
| requestAnimationFrame(() => window.setTimeout(resolve, 200)) |
| ) |
| ); |
| } |
|
|
| async function waitUntilFrame( |
| player: PlayerRef, |
| target: number, |
| maxMs = 3000 |
| ): Promise<void> { |
| const start = performance.now(); |
| while (performance.now() - start < maxMs) { |
| if (Math.abs(player.getCurrentFrame() - target) <= 1) { |
| await waitForPaint(); |
| await waitForPaint(); |
| return; |
| } |
| await new Promise((r) => requestAnimationFrame(r)); |
| } |
| await waitForPaint(); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| function loadCrossOriginDataUrl(src: string): Promise<string | null> { |
| return new Promise((resolve) => { |
| if (!src || src.startsWith("data:") || src.startsWith("blob:")) { |
| resolve(src || null); |
| return; |
| } |
| const img = new Image(); |
| img.crossOrigin = "anonymous"; |
| const sep = src.includes("?") ? "&" : "?"; |
| img.src = `${src}${sep}_b2v_export=1`; |
| img.onload = () => { |
| try { |
| const c = document.createElement("canvas"); |
| c.width = img.naturalWidth || 1; |
| c.height = img.naturalHeight || 1; |
| const ctx = c.getContext("2d"); |
| if (!ctx) { resolve(null); return; } |
| ctx.drawImage(img, 0, 0); |
| resolve(c.toDataURL("image/png")); |
| } catch { |
| resolve(null); |
| } |
| }; |
| img.onerror = () => resolve(null); |
| window.setTimeout(() => resolve(null), 12000); |
| }); |
| } |
|
|
| |
| function simulateObjectFit( |
| raw: HTMLImageElement, |
| dw: number, |
| dh: number, |
| fit: "cover" | "contain" |
| ): string { |
| const safeDw = Number.isFinite(dw) && dw > 0 ? dw : 1; |
| const safeDh = Number.isFinite(dh) && dh > 0 ? dh : 1; |
| const nw = raw.naturalWidth || safeDw; |
| const nh = raw.naturalHeight || safeDh; |
| if (!Number.isFinite(nw) || !Number.isFinite(nh) || nw <= 0 || nh <= 0) { |
| const c = document.createElement("canvas"); |
| c.width = Math.round(safeDw); |
| c.height = Math.round(safeDh); |
| return c.toDataURL("image/png"); |
| } |
| const c = document.createElement("canvas"); |
| c.width = Math.round(safeDw); |
| c.height = Math.round(safeDh); |
| const ctx = c.getContext("2d")!; |
| const s = fit === "cover" ? Math.max(safeDw / nw, safeDh / nh) : Math.min(safeDw / nw, safeDh / nh); |
| const fw = nw * s, fh = nh * s; |
| if (!Number.isFinite(fw) || !Number.isFinite(fh)) return c.toDataURL("image/png"); |
| ctx.drawImage(raw, (safeDw - fw) / 2, (safeDh - fh) / 2, fw, fh); |
| return c.toDataURL("image/png"); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async function inlineImagesForCapture(root: HTMLElement): Promise<() => void> { |
| const restoreFns: Array<() => void> = []; |
|
|
| |
| await Promise.all( |
| (Array.from(root.querySelectorAll("img")) as HTMLImageElement[]).map(async (img) => { |
| const src = img.src; |
| if (!src || src.startsWith("data:") || src.startsWith("blob:")) return; |
|
|
| const dataUrl = await loadCrossOriginDataUrl(src); |
| if (!dataUrl) return; |
|
|
| const computed = window.getComputedStyle(img); |
| const fit = computed.objectFit; |
| const dw = img.offsetWidth; |
| const dh = img.offsetHeight; |
|
|
| let finalUrl = dataUrl; |
| let patchStyle = false; |
|
|
| if ((fit === "cover" || fit === "contain") && dw > 0 && dh > 0) { |
| const raw = new Image(); |
| await new Promise<void>((res) => { raw.onload = () => res(); raw.onerror = () => res(); raw.src = dataUrl; }); |
| finalUrl = simulateObjectFit(raw, dw, dh, fit as "cover" | "contain"); |
| patchStyle = true; |
| } |
|
|
| const origSrc = img.src; |
| const origOF = img.style.objectFit; |
| const origW = img.style.width; |
| const origH = img.style.height; |
|
|
| img.src = finalUrl; |
| if (patchStyle) { |
| img.style.objectFit = "fill"; |
| img.style.width = `${dw}px`; |
| img.style.height = `${dh}px`; |
| } |
| try { await img.decode(); } catch { } |
|
|
| restoreFns.push(() => { |
| img.src = origSrc; |
| img.style.objectFit = origOF; |
| img.style.width = origW; |
| img.style.height = origH; |
| }); |
| }) |
| ); |
|
|
| |
| await Promise.all( |
| (Array.from(root.querySelectorAll<HTMLElement>("*"))).map(async (el) => { |
| const bg = el.style.backgroundImage || window.getComputedStyle(el).backgroundImage; |
| if (!bg || bg === "none" || !bg.includes("url(")) return; |
|
|
| const re = /url\(["']?([^"')]+)["']?\)/g; |
| const replacements: { search: string; data: string }[] = []; |
| let m: RegExpExecArray | null; |
| while ((m = re.exec(bg)) !== null) { |
| const u = m[1]; |
| if (!u || u.startsWith("data:") || u.startsWith("blob:")) continue; |
| const d = await loadCrossOriginDataUrl(u); |
| if (d) replacements.push({ search: u, data: d }); |
| } |
| if (!replacements.length) return; |
|
|
| let newBg = bg; |
| for (const { search, data } of replacements) newBg = newBg.replace(search, data); |
| const origInline = el.style.backgroundImage; |
| el.style.backgroundImage = newBg; |
| restoreFns.push(() => { el.style.backgroundImage = origInline; }); |
| }) |
| ); |
|
|
| await waitForPaint(); |
| return () => restoreFns.forEach((fn) => fn()); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| async function captureCompositionToDataUrl(playerContainer: HTMLElement, compositionWidth: number): Promise<string> { |
| |
| const captureRoot = playerContainer; |
|
|
| |
| |
| |
| |
| const containerW = finitePositive(captureRoot.offsetWidth, compositionWidth); |
| const dynamicScale = Math.max(EXPORT_CAPTURE_SCALE, compositionWidth / containerW); |
|
|
| let restoreImages: () => void = () => {}; |
| try { |
| restoreImages = await inlineImagesForCapture(captureRoot); |
| } catch { |
| |
| restoreImages = () => {}; |
| } |
|
|
| try { |
| |
| if ("fonts" in document) { |
| try { |
| await (document as Document & { fonts?: FontFaceSet }).fonts?.ready; |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| await waitForPaint(); |
|
|
| const { toPng } = await import("html-to-image"); |
|
|
| try { |
| const dataUrl = await toPng(captureRoot, { |
| pixelRatio: dynamicScale, |
| cacheBust: true, |
| skipFonts: false, |
| backgroundColor: "transparent", |
| }); |
| if (!dataUrl || dataUrl.length < 300) { |
| throw new Error("html-to-image produced empty output."); |
| } |
| return dataUrl; |
| } catch { |
| const html2canvas = (await import("html2canvas")).default; |
| let canvas: HTMLCanvasElement; |
| try { |
| canvas = await html2canvas(captureRoot, { |
| scale: dynamicScale, |
| useCORS: true, |
| allowTaint: false, |
| logging: false, |
| backgroundColor: null, |
| foreignObjectRendering: false, |
| }); |
| } catch { |
| |
| canvas = await html2canvas(captureRoot, { |
| scale: 1, |
| useCORS: true, |
| allowTaint: false, |
| logging: false, |
| backgroundColor: null, |
| foreignObjectRendering: false, |
| }); |
| } |
|
|
| const dataUrl = canvas.toDataURL("image/png"); |
| if (!dataUrl || dataUrl.length < 300) { |
| throw new Error("Capture produced an empty image β ensure the preview is fully loaded."); |
| } |
| return dataUrl; |
| } |
| } finally { |
| restoreImages(); |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| export async function captureSceneSnapshots( |
| player: PlayerRef, |
| project: Project, |
| timelineFractions?: number[] |
| ): Promise<{ dataUrl: string; title: string; safeSlug: string }[]> { |
| const schedule = getSceneExportFrameSchedule(project, timelineFractions); |
| if (!schedule.length) throw new Error("No scenes to export."); |
|
|
| const config = getTemplateConfig(project.template ?? "default"); |
| const isPortrait = project.aspect_ratio === "portrait"; |
| const compositionWidth = isPortrait ? config.baseHeight : config.baseWidth; |
|
|
| const wasPlaying = player.isPlaying(); |
| player.pause(); |
|
|
| const playerContainer = player.getContainerNode(); |
| if (!playerContainer) throw new Error("Preview not ready β let it fully load first."); |
|
|
| const out: { dataUrl: string; title: string; safeSlug: string }[] = []; |
|
|
| try { |
| for (const step of schedule) { |
| player.pause(); |
| player.seekTo(step.frame); |
| await waitUntilFrame(player, step.frame); |
| let dataUrl: string; |
| try { |
| dataUrl = await captureCompositionToDataUrl(playerContainer, compositionWidth); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : String(err); |
| throw new Error( |
| `Scene "${step.title}": could not capture preview at frame ${step.frame}. ${msg}` |
| ); |
| } |
| out.push({ dataUrl, title: step.title, safeSlug: step.safeSlug }); |
| } |
| } finally { |
| if (wasPlaying) { try { player.play(); } catch { } } |
| } |
|
|
| return out; |
| } |
|
|
| function projectBaseName(p: Project): string { |
| return (p.name ?? "project").replace(/\s+/g, "_").slice(0, 50); |
| } |
|
|
| |
|
|
| export async function exportScenesPptx( |
| player: PlayerRef, |
| project: Project, |
| timelineFractions?: number[] |
| ): Promise<void> { |
| const base = projectBaseName(project); |
| const shots = await captureSceneSnapshots(player, project, timelineFractions); |
| const { default: PptxGenJS } = await import("pptxgenjs"); |
| const pptx = new PptxGenJS(); |
|
|
| |
| |
| const firstImg = await new Promise<HTMLImageElement>((res, rej) => { |
| const i = new Image(); |
| i.onload = () => res(i); |
| i.onerror = () => rej(new Error("Could not decode captured frame.")); |
| i.src = shots[0].dataUrl; |
| }); |
| const iw = finitePositive(firstImg.naturalWidth, 1920); |
| const ih = finitePositive(firstImg.naturalHeight, 1080); |
| const slideW = 10; |
| const slideH = finitePositive(parseFloat(((ih / iw) * slideW).toFixed(4)), 5.625); |
| pptx.defineLayout({ name: "B2V_SLIDE", width: slideW, height: slideH }); |
| pptx.layout = "B2V_SLIDE"; |
|
|
| for (const s of shots) { |
| pptx.addSlide().addImage({ data: s.dataUrl, x: 0, y: 0, w: slideW, h: slideH }); |
| } |
|
|
| await pptx.writeFile({ fileName: `${base}_scenes.pptx` }); |
| } |
|
|
| export async function exportScenesPdf( |
| player: PlayerRef, |
| project: Project, |
| timelineFractions?: number[] |
| ): Promise<void> { |
| const base = projectBaseName(project); |
| const shots = await captureSceneSnapshots(player, project, timelineFractions); |
| const { jsPDF } = await import("jspdf"); |
| let doc: import("jspdf").jsPDF | null = null; |
|
|
| for (const s of shots) { |
| const img = await new Promise<HTMLImageElement>((res, rej) => { |
| const i = new Image(); |
| i.onload = () => res(i); |
| i.onerror = () => rej(new Error("Could not decode capture.")); |
| i.src = s.dataUrl; |
| }); |
| const w = finitePositive(img.naturalWidth, 1920); |
| const h = finitePositive(img.naturalHeight, 1080); |
|
|
| if (!doc) { |
| doc = new jsPDF({ unit: "pt", format: [w, h], orientation: w >= h ? "landscape" : "portrait", compress: true }); |
| } else { |
| doc.addPage([w, h], w >= h ? "l" : "p"); |
| } |
| doc.addImage(s.dataUrl, "PNG", 0, 0, w, h); |
| } |
|
|
| if (!doc) throw new Error("No pages generated."); |
| doc.save(`${base}_scenes.pdf`); |
| } |
|
|
| |
| export async function exportScenesPng( |
| player: PlayerRef, |
| project: Project, |
| timelineFractions?: number[] |
| ): Promise<void> { |
| const base = projectBaseName(project); |
| const shots = await captureSceneSnapshots(player, project, timelineFractions); |
|
|
| const { default: JSZip } = await import("jszip"); |
| const zip = new JSZip(); |
|
|
| for (let i = 0; i < shots.length; i++) { |
| const s = shots[i]; |
| const b64 = s.dataUrl.split(",")[1] ?? ""; |
| if (!b64) throw new Error(`Empty image data for scene ${i + 1}.`); |
| zip.file(`${String(i + 1).padStart(2, "0")}_${s.safeSlug}.png`, b64, { base64: true }); |
| } |
|
|
| const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } }); |
| downloadBlob(blob, `${base}_scenes.zip`); |
| } |
|
|