From e2140bc4c088e4d06e99cea91e8cd7e50f8aa8a3 Mon Sep 17 00:00:00 2001 From: Antunes <34199649+lenxism@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:02:26 -0300 Subject: [PATCH] Honor still alpha, SIGINT, and Source Media ready timeout in headless render. Follow-ups left uncommitted when #20 merged: transparent Stage stills match the editor, still capture maps abort to exit 130, and the ready gate no longer hangs forever on missing display aspect. --- src/app/headless-bridge.test.ts | 20 ++++++++++++++++ src/app/headless-bridge.ts | 33 ++++++++++++++++++++++---- src/cli/headless-capture.ts | 7 +++++- src/cli/render.test.ts | 41 ++++++++++++++++++++++++++++++++- src/cli/render.ts | 11 ++++++++- src/core/export.test.ts | 10 ++++++++ src/core/export.ts | 4 +++- 7 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/app/headless-bridge.test.ts b/src/app/headless-bridge.test.ts index 99e7b67..af836b9 100644 --- a/src/app/headless-bridge.test.ts +++ b/src/app/headless-bridge.test.ts @@ -155,4 +155,24 @@ describe('headless bridge — ready gate', () => { await ready expect(settled).toBe(true) }) + + it('rejects when bound Source Media never publishes a display aspect', async () => { + const deps: HeadlessReadyDeps = { + getExporter: () => stubExporter(), + subscribeExporter: () => () => {}, + whenAssetsIdle: () => Promise.resolve(), + getSourceMedia: () => ({ + kind: 'image', + url: 'blob:test', + naturalWidth: 100, + naturalHeight: 100, + }), + getDisplayedSourceAspect: () => null, + subscribeDisplayedSourceAspect: () => () => {}, + } + + await expect( + waitUntilHeadlessReady(deps, { displayedMediaTimeoutMs: 20 }), + ).rejects.toThrow(/Source Media did not become ready/i) + }) }) diff --git a/src/app/headless-bridge.ts b/src/app/headless-bridge.ts index 5c81194..fa43dca 100644 --- a/src/app/headless-bridge.ts +++ b/src/app/headless-bridge.ts @@ -134,27 +134,50 @@ function waitForExporter(deps: HeadlessReadyDeps): Promise { }) } -function waitForDisplayedSourceMedia(deps: HeadlessReadyDeps): Promise { +/** How long the ready gate waits for bound Source Media to publish a display aspect. */ +export const HEADLESS_DISPLAYED_MEDIA_TIMEOUT_MS = 30_000 + +function waitForDisplayedSourceMedia( + deps: HeadlessReadyDeps, + timeoutMs: number, +): Promise { if (!deps.getSourceMedia()) return Promise.resolve() if (deps.getDisplayedSourceAspect() !== null) return Promise.resolve() - return new Promise((resolve) => { - const unsubscribe = deps.subscribeDisplayedSourceAspect((aspect) => { + return new Promise((resolve, reject) => { + let unsubscribe = () => {} + const timer = setTimeout(() => { + unsubscribe() + reject(new Error('Source Media did not become ready for display')) + }, timeoutMs) + unsubscribe = deps.subscribeDisplayedSourceAspect((aspect) => { if (aspect === null) return + clearTimeout(timer) unsubscribe() resolve() }) }) } +export interface HeadlessReadyOptions { + /** Override {@link HEADLESS_DISPLAYED_MEDIA_TIMEOUT_MS} (tests use a short value). */ + displayedMediaTimeoutMs?: number +} + /** * Gate the driver must clear before capture: Exporter registered, Three.js * LoadingManager idle (GLB / HDRI / Basis), and — when Source Media is bound — * the scene actually displaying it (Source Image bypasses LoadingManager). */ -export async function waitUntilHeadlessReady(deps: HeadlessReadyDeps): Promise { +export async function waitUntilHeadlessReady( + deps: HeadlessReadyDeps, + options: HeadlessReadyOptions = {}, +): Promise { await waitForExporter(deps) await deps.whenAssetsIdle() - await waitForDisplayedSourceMedia(deps) + await waitForDisplayedSourceMedia( + deps, + options.displayedMediaTimeoutMs ?? HEADLESS_DISPLAYED_MEDIA_TIMEOUT_MS, + ) } function storeReadyDeps(): HeadlessReadyDeps { diff --git a/src/cli/headless-capture.ts b/src/cli/headless-capture.ts index 1cb4556..2f81a9f 100644 --- a/src/cli/headless-capture.ts +++ b/src/cli/headless-capture.ts @@ -220,13 +220,18 @@ async function captureOnPage( async function captureStillInBrowser(input: StillCaptureInput): Promise { return withHeadlessPage(async (page) => { await bindDocumentAndMedia(page, input.documentJson, input.mediaPath) + if (input.signal?.aborted) { + const error = new Error('Interrupted') + error.name = 'AbortError' + throw error + } const captured = await captureOnPage(page, input.request, { atSeconds: input.atSeconds }) return { data: Uint8Array.from(captured.data), width: input.request.width, height: input.request.height, } - }) + }, input.signal) } async function readSourceMediaFacts(page: Page): Promise { diff --git a/src/cli/render.test.ts b/src/cli/render.test.ts index 9ce4d05..f32506e 100644 --- a/src/cli/render.test.ts +++ b/src/cli/render.test.ts @@ -548,7 +548,7 @@ describe('mockstudio CLI — render (video + Source Video)', () => { expect(stderr).toContain(`Wrote ${out}`) }) - it('maps an aborted capture to exit 130 with --json on stdout', async () => { + it('maps an aborted video capture to exit 130 with --json on stdout', async () => { const dir = tempDir('mockstudio-render-sigint-') const scene = writeScene(dir, '16:9', 1) const media = writePng(dir) @@ -583,6 +583,45 @@ describe('mockstudio CLI — render (video + Source Video)', () => { errors: [{ kind: 'interrupted', code: 'interrupted' }], }) }) + + it('maps an aborted still capture to exit 130 and forwards the signal', async () => { + const dir = tempDir('mockstudio-render-still-sigint-') + const scene = writeScene(dir) + const media = writePng(dir) + const out = join(dir, 'frame.png') + const controller = new AbortController() + controller.abort() + + const stdout: string[] = [] + let seenSignal: AbortSignal | undefined + const code = await runRenderCommand( + [scene, '--media', media, '--out', out, '--json'], + { + writeStdout: (chunk) => { + stdout.push(chunk) + }, + writeStderr: () => {}, + }, + { + async captureStill(input) { + seenSignal = input.signal + const error = new Error('Target closed') + throw error + }, + async captureVideo() { + throw new Error('unexpected video') + }, + }, + { signal: controller.signal }, + ) + + expect(seenSignal).toBe(controller.signal) + expect(code).toBe(130) + expect(JSON.parse(stdout.join(''))).toMatchObject({ + ok: false, + errors: [{ kind: 'interrupted', code: 'interrupted' }], + }) + }) }) /** diff --git a/src/cli/render.ts b/src/cli/render.ts index 8b078de..3726fdf 100644 --- a/src/cli/render.ts +++ b/src/cli/render.ts @@ -33,6 +33,8 @@ export interface StillCaptureInput { request: ImageExportRequest /** Source Video still time (`--at`); omitted for image Source Media. */ atSeconds?: number + /** AbortSignal for SIGINT → exit 130 (same contract as video encode). */ + signal?: AbortSignal } export interface StillCaptureResult { @@ -285,6 +287,7 @@ export async function runRenderCommand( document: loaded.document, probedWidth: probed.media.width, probedHeight: probed.media.height, + signal: options.signal, }) } @@ -299,8 +302,10 @@ async function runStillRender(input: { document: ReturnType['document'] probedWidth: number probedHeight: number + signal?: AbortSignal }): Promise { - const { io, capture, value, documentJson, mediaPath, outPath, mediaKind, document } = input + const { io, capture, value, documentJson, mediaPath, outPath, mediaKind, document, signal } = + input if (value.preview) { return reportFailure( @@ -345,8 +350,12 @@ async function runStillRender(input: { mediaPath, request, atSeconds, + signal, }) } catch (error) { + if (signal?.aborted) { + return reportFailure(io, value.json, 'Interrupted', EXIT.interrupted) + } const classified = classifyRenderFailure(error) return reportFailure(io, value.json, classified.message, classified.code) } diff --git a/src/core/export.test.ts b/src/core/export.test.ts index 1912571..17e7cd3 100644 --- a/src/core/export.test.ts +++ b/src/core/export.test.ts @@ -329,6 +329,16 @@ describe('buildStillExportRequest — Stage size + ExportFormat', () => { expect(buildStillExportRequest(documentWithArtboard('1:1'), 'jpeg').format).toBe('jpeg') expect(buildStillExportRequest(documentWithArtboard('1:1'), 'webp').format).toBe('webp') }) + + it('asks the Exporter for alpha when the Stage is transparent and the format supports it', () => { + const document = documentWithArtboard('16:9') + document.stage.background = { ...document.stage.background, kind: 'transparent' } + + expect(buildStillExportRequest(document, 'png').transparent).toBe(true) + expect(buildStillExportRequest(document, 'webp').transparent).toBe(true) + // JPEG cannot carry alpha — same effective gate as the editor export UI. + expect(buildStillExportRequest(document, 'jpeg').transparent).toBe(false) + }) }) describe('buildVideoExportRequest — Stage size + fps + format', () => { diff --git a/src/core/export.ts b/src/core/export.ts index 3e94e12..370e889 100644 --- a/src/core/export.ts +++ b/src/core/export.ts @@ -259,6 +259,8 @@ export function resolveStageExportSize( /** * Build the still `ExportRequest` the headless CLI hands the shared Exporter: * Stage size from the Scene Document + an already-resolved `ExportFormat`. + * Transparent Stage + alpha-capable format (PNG/WebP) asks the Exporter for + * see-through stills — same effective flag the editor export UI computes. */ export function buildStillExportRequest( document: SceneDocument, @@ -271,7 +273,7 @@ export function buildStillExportRequest( width: size.width, height: size.height, format, - transparent: false, + transparent: documentIsAlphaCapable(document) && formatSupportsTransparency(format), } }