Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/app/headless-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
33 changes: 28 additions & 5 deletions src/app/headless-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,27 +134,50 @@ function waitForExporter(deps: HeadlessReadyDeps): Promise<Exporter> {
})
}

function waitForDisplayedSourceMedia(deps: HeadlessReadyDeps): Promise<void> {
/** 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<void> {
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<void> {
export async function waitUntilHeadlessReady(
deps: HeadlessReadyDeps,
options: HeadlessReadyOptions = {},
): Promise<void> {
await waitForExporter(deps)
await deps.whenAssetsIdle()
await waitForDisplayedSourceMedia(deps)
await waitForDisplayedSourceMedia(
deps,
options.displayedMediaTimeoutMs ?? HEADLESS_DISPLAYED_MEDIA_TIMEOUT_MS,
)
}

function storeReadyDeps(): HeadlessReadyDeps {
Expand Down
7 changes: 6 additions & 1 deletion src/cli/headless-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,18 @@ async function captureOnPage(
async function captureStillInBrowser(input: StillCaptureInput): Promise<StillCaptureResult> {
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<SourceMediaFacts | null> {
Expand Down
41 changes: 40 additions & 1 deletion src/cli/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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' }],
})
})
})

/**
Expand Down
11 changes: 10 additions & 1 deletion src/cli/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -285,6 +287,7 @@ export async function runRenderCommand(
document: loaded.document,
probedWidth: probed.media.width,
probedHeight: probed.media.height,
signal: options.signal,
})
}

Expand All @@ -299,8 +302,10 @@ async function runStillRender(input: {
document: ReturnType<typeof fromJson>['document']
probedWidth: number
probedHeight: number
signal?: AbortSignal
}): Promise<number> {
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(
Expand Down Expand Up @@ -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)
}
Expand Down
10 changes: 10 additions & 0 deletions src/core/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 3 additions & 1 deletion src/core/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -271,7 +273,7 @@ export function buildStillExportRequest(
width: size.width,
height: size.height,
format,
transparent: false,
transparent: documentIsAlphaCapable(document) && formatSupportsTransparency(format),
}
}

Expand Down
Loading