@hyperframes/producer 0.5.0-alpha.5 → 0.5.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -100569,7 +100569,7 @@ var captionRules = [
100569
100569
  const hasHardKill = /\.set\s*\([^,]+,\s*\{[^}]*(?:visibility\s*:\s*["']hidden["']|opacity\s*:\s*0)/.test(
100570
100570
  content
100571
100571
  );
100572
- const hasCaptionLoop = /forEach|\.forEach\s*\(/.test(content) && /createElement|caption|group|cg-/.test(content);
100572
+ const hasCaptionLoop = /forEach|\.forEach\s*\(/.test(content) && /karaoke|caption[-_]?(?:group|word|line|block)|cg-/.test(content);
100573
100573
  if (hasCaptionLoop && hasExitTween && !hasHardKill) {
100574
100574
  findings.push({
100575
100575
  code: "caption_exit_missing_hard_kill",
@@ -104264,6 +104264,7 @@ function calculateOptimalWorkers(totalFrames, requested, config2) {
104264
104264
  const effectiveCoresPerWorker = config2?.coresPerWorker ?? DEFAULT_CONFIG.coresPerWorker;
104265
104265
  const effectiveMinParallelFrames = config2?.minParallelFrames ?? DEFAULT_CONFIG.minParallelFrames;
104266
104266
  const effectiveLargeRenderThreshold = config2?.largeRenderThreshold ?? DEFAULT_CONFIG.largeRenderThreshold;
104267
+ const captureCostMultiplier = Math.max(1, config2?.captureCostMultiplier ?? 1);
104267
104268
  if (requested !== void 0) {
104268
104269
  return Math.max(MIN_WORKERS, Math.min(effectiveMaxWorkers, requested));
104269
104270
  }
@@ -104276,8 +104277,14 @@ function calculateOptimalWorkers(totalFrames, requested, config2) {
104276
104277
  const optimal = Math.min(cpuBasedWorkers, memoryBasedWorkers, frameBasedWorkers);
104277
104278
  const minWorkersForJob = totalFrames >= effectiveMinParallelFrames ? 2 : MIN_WORKERS;
104278
104279
  let finalWorkers = Math.max(minWorkersForJob, Math.min(effectiveMaxWorkers, optimal));
104279
- if (totalFrames >= effectiveLargeRenderThreshold) {
104280
- const cpuScaledMax = Math.max(2, Math.floor(cpuCount / effectiveCoresPerWorker));
104280
+ const weightedFrames = totalFrames * captureCostMultiplier;
104281
+ const contentionThreshold = Math.max(
104282
+ effectiveMinParallelFrames,
104283
+ Math.floor(effectiveLargeRenderThreshold / 3)
104284
+ );
104285
+ if (totalFrames >= effectiveLargeRenderThreshold || weightedFrames >= contentionThreshold) {
104286
+ const weightedCoresPerWorker = effectiveCoresPerWorker * captureCostMultiplier;
104287
+ const cpuScaledMax = Math.max(MIN_WORKERS, Math.floor(cpuCount / weightedCoresPerWorker));
104281
104288
  if (finalWorkers > cpuScaledMax) {
104282
104289
  finalWorkers = cpuScaledMax;
104283
104290
  }
@@ -109423,6 +109430,18 @@ function detectRenderModeHints(html) {
109423
109430
  reasons
109424
109431
  };
109425
109432
  }
109433
+ var SHADER_TRANSITION_USAGE_PATTERN = /\b(?:(?:window|globalThis)\s*\.\s*)?HyperShader\s*\.\s*init\s*\(|\b__hf\s*\.\s*transitions\s*=/;
109434
+ function detectShaderTransitionUsage(html) {
109435
+ let scriptMatch;
109436
+ const scriptPattern = new RegExp(INLINE_SCRIPT_PATTERN.source, INLINE_SCRIPT_PATTERN.flags);
109437
+ while ((scriptMatch = scriptPattern.exec(html)) !== null) {
109438
+ const attrs = scriptMatch[1] || "";
109439
+ if (/\bsrc\s*=/i.test(attrs)) continue;
109440
+ const content = stripJsComments(stripCompilerMountBootstrap(scriptMatch[2] || ""));
109441
+ if (SHADER_TRANSITION_USAGE_PATTERN.test(content)) return true;
109442
+ }
109443
+ return false;
109444
+ }
109426
109445
  async function resolveMediaDuration(src, mediaStart, baseDir, downloadDir, tagName19) {
109427
109446
  let filePath = src;
109428
109447
  if (isHttpUrl(src)) {
@@ -109959,6 +109978,7 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
109959
109978
  "$1"
109960
109979
  );
109961
109980
  const renderModeHints = detectRenderModeHints(sanitizedHtml);
109981
+ const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml);
109962
109982
  const coalescedHtml = await injectDeterministicFontFaces(
109963
109983
  coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml))
109964
109984
  );
@@ -110006,7 +110026,8 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
110006
110026
  width,
110007
110027
  height,
110008
110028
  staticDuration,
110009
- renderModeHints
110029
+ renderModeHints,
110030
+ hasShaderTransitions
110010
110031
  };
110011
110032
  }
110012
110033
  async function discoverMediaFromBrowser(page) {
@@ -110108,7 +110129,8 @@ async function recompileWithResolutions(compiled, resolutions, projectDir, downl
110108
110129
  audios,
110109
110130
  images,
110110
110131
  unresolvedCompositions: remaining,
110111
- renderModeHints: compiled.renderModeHints
110132
+ renderModeHints: compiled.renderModeHints,
110133
+ hasShaderTransitions: compiled.hasShaderTransitions
110112
110134
  };
110113
110135
  }
110114
110136
 
@@ -110387,7 +110409,8 @@ function writeCompiledArtifacts(compiled, workDir, includeSummary) {
110387
110409
  mediaStart: a.mediaStart
110388
110410
  })),
110389
110411
  subCompositions: Array.from(compiled.subCompositions.keys()),
110390
- renderModeHints: compiled.renderModeHints
110412
+ renderModeHints: compiled.renderModeHints,
110413
+ hasShaderTransitions: compiled.hasShaderTransitions
110391
110414
  };
110392
110415
  writeFileSync5(join16(compileDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
110393
110416
  }
@@ -110400,6 +110423,290 @@ function applyRenderModeHints(cfg, compiled, log = defaultLogger) {
110400
110423
  reasons: compiled.renderModeHints.reasons.map((reason) => reason.message)
110401
110424
  });
110402
110425
  }
110426
+ function resolveRenderWorkerCount(totalFrames, requestedWorkers, cfg, compiled, composition, log = defaultLogger, measuredCaptureCost) {
110427
+ const captureCost = combineCaptureCostEstimates(
110428
+ estimateCaptureCostMultiplier(compiled, composition),
110429
+ measuredCaptureCost
110430
+ );
110431
+ const workerCount = calculateOptimalWorkers(totalFrames, requestedWorkers, {
110432
+ ...cfg,
110433
+ captureCostMultiplier: captureCost.multiplier
110434
+ });
110435
+ if (requestedWorkers !== void 0 || captureCost.multiplier <= 1) {
110436
+ return workerCount;
110437
+ }
110438
+ const baselineWorkers = calculateOptimalWorkers(totalFrames, void 0, cfg);
110439
+ if (workerCount < baselineWorkers) {
110440
+ log.warn(
110441
+ "[Render] Reduced auto worker count for high-cost capture workload to avoid Chrome compositor starvation.",
110442
+ {
110443
+ from: baselineWorkers,
110444
+ to: workerCount,
110445
+ costMultiplier: captureCost.multiplier,
110446
+ reasons: captureCost.reasons
110447
+ }
110448
+ );
110449
+ }
110450
+ return workerCount;
110451
+ }
110452
+ function estimateCaptureCostMultiplier(compiled, composition) {
110453
+ let multiplier = 1;
110454
+ const reasons = [];
110455
+ if (compiled.hasShaderTransitions) {
110456
+ multiplier += 2;
110457
+ reasons.push("shader-transitions");
110458
+ }
110459
+ const reasonCodes = new Set(compiled.renderModeHints.reasons.map((reason) => reason.code));
110460
+ if (reasonCodes.has("requestAnimationFrame")) {
110461
+ multiplier += 1;
110462
+ reasons.push("requestAnimationFrame");
110463
+ }
110464
+ if (reasonCodes.has("iframe")) {
110465
+ multiplier += 0.5;
110466
+ reasons.push("iframe");
110467
+ }
110468
+ if (composition.videos.length > 0) {
110469
+ multiplier += Math.min(2, composition.videos.length * 0.75);
110470
+ reasons.push(`${composition.videos.length} video${composition.videos.length === 1 ? "" : "s"}`);
110471
+ }
110472
+ if (composition.audios.length > 0) {
110473
+ multiplier += Math.min(1, composition.audios.length * 0.75);
110474
+ reasons.push(`${composition.audios.length} audio${composition.audios.length === 1 ? "" : "s"}`);
110475
+ }
110476
+ return {
110477
+ multiplier: Math.round(multiplier * 100) / 100,
110478
+ reasons
110479
+ };
110480
+ }
110481
+ function combineCaptureCostEstimates(staticCost, measuredCost) {
110482
+ if (!measuredCost || measuredCost.multiplier <= 1) return staticCost;
110483
+ if (staticCost.multiplier >= measuredCost.multiplier) {
110484
+ return {
110485
+ multiplier: staticCost.multiplier,
110486
+ reasons: [...staticCost.reasons, ...measuredCost.reasons],
110487
+ p95Ms: measuredCost.p95Ms
110488
+ };
110489
+ }
110490
+ return {
110491
+ multiplier: measuredCost.multiplier,
110492
+ reasons: [...measuredCost.reasons, ...staticCost.reasons],
110493
+ p95Ms: measuredCost.p95Ms
110494
+ };
110495
+ }
110496
+ var CAPTURE_CALIBRATION_TARGET_MS = 600;
110497
+ var MAX_MEASURED_CAPTURE_COST_MULTIPLIER = 8;
110498
+ var CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS = 3e4;
110499
+ function createCaptureCalibrationConfig(cfg) {
110500
+ return {
110501
+ ...cfg,
110502
+ protocolTimeout: Math.min(cfg.protocolTimeout, CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS)
110503
+ };
110504
+ }
110505
+ function estimateMeasuredCaptureCostMultiplier(samples) {
110506
+ if (samples.length === 0) {
110507
+ return { multiplier: 1, reasons: [] };
110508
+ }
110509
+ const sorted = [...samples].sort((a, b) => a.captureTimeMs - b.captureTimeMs);
110510
+ const p95Index = Math.max(0, Math.ceil(sorted.length * 0.95) - 1);
110511
+ const p95Sample = sorted[p95Index] ?? sorted[sorted.length - 1];
110512
+ if (!p95Sample) {
110513
+ return { multiplier: 1, reasons: [] };
110514
+ }
110515
+ const p95Ms = Math.round(p95Sample.captureTimeMs);
110516
+ const multiplier = Math.min(
110517
+ MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
110518
+ Math.max(1, Math.round(p95Ms / CAPTURE_CALIBRATION_TARGET_MS * 100) / 100)
110519
+ );
110520
+ return {
110521
+ multiplier,
110522
+ reasons: multiplier > 1 ? [`calibration-p95=${p95Ms}ms`] : [],
110523
+ p95Ms
110524
+ };
110525
+ }
110526
+ function selectCaptureCalibrationFrames(totalFrames) {
110527
+ if (totalFrames <= 0) return [];
110528
+ const lastFrame = totalFrames - 1;
110529
+ const candidates = [
110530
+ 0,
110531
+ Math.floor(totalFrames * 0.25),
110532
+ Math.floor(totalFrames * 0.5),
110533
+ Math.floor(totalFrames * 0.75),
110534
+ lastFrame
110535
+ ];
110536
+ return Array.from(
110537
+ new Set(candidates.map((frame) => Math.max(0, Math.min(lastFrame, frame))))
110538
+ ).sort((a, b) => a - b);
110539
+ }
110540
+ function findMissingFrameRanges(totalFrames, framesDir, frameExt) {
110541
+ const ranges = [];
110542
+ let rangeStart = null;
110543
+ for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
110544
+ const framePath = join16(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`);
110545
+ const missing = !existsSync16(framePath);
110546
+ if (missing && rangeStart === null) {
110547
+ rangeStart = frameIndex;
110548
+ } else if (!missing && rangeStart !== null) {
110549
+ ranges.push({ startFrame: rangeStart, endFrame: frameIndex });
110550
+ rangeStart = null;
110551
+ }
110552
+ }
110553
+ if (rangeStart !== null) {
110554
+ ranges.push({ startFrame: rangeStart, endFrame: totalFrames });
110555
+ }
110556
+ return ranges;
110557
+ }
110558
+ function buildMissingFrameRetryBatches(ranges, maxWorkers, workDir, attempt) {
110559
+ const workersPerBatch = Math.max(1, Math.floor(maxWorkers));
110560
+ const batches = [];
110561
+ for (let i = 0; i < ranges.length; i += workersPerBatch) {
110562
+ const batchIndex = batches.length;
110563
+ const batch = ranges.slice(i, i + workersPerBatch).map((range, workerId) => ({
110564
+ workerId,
110565
+ startFrame: range.startFrame,
110566
+ endFrame: range.endFrame,
110567
+ outputDir: join16(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`)
110568
+ }));
110569
+ batches.push(batch);
110570
+ }
110571
+ return batches;
110572
+ }
110573
+ function getNextRetryWorkerCount(currentWorkers) {
110574
+ return Math.max(1, Math.floor(currentWorkers / 2));
110575
+ }
110576
+ function isRecoverableParallelCaptureError(error) {
110577
+ const message = error instanceof Error ? error.message : String(error);
110578
+ return message.includes("[Parallel] Capture failed") && /Runtime\.callFunctionOn timed out|HeadlessExperimental\.beginFrame timed out|Waiting failed|timeout exceeded|timed out|Navigation timeout|Protocol error|Target closed/i.test(
110579
+ message
110580
+ );
110581
+ }
110582
+ function shouldFallbackToScreenshotAfterCalibrationError(error) {
110583
+ const message = error instanceof Error ? error.message : String(error);
110584
+ return /HeadlessExperimental\.beginFrame timed out|beginFrame probe timeout|Another frame is pending|Frame still pending|Protocol error.*HeadlessExperimental\.beginFrame/i.test(
110585
+ message
110586
+ );
110587
+ }
110588
+ function countCapturedFrames(totalFrames, framesDir, frameExt) {
110589
+ let captured = 0;
110590
+ for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
110591
+ const framePath = join16(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`);
110592
+ if (existsSync16(framePath)) captured++;
110593
+ }
110594
+ return captured;
110595
+ }
110596
+ function countFrameRanges(ranges) {
110597
+ return ranges.reduce((sum, range) => sum + (range.endFrame - range.startFrame), 0);
110598
+ }
110599
+ async function measureCaptureCostFromSession(session, totalFrames, fps) {
110600
+ const sampledFrames = selectCaptureCalibrationFrames(totalFrames);
110601
+ const samples = [];
110602
+ for (const frameIndex of sampledFrames) {
110603
+ const time = frameIndex / fps;
110604
+ const startedAt = Date.now();
110605
+ const result = await captureFrameToBuffer(session, frameIndex, time);
110606
+ samples.push({
110607
+ frameIndex,
110608
+ captureTimeMs: result.captureTimeMs || Date.now() - startedAt
110609
+ });
110610
+ }
110611
+ return {
110612
+ estimate: estimateMeasuredCaptureCostMultiplier(samples),
110613
+ samples
110614
+ };
110615
+ }
110616
+ async function executeDiskCaptureWithAdaptiveRetry(options) {
110617
+ const attempts = [];
110618
+ let currentWorkers = options.initialWorkerCount;
110619
+ let missingRanges = null;
110620
+ let attempt = 0;
110621
+ while (true) {
110622
+ const frameCount = missingRanges ? countFrameRanges(missingRanges) : options.totalFrames;
110623
+ attempts.push({
110624
+ attempt,
110625
+ workers: currentWorkers,
110626
+ frameCount,
110627
+ reason: attempt === 0 ? "initial" : "retry"
110628
+ });
110629
+ const attemptWorkDir = join16(options.workDir, `capture-attempt-${attempt}`);
110630
+ const batches = missingRanges ? buildMissingFrameRetryBatches(missingRanges, currentWorkers, attemptWorkDir, attempt) : [distributeFrames(options.totalFrames, currentWorkers, attemptWorkDir)];
110631
+ try {
110632
+ for (const tasks of batches) {
110633
+ const capturedBeforeBatch = countCapturedFrames(
110634
+ options.totalFrames,
110635
+ options.framesDir,
110636
+ options.frameExt
110637
+ );
110638
+ try {
110639
+ await executeParallelCapture(
110640
+ options.serverUrl,
110641
+ attemptWorkDir,
110642
+ tasks,
110643
+ options.captureOptions,
110644
+ options.createBeforeCaptureHook,
110645
+ options.abortSignal,
110646
+ options.onProgress ? (progress) => {
110647
+ options.onProgress?.({
110648
+ ...progress,
110649
+ totalFrames: options.totalFrames,
110650
+ capturedFrames: Math.min(
110651
+ options.totalFrames,
110652
+ capturedBeforeBatch + progress.capturedFrames
110653
+ )
110654
+ });
110655
+ } : void 0,
110656
+ void 0,
110657
+ options.cfg
110658
+ );
110659
+ } finally {
110660
+ await mergeWorkerFrames(attemptWorkDir, tasks, options.framesDir);
110661
+ }
110662
+ }
110663
+ const remaining = findMissingFrameRanges(
110664
+ options.totalFrames,
110665
+ options.framesDir,
110666
+ options.frameExt
110667
+ );
110668
+ if (remaining.length === 0) {
110669
+ return attempts;
110670
+ }
110671
+ if (!options.allowRetry || currentWorkers <= 1) {
110672
+ throw new Error(
110673
+ `[Render] Capture completed but ${countFrameRanges(remaining)} frame(s) are missing`
110674
+ );
110675
+ }
110676
+ const nextWorkers = getNextRetryWorkerCount(currentWorkers);
110677
+ options.log.warn("[Render] Retrying missing captured frames with fewer workers.", {
110678
+ fromWorkers: currentWorkers,
110679
+ toWorkers: nextWorkers,
110680
+ missingFrames: countFrameRanges(remaining)
110681
+ });
110682
+ currentWorkers = nextWorkers;
110683
+ missingRanges = remaining;
110684
+ attempt++;
110685
+ } catch (error) {
110686
+ const remaining = findMissingFrameRanges(
110687
+ options.totalFrames,
110688
+ options.framesDir,
110689
+ options.frameExt
110690
+ );
110691
+ if (remaining.length === 0) {
110692
+ return attempts;
110693
+ }
110694
+ if (!options.allowRetry || currentWorkers <= 1 || !isRecoverableParallelCaptureError(error)) {
110695
+ throw error;
110696
+ }
110697
+ const nextWorkers = getNextRetryWorkerCount(currentWorkers);
110698
+ options.log.warn("[Render] Parallel capture timed out; retrying missing frames.", {
110699
+ fromWorkers: currentWorkers,
110700
+ toWorkers: nextWorkers,
110701
+ missingFrames: countFrameRanges(remaining),
110702
+ error: error instanceof Error ? error.message : String(error)
110703
+ });
110704
+ currentWorkers = nextWorkers;
110705
+ missingRanges = remaining;
110706
+ attempt++;
110707
+ }
110708
+ }
110709
+ }
110403
110710
  function blitHdrVideoLayer(canvas, el, time, fps, hdrFrameDirs, hdrStartTimes, width, height, log, sourceTransfer, targetTransfer) {
110404
110711
  const frameDir = hdrFrameDirs.get(el.id);
110405
110712
  const startTime = hdrStartTimes.get(el.id);
@@ -111026,7 +111333,7 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
111026
111333
  let extractionResult = null;
111027
111334
  const nativeHdrVideoIds = /* @__PURE__ */ new Set();
111028
111335
  const videoTransfers = /* @__PURE__ */ new Map();
111029
- if (job.config.hdr && composition.videos.length > 0) {
111336
+ if (job.config.hdrMode !== "force-sdr" && composition.videos.length > 0) {
111030
111337
  await Promise.all(
111031
111338
  composition.videos.map(async (v) => {
111032
111339
  let videoPath = v.src;
@@ -111047,7 +111354,7 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
111047
111354
  const imageTransfers = /* @__PURE__ */ new Map();
111048
111355
  const hdrImageSrcPaths = /* @__PURE__ */ new Map();
111049
111356
  const imageColorSpaces = [];
111050
- if (job.config.hdr && composition.images.length > 0) {
111357
+ if (job.config.hdrMode !== "force-sdr" && composition.images.length > 0) {
111051
111358
  const probed = await Promise.all(
111052
111359
  composition.images.map(async (img) => {
111053
111360
  let imgPath = img.src;
@@ -111104,28 +111411,53 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
111104
111411
  perfStages.videoExtractMs = Date.now() - stage2Start;
111105
111412
  }
111106
111413
  let effectiveHdr;
111107
- if (job.config.hdr) {
111414
+ let forcedHdrWithoutSources = false;
111415
+ {
111416
+ const hdrMode = job.config.hdrMode ?? "auto";
111108
111417
  const videoColorSpaces = (extractionResult?.extracted ?? []).map(
111109
111418
  (ext) => ext.metadata.colorSpace
111110
111419
  );
111111
111420
  const allColorSpaces = [...videoColorSpaces, ...imageColorSpaces];
111112
- if (allColorSpaces.length > 0) {
111113
- const info = analyzeCompositionHdr(allColorSpaces);
111114
- if (info.hasHdr && info.dominantTransfer) {
111421
+ const info = allColorSpaces.length > 0 ? analyzeCompositionHdr(allColorSpaces) : null;
111422
+ if (hdrMode === "force-sdr") {
111423
+ effectiveHdr = void 0;
111424
+ } else if (hdrMode === "force-hdr") {
111425
+ if (info?.hasHdr && info.dominantTransfer) {
111426
+ effectiveHdr = { transfer: info.dominantTransfer };
111427
+ } else {
111428
+ effectiveHdr = { transfer: "hlg" };
111429
+ forcedHdrWithoutSources = true;
111430
+ }
111431
+ } else {
111432
+ if (info?.hasHdr && info.dominantTransfer) {
111115
111433
  effectiveHdr = { transfer: info.dominantTransfer };
111116
111434
  }
111117
111435
  }
111118
111436
  }
111119
111437
  if (effectiveHdr && outputFormat !== "mp4") {
111438
+ const hdrSourceReason = forcedHdrWithoutSources ? "HDR was forced without detected HDR sources" : "HDR source detected";
111120
111439
  log.warn(
111121
- `[Render] HDR source detected but format is "${outputFormat}" \u2014 falling back to SDR. HDR + alpha is not supported. Use --format mp4 for HDR10 output.`
111440
+ `[Render] ${hdrSourceReason}, but format is "${outputFormat}" \u2014 falling back to SDR. HDR + alpha is not supported. Use --format mp4 for HDR10 output.`
111122
111441
  );
111123
111442
  effectiveHdr = void 0;
111124
111443
  }
111125
- if (effectiveHdr) {
111126
- log.info(
111127
- `[Render] HDR source detected \u2014 output: ${effectiveHdr.transfer.toUpperCase()} (BT.2020, 10-bit H.265)`
111128
- );
111444
+ {
111445
+ const hdrMode = job.config.hdrMode ?? "auto";
111446
+ if (forcedHdrWithoutSources) {
111447
+ log.warn(
111448
+ "[Render] HDR forced by --hdr flag, but no HDR sources were detected \u2014 defaulting to HLG. SDR-only compositions may look perceptually wrong on HDR displays."
111449
+ );
111450
+ }
111451
+ if (effectiveHdr) {
111452
+ const reason = hdrMode === "force-hdr" ? forcedHdrWithoutSources ? "forced by --hdr flag (no HDR sources detected \u2014 defaulting to HLG)" : "forced by --hdr flag" : "auto-detected from source(s)";
111453
+ log.info(
111454
+ `[Render] HDR ${reason} \u2014 output: ${effectiveHdr.transfer.toUpperCase()} (BT.2020, 10-bit H.265)`
111455
+ );
111456
+ } else if (hdrMode === "force-sdr") {
111457
+ log.info("[Render] SDR forced by --sdr flag");
111458
+ } else {
111459
+ log.info("[Render] No HDR sources detected \u2014 rendering SDR");
111460
+ }
111129
111461
  }
111130
111462
  const stage3Start = Date.now();
111131
111463
  updateJobStatus(job, "preprocessing", "Processing audio tracks", 20, onProgress);
@@ -111172,7 +111504,101 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
111172
111504
  ...captureOptions,
111173
111505
  skipReadinessVideoIds: Array.from(nativeHdrVideoIds)
111174
111506
  });
111175
- const workerCount = calculateOptimalWorkers(totalFrames, job.config.workers, cfg);
111507
+ let captureCalibration;
111508
+ let switchedToScreenshotAfterCalibration = false;
111509
+ if (job.config.workers === void 0 && totalFrames >= 60) {
111510
+ const calibrationDir = join16(workDir, "capture-calibration");
111511
+ const calibrationCfg = createCaptureCalibrationConfig(cfg);
111512
+ const videoInjector = createVideoFrameInjector(frameLookup);
111513
+ let calibrationSession = null;
111514
+ try {
111515
+ calibrationSession = await createCaptureSession(
111516
+ fileServer.url,
111517
+ calibrationDir,
111518
+ buildHdrCaptureOptions(),
111519
+ videoInjector,
111520
+ calibrationCfg
111521
+ );
111522
+ if (!calibrationSession.isInitialized) {
111523
+ await initializeSession(calibrationSession);
111524
+ }
111525
+ assertNotAborted();
111526
+ captureCalibration = await measureCaptureCostFromSession(
111527
+ calibrationSession,
111528
+ totalFrames,
111529
+ job.config.fps
111530
+ );
111531
+ if (captureCalibration.estimate.multiplier > 1) {
111532
+ log.warn("[Render] Measured slow frame capture during auto-worker calibration.", {
111533
+ multiplier: captureCalibration.estimate.multiplier,
111534
+ p95Ms: captureCalibration.estimate.p95Ms,
111535
+ sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex)
111536
+ });
111537
+ } else {
111538
+ log.debug("[Render] Auto-worker calibration kept baseline capture cost.", {
111539
+ p95Ms: captureCalibration.estimate.p95Ms,
111540
+ sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex)
111541
+ });
111542
+ }
111543
+ } catch (error) {
111544
+ const shouldFallbackToScreenshot = !cfg.forceScreenshot && shouldFallbackToScreenshotAfterCalibrationError(error);
111545
+ if (shouldFallbackToScreenshot) {
111546
+ cfg.forceScreenshot = true;
111547
+ switchedToScreenshotAfterCalibration = true;
111548
+ if (probeSession) {
111549
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
111550
+ await closeCaptureSession(probeSession).catch(() => {
111551
+ });
111552
+ probeSession = null;
111553
+ }
111554
+ }
111555
+ captureCalibration = {
111556
+ estimate: {
111557
+ multiplier: MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
111558
+ reasons: shouldFallbackToScreenshot ? ["calibration-beginframe-timeout", "screenshot-fallback"] : ["calibration-failed"]
111559
+ },
111560
+ samples: []
111561
+ };
111562
+ if (shouldFallbackToScreenshot) {
111563
+ log.warn(
111564
+ "[Render] BeginFrame auto-worker calibration timed out; falling back to screenshot capture mode.",
111565
+ {
111566
+ protocolTimeout: calibrationCfg.protocolTimeout,
111567
+ error: error instanceof Error ? error.message : String(error)
111568
+ }
111569
+ );
111570
+ } else {
111571
+ log.warn("[Render] Auto-worker calibration failed; using conservative worker budget.", {
111572
+ protocolTimeout: calibrationCfg.protocolTimeout,
111573
+ error: error instanceof Error ? error.message : String(error)
111574
+ });
111575
+ }
111576
+ } finally {
111577
+ if (calibrationSession) {
111578
+ lastBrowserConsole = calibrationSession.browserConsoleBuffer;
111579
+ await closeCaptureSession(calibrationSession).catch(() => {
111580
+ });
111581
+ }
111582
+ }
111583
+ }
111584
+ let workerCount = resolveRenderWorkerCount(
111585
+ totalFrames,
111586
+ job.config.workers,
111587
+ cfg,
111588
+ compiled,
111589
+ composition,
111590
+ log,
111591
+ captureCalibration?.estimate
111592
+ );
111593
+ if (switchedToScreenshotAfterCalibration && workerCount > 1) {
111594
+ workerCount = 1;
111595
+ }
111596
+ if (workerCount > 1 && probeSession) {
111597
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
111598
+ await closeCaptureSession(probeSession);
111599
+ probeSession = null;
111600
+ }
111601
+ const captureAttempts = [];
111176
111602
  const FORMAT_EXT = {
111177
111603
  mp4: ".mp4",
111178
111604
  webm: ".webm",
@@ -111764,15 +112190,18 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
111764
112190
  perfStages.encodeMs = encodeResult.durationMs;
111765
112191
  } else {
111766
112192
  if (workerCount > 1) {
111767
- const tasks = distributeFrames(job.totalFrames, workerCount, workDir);
111768
- await executeParallelCapture(
111769
- fileServer.url,
112193
+ const attempts = await executeDiskCaptureWithAdaptiveRetry({
112194
+ serverUrl: fileServer.url,
111770
112195
  workDir,
111771
- tasks,
111772
- buildHdrCaptureOptions(),
111773
- () => createVideoFrameInjector(frameLookup),
112196
+ framesDir,
112197
+ totalFrames: job.totalFrames,
112198
+ initialWorkerCount: workerCount,
112199
+ allowRetry: job.config.workers === void 0,
112200
+ frameExt: needsAlpha ? "png" : "jpg",
112201
+ captureOptions: buildHdrCaptureOptions(),
112202
+ createBeforeCaptureHook: () => createVideoFrameInjector(frameLookup),
111774
112203
  abortSignal,
111775
- (progress) => {
112204
+ onProgress: (progress) => {
111776
112205
  job.framesRendered = progress.capturedFrames;
111777
112206
  const frameProgress = progress.capturedFrames / progress.totalFrames;
111778
112207
  const progressPct = 25 + frameProgress * 45;
@@ -111780,16 +112209,20 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
111780
112209
  updateJobStatus(
111781
112210
  job,
111782
112211
  "rendering",
111783
- `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`,
112212
+ `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
111784
112213
  Math.round(progressPct),
111785
112214
  onProgress
111786
112215
  );
111787
112216
  }
111788
112217
  },
111789
- void 0,
111790
- cfg
111791
- );
111792
- await mergeWorkerFrames(workDir, tasks, framesDir);
112218
+ cfg,
112219
+ log
112220
+ });
112221
+ captureAttempts.push(...attempts);
112222
+ const lastAttempt = attempts[attempts.length - 1];
112223
+ if (lastAttempt) {
112224
+ workerCount = lastAttempt.workers;
112225
+ }
111793
112226
  if (probeSession) {
111794
112227
  lastBrowserConsole = probeSession.browserConsoleBuffer;
111795
112228
  await closeCaptureSession(probeSession);
@@ -111959,6 +112392,13 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
111959
112392
  stages: perfStages,
111960
112393
  videoExtractBreakdown: extractionResult?.phaseBreakdown,
111961
112394
  tmpPeakBytes,
112395
+ captureCalibration: captureCalibration ? {
112396
+ sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex),
112397
+ p95Ms: captureCalibration.estimate.p95Ms,
112398
+ multiplier: captureCalibration.estimate.multiplier,
112399
+ reasons: captureCalibration.estimate.reasons
112400
+ } : void 0,
112401
+ captureAttempts: captureAttempts.length > 0 ? captureAttempts : void 0,
111962
112402
  hdrDiagnostics: hdrDiagnostics.videoExtractionFailures > 0 || hdrDiagnostics.imageDecodeFailures > 0 ? { ...hdrDiagnostics } : void 0,
111963
112403
  captureAvgMs: totalFrames > 0 ? Math.round((perfStages.captureMs ?? 0) / totalFrames) : void 0,
111964
112404
  peakRssMb: Math.round(peakRssBytes / (1024 * 1024)),