// DDR-148 — video/animation exporter (mp4 · webm · gif). // // Drives bin/_video-playwright.mjs, which picks one of two capture strategies: // • mp4/webm of a registered video-comp — DDR-148 addendum (audio export): // window.__maude_render_video__ → @remotion/web-renderer's renderMediaOnWeb, // video+audio in one pass (Remotion owns the TransitionSeries/volume-closure // timeline math). `getWebRendererBundle()` supplies the in-page renderer. // • everything else (gif, ordinary/CSS-WAAPI artboards, or no registered // comp) — the original frame-step spine: steps the artboard frame-by-frame // via window.__maude_seek__ and encodes in-page with mediabunny (H.264/avc // MP4, VP9/VP8 WebM fallback) or gifenc. `getEncodeLibBundle()` supplies // this in-page encoder, and doubles as the render-lib path's fallback when // the target has no registered comp (the shim decides at runtime — see // _video-playwright.mjs for the exact routing condition). // Scope is always `artboard`. No native binaries; the capture Chromium is the // same one every other exporter already uses (DDR-041). import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { getEncodeLibBundle, getWebRendererBundle } from './_browser-bundles.ts'; import { exportShimPath, runShim } from './_runtime.ts'; import { type ExportDegradation, hasAudioStream, remedyFor } from './degraded.ts'; import { canvasShellUrl, type ExportContext, type ExportHooks, type ExportOptions, type ExportResult, } from './index.ts'; import type { Target } from './scope.ts'; import { audioRefusalMessage, scanUnsupportedMedia, type UnsupportedFinding, } from './unsupported-media.ts'; /** Resolve a client-influenced canvas `file` to an absolute path inside the * design root, following symlinks — .tsx/.html only. Mirrors jobs.ts's guard * (security review F2). Returns null on any failure. */ function safeCanvasAbs(repoRoot: string, designRoot: string, file: string): string | null { if (!/\.(tsx|html)$/i.test(file)) return null; try { const designReal = realpathSync(path.resolve(designRoot)); // `file` is repo-relative (carries the `.design/` prefix) — resolve against // repoRoot, require the realpath under designRoot. const abs = realpathSync(path.resolve(repoRoot, file)); return abs === designReal || abs.startsWith(designReal + path.sep) ? abs : null; } catch { return null; } } /** * `options.unsupportedMedia` — the cell's pre-resolved `scanUnsupportedMedia` * findings for a remote job, or null when absent (local lane: scan the disk). * Shape-checked: it crosses the cell→worker HTTP boundary. */ function shippedUnsupportedMedia(options: ExportOptions): UnsupportedFinding[] | null { const raw = (options as { unsupportedMedia?: unknown }).unsupportedMedia; if (!Array.isArray(raw)) return null; return raw.filter( (f): f is UnsupportedFinding => !!f && typeof f === 'object' && ((f as UnsupportedFinding).element === 'Audio' || (f as UnsupportedFinding).element === 'OffthreadVideo') && typeof (f as UnsupportedFinding).sourceFile === 'string' ); } // DDR-045: resolve via DEV_SERVER_ROOT, never `import.meta.dir`. See _runtime.ts. const VIDEO_PLAYWRIGHT = exportShimPath('_video-playwright.mjs'); type VideoFormat = 'mp4' | 'webm' | 'gif'; const CONTENT_TYPE: Record = { mp4: 'video/mp4', webm: 'video/webm', gif: 'image/gif', }; /** Default frame ceiling — 2 min @ 30 fps. Raisable per-export via * `options.maxFrames` (an editor invites longer cuts than the original 30 s * cap; the flatmap RCA's real 90 s reel already violated it). A request that * exceeds the resolved ceiling is REFUSED with remediation, never silently * truncated. */ const DEFAULT_MAX_FRAMES = 3600; /** Absolute backstop for `options.maxFrames` itself (10 min @ 30 fps). */ const MAX_FRAMES_CEILING = 18000; /** Above this, the frame-step fallback path gets slow (~2.5 s/frame) and 1080p * captures have died from memory pressure — warn loudly, recommend ≤720p. */ const HEAVY_FRAMES_WARNING = 900; // 30 s @ 30 fps async function runVideo( format: VideoFormat, targets: Target[], options: ExportOptions, ctx: ExportContext, hooks?: ExportHooks ): Promise { const el = targets.find((t): t is Extract => t.kind === 'element'); if (!el) { throw new Error( `${format} export needs an artboard target — make the artboard a video-comp or add motion` ); } // Pre-flight the two elements the audio renderer rejects, BEFORE launching a // browser (RCA issue-mp4-audio-export-html5audio-silent-degrade). This is // decidable from source and always fails, so discovering it 37 minutes later // via a muted file is pure waste. GIF is silent by format, so it is exempt. let preStamped: ExportDegradation | undefined; if (format !== 'gif' && options.allowUnsupportedMedia !== true) { // The scan reads the canvas SOURCE, which only the process holding the // checkout has. On the render worker (DDR-230: no tenant files) the read // fails quietly — an empty finding list — and the export went on to do // EXACTLY what this guard exists to prevent: renderMediaOnWeb rejected //