import { createHash } from 'node:crypto'; import { readFile, stat, writeFile } from 'node:fs/promises'; import { runAssembleMediaQc, type AssembleMediaQcReport } from '../assemble/media-qc.js'; import type { AssembleManifestEntry } from '../assemble/types.js'; import { renderReframeVideo, type ReframeRenderResult } from './render.js'; import { approvedReframeFreezeSeconds, runReframePlanQc, type ReframePlanQcReport } from './qc.js'; import type { ReframePlan, ReframeSourceContext } from './types.js'; export interface ExecuteReframeResult { render: ReframeRenderResult; planQc: ReframePlanQcReport; mediaQc: AssembleMediaQcReport; qcReceiptPath: string; } export interface ReframeQcReceipt { schemaVersion: 1; generatedAt: string; sourcePath: string; outputPath: string; sourceSha256: string; outputSha256: string; planSha256: string; ok: boolean; planQc: ReframePlanQcReport; mediaQc: AssembleMediaQcReport; } function sha256(value: string | Buffer): string { return createHash('sha256').update(value).digest('hex'); } export async function executeReframe(input: { plan: ReframePlan; context: ReframeSourceContext; outputPath: string; strict?: boolean; ffmpegPath?: string; }): Promise { const planQc = runReframePlanQc(input.plan, input.context); if (input.strict && !planQc.ok) { const codes = planQc.findings .filter((finding) => finding.severity === 'error') .map((finding) => finding.code); throw new Error(`Reframe plan QC failed: ${codes.join(', ')}`); } const render = await renderReframeVideo({ plan: input.plan, context: input.context, outputPath: input.outputPath, ...(input.ffmpegPath ? { ffmpegPath: input.ffmpegPath } : {}), }); const file = await stat(render.outputPath); const manifest: AssembleManifestEntry[] = [{ kind: 'final-video', path: render.outputPath, durationMs: Math.round((render.durationSeconds ?? input.context.sourceDurationSeconds) * 1000), sizeBytes: file.size, generator: 'reframe', }]; const mediaQc = await runAssembleMediaQc({ manifest, outputPath: render.outputPath, freezeToleranceSec: 0.8 + approvedReframeFreezeSeconds(input.plan), blackToleranceSec: 0.4 + approvedReframeFreezeSeconds(input.plan), }); const sourceBytes = await readFile(input.plan.sourcePath); const outputBytes = await readFile(render.outputPath); const receipt: ReframeQcReceipt = { schemaVersion: 1, generatedAt: new Date().toISOString(), sourcePath: input.plan.sourcePath, outputPath: render.outputPath, sourceSha256: sha256(sourceBytes), outputSha256: sha256(outputBytes), planSha256: sha256(JSON.stringify(input.plan)), ok: planQc.ok && mediaQc.status !== 'fail', planQc, mediaQc, }; const qcReceiptPath = `${render.outputPath}.reframe-qc.json`; await writeFile(qcReceiptPath, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8'); if (input.strict && mediaQc.status === 'fail') { const codes = mediaQc.issues .filter((issue) => issue.severity === 'error') .map((issue) => issue.code); throw new Error(`Reframe media QC failed: ${codes.join(', ')}`); } return { render, planQc, mediaQc, qcReceiptPath }; } export * from './captions.js'; export * from './plan.js'; export * from './qc.js'; export * from './render.js'; export * from './suggest.js'; export * from './template.js'; export * from './types.js';