import { failCommand } from "../utils/commandResult.js"; // fallow-ignore-file complexity import { defineCommand } from "citty"; import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { resolve, join, relative, isAbsolute, basename } from "node:path"; import { DEFAULT_ZOOM_SCALE, captureRegionCrop, openSettledCompositionPage, parseZoomTarget, resolveCropRegion, runFfmpegOnce, seekCompositionTimeline, type ZoomTarget, } from "../capture/captureCompositionFrame.js"; import { resolveProject } from "../utils/project.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; import { c } from "../ui/colors.js"; import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js"; import { parseAngle, type Camera } from "./motionShotLayout.js"; import type { Example } from "./_examples.js"; // Runs IN THE BROWSER (serialized into page.evaluate). Tilt the whole stage so // the REAL painted pixels are viewed from an orthogonal angle (FINDING [10]: // snapshot only captured the composition's own head-on camera, so 3D depth / // occlusion couldn't be verified). Same approach as motionShot's orbit camera: // make the composition root + its ancestor chain preserve-3d, strip intermediate // perspective, put one perspective on the root's parent (the lens) and rotate // the root — works on any composition shape (no #stage assumption). // // Kept as a self-contained copy of motionShot.ts's `applyOrbitCamera` because // that one is module-private; this is ~15 lines and sharing it would mean // touching motionShot.ts (out of scope for this change). function orbitStageSource(): string { return `function(cam) { var root = document.querySelector("[data-composition-id]") || document.querySelector("#stage") || document.body.firstElementChild || document.body; var n = root; while (n && n !== document.body) { n.style.transformStyle = "preserve-3d"; n.style.perspective = "none"; n = n.parentElement; } root.style.transformStyle = "preserve-3d"; root.style.perspective = "none"; root.style.transformOrigin = "50% 50%"; root.style.transform = "rotateX(" + cam.pitch + "deg) rotateY(" + cam.yaw + "deg)"; var lens = root.parentElement || document.body; lens.style.perspective = "1600px"; lens.style.perspectiveOrigin = "50% 50%"; }`; } /** Maximum time a single-frame FFmpeg extract is allowed to run. Mirrors the * default applied by `@hyperframes/engine`'s `runFfmpeg` so a pathological * clip (corrupt media, stalled network mount, codec edge case) cannot wedge * `hyperframes snapshot` indefinitely. */ const FFMPEG_EXTRACT_TIMEOUT_MS = 30_000; /** Keep millisecond-level snapshot timing proof without leaking floating-point noise. */ export function formatSnapshotTimestamp(time: number): string { return `${Number(time.toFixed(3))}s`; } /** Keep an exact clip-end snapshot aligned with the renderer's inclusive media * window. This intentionally differs from the live player's exclusive-end * visibility so an explicit end-boundary review does not become blank. FFmpeg * cannot decode at a source's exclusive duration, so sample one nominal 30fps * frame inside the source. This also clamps clips whose configured media window * extends beyond the source. An infinite clip duration intentionally never * enters the end-boundary branch. */ export function resolveSnapshotVideoFrameTime(input: { globalTime: number; clipStart: number; clipDuration: number; relativeTime: number; sourceDuration: number; }): number | null { const { globalTime, clipStart, clipDuration, relativeTime, sourceDuration } = input; const clipEnd = clipStart + clipDuration; const clipEndTolerance = 1e-9; if (globalTime < clipStart || globalTime > clipEnd + clipEndTolerance || relativeTime < 0) return null; const atClipEnd = Math.abs(globalTime - clipEnd) <= clipEndTolerance; if (!atClipEnd) return relativeTime; const sourceEnd = sourceDuration > 0 ? sourceDuration : relativeTime; return Math.max(0, Math.min(relativeTime, sourceEnd - 1 / 30)); } export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string { if (ffmpegPath) return ffmpegPath; throw new Error( `FFmpeg is required to extract video frames for snapshots. ${getFFmpegInstallHint()}`, ); } /** * Extract a single frame from a video file at `timeSeconds` via FFmpeg. * Used to work around Chrome-headless's inability to reliably seek *