@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.
@@ -103358,7 +103358,7 @@ var captionRules = [
103358
103358
  const hasHardKill = /\.set\s*\([^,]+,\s*\{[^}]*(?:visibility\s*:\s*["']hidden["']|opacity\s*:\s*0)/.test(
103359
103359
  content
103360
103360
  );
103361
- const hasCaptionLoop = /forEach|\.forEach\s*\(/.test(content) && /createElement|caption|group|cg-/.test(content);
103361
+ const hasCaptionLoop = /forEach|\.forEach\s*\(/.test(content) && /karaoke|caption[-_]?(?:group|word|line|block)|cg-/.test(content);
103362
103362
  if (hasCaptionLoop && hasExitTween && !hasHardKill) {
103363
103363
  findings.push({
103364
103364
  code: "caption_exit_missing_hard_kill",
@@ -107053,6 +107053,7 @@ function calculateOptimalWorkers(totalFrames, requested, config2) {
107053
107053
  const effectiveCoresPerWorker = config2?.coresPerWorker ?? DEFAULT_CONFIG.coresPerWorker;
107054
107054
  const effectiveMinParallelFrames = config2?.minParallelFrames ?? DEFAULT_CONFIG.minParallelFrames;
107055
107055
  const effectiveLargeRenderThreshold = config2?.largeRenderThreshold ?? DEFAULT_CONFIG.largeRenderThreshold;
107056
+ const captureCostMultiplier = Math.max(1, config2?.captureCostMultiplier ?? 1);
107056
107057
  if (requested !== void 0) {
107057
107058
  return Math.max(MIN_WORKERS, Math.min(effectiveMaxWorkers, requested));
107058
107059
  }
@@ -107065,8 +107066,14 @@ function calculateOptimalWorkers(totalFrames, requested, config2) {
107065
107066
  const optimal = Math.min(cpuBasedWorkers, memoryBasedWorkers, frameBasedWorkers);
107066
107067
  const minWorkersForJob = totalFrames >= effectiveMinParallelFrames ? 2 : MIN_WORKERS;
107067
107068
  let finalWorkers = Math.max(minWorkersForJob, Math.min(effectiveMaxWorkers, optimal));
107068
- if (totalFrames >= effectiveLargeRenderThreshold) {
107069
- const cpuScaledMax = Math.max(2, Math.floor(cpuCount / effectiveCoresPerWorker));
107069
+ const weightedFrames = totalFrames * captureCostMultiplier;
107070
+ const contentionThreshold = Math.max(
107071
+ effectiveMinParallelFrames,
107072
+ Math.floor(effectiveLargeRenderThreshold / 3)
107073
+ );
107074
+ if (totalFrames >= effectiveLargeRenderThreshold || weightedFrames >= contentionThreshold) {
107075
+ const weightedCoresPerWorker = effectiveCoresPerWorker * captureCostMultiplier;
107076
+ const cpuScaledMax = Math.max(MIN_WORKERS, Math.floor(cpuCount / weightedCoresPerWorker));
107070
107077
  if (finalWorkers > cpuScaledMax) {
107071
107078
  finalWorkers = cpuScaledMax;
107072
107079
  }
@@ -109588,6 +109595,18 @@ function detectRenderModeHints(html) {
109588
109595
  reasons
109589
109596
  };
109590
109597
  }
109598
+ var SHADER_TRANSITION_USAGE_PATTERN = /\b(?:(?:window|globalThis)\s*\.\s*)?HyperShader\s*\.\s*init\s*\(|\b__hf\s*\.\s*transitions\s*=/;
109599
+ function detectShaderTransitionUsage(html) {
109600
+ let scriptMatch;
109601
+ const scriptPattern = new RegExp(INLINE_SCRIPT_PATTERN.source, INLINE_SCRIPT_PATTERN.flags);
109602
+ while ((scriptMatch = scriptPattern.exec(html)) !== null) {
109603
+ const attrs = scriptMatch[1] || "";
109604
+ if (/\bsrc\s*=/i.test(attrs)) continue;
109605
+ const content = stripJsComments(stripCompilerMountBootstrap(scriptMatch[2] || ""));
109606
+ if (SHADER_TRANSITION_USAGE_PATTERN.test(content)) return true;
109607
+ }
109608
+ return false;
109609
+ }
109591
109610
  async function resolveMediaDuration(src, mediaStart, baseDir, downloadDir, tagName19) {
109592
109611
  let filePath = src;
109593
109612
  if (isHttpUrl(src)) {
@@ -110124,6 +110143,7 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
110124
110143
  "$1"
110125
110144
  );
110126
110145
  const renderModeHints = detectRenderModeHints(sanitizedHtml);
110146
+ const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml);
110127
110147
  const coalescedHtml = await injectDeterministicFontFaces(
110128
110148
  coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml))
110129
110149
  );
@@ -110171,7 +110191,8 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
110171
110191
  width,
110172
110192
  height,
110173
110193
  staticDuration,
110174
- renderModeHints
110194
+ renderModeHints,
110195
+ hasShaderTransitions
110175
110196
  };
110176
110197
  }
110177
110198
  async function discoverMediaFromBrowser(page) {
@@ -110273,7 +110294,8 @@ async function recompileWithResolutions(compiled, resolutions, projectDir, downl
110273
110294
  audios,
110274
110295
  images,
110275
110296
  unresolvedCompositions: remaining,
110276
- renderModeHints: compiled.renderModeHints
110297
+ renderModeHints: compiled.renderModeHints,
110298
+ hasShaderTransitions: compiled.hasShaderTransitions
110277
110299
  };
110278
110300
  }
110279
110301
 
@@ -110552,7 +110574,8 @@ function writeCompiledArtifacts(compiled, workDir, includeSummary) {
110552
110574
  mediaStart: a.mediaStart
110553
110575
  })),
110554
110576
  subCompositions: Array.from(compiled.subCompositions.keys()),
110555
- renderModeHints: compiled.renderModeHints
110577
+ renderModeHints: compiled.renderModeHints,
110578
+ hasShaderTransitions: compiled.hasShaderTransitions
110556
110579
  };
110557
110580
  writeFileSync5(join16(compileDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
110558
110581
  }
@@ -110565,6 +110588,290 @@ function applyRenderModeHints(cfg, compiled, log = defaultLogger) {
110565
110588
  reasons: compiled.renderModeHints.reasons.map((reason) => reason.message)
110566
110589
  });
110567
110590
  }
110591
+ function resolveRenderWorkerCount(totalFrames, requestedWorkers, cfg, compiled, composition, log = defaultLogger, measuredCaptureCost) {
110592
+ const captureCost = combineCaptureCostEstimates(
110593
+ estimateCaptureCostMultiplier(compiled, composition),
110594
+ measuredCaptureCost
110595
+ );
110596
+ const workerCount = calculateOptimalWorkers(totalFrames, requestedWorkers, {
110597
+ ...cfg,
110598
+ captureCostMultiplier: captureCost.multiplier
110599
+ });
110600
+ if (requestedWorkers !== void 0 || captureCost.multiplier <= 1) {
110601
+ return workerCount;
110602
+ }
110603
+ const baselineWorkers = calculateOptimalWorkers(totalFrames, void 0, cfg);
110604
+ if (workerCount < baselineWorkers) {
110605
+ log.warn(
110606
+ "[Render] Reduced auto worker count for high-cost capture workload to avoid Chrome compositor starvation.",
110607
+ {
110608
+ from: baselineWorkers,
110609
+ to: workerCount,
110610
+ costMultiplier: captureCost.multiplier,
110611
+ reasons: captureCost.reasons
110612
+ }
110613
+ );
110614
+ }
110615
+ return workerCount;
110616
+ }
110617
+ function estimateCaptureCostMultiplier(compiled, composition) {
110618
+ let multiplier = 1;
110619
+ const reasons = [];
110620
+ if (compiled.hasShaderTransitions) {
110621
+ multiplier += 2;
110622
+ reasons.push("shader-transitions");
110623
+ }
110624
+ const reasonCodes = new Set(compiled.renderModeHints.reasons.map((reason) => reason.code));
110625
+ if (reasonCodes.has("requestAnimationFrame")) {
110626
+ multiplier += 1;
110627
+ reasons.push("requestAnimationFrame");
110628
+ }
110629
+ if (reasonCodes.has("iframe")) {
110630
+ multiplier += 0.5;
110631
+ reasons.push("iframe");
110632
+ }
110633
+ if (composition.videos.length > 0) {
110634
+ multiplier += Math.min(2, composition.videos.length * 0.75);
110635
+ reasons.push(`${composition.videos.length} video${composition.videos.length === 1 ? "" : "s"}`);
110636
+ }
110637
+ if (composition.audios.length > 0) {
110638
+ multiplier += Math.min(1, composition.audios.length * 0.75);
110639
+ reasons.push(`${composition.audios.length} audio${composition.audios.length === 1 ? "" : "s"}`);
110640
+ }
110641
+ return {
110642
+ multiplier: Math.round(multiplier * 100) / 100,
110643
+ reasons
110644
+ };
110645
+ }
110646
+ function combineCaptureCostEstimates(staticCost, measuredCost) {
110647
+ if (!measuredCost || measuredCost.multiplier <= 1) return staticCost;
110648
+ if (staticCost.multiplier >= measuredCost.multiplier) {
110649
+ return {
110650
+ multiplier: staticCost.multiplier,
110651
+ reasons: [...staticCost.reasons, ...measuredCost.reasons],
110652
+ p95Ms: measuredCost.p95Ms
110653
+ };
110654
+ }
110655
+ return {
110656
+ multiplier: measuredCost.multiplier,
110657
+ reasons: [...measuredCost.reasons, ...staticCost.reasons],
110658
+ p95Ms: measuredCost.p95Ms
110659
+ };
110660
+ }
110661
+ var CAPTURE_CALIBRATION_TARGET_MS = 600;
110662
+ var MAX_MEASURED_CAPTURE_COST_MULTIPLIER = 8;
110663
+ var CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS = 3e4;
110664
+ function createCaptureCalibrationConfig(cfg) {
110665
+ return {
110666
+ ...cfg,
110667
+ protocolTimeout: Math.min(cfg.protocolTimeout, CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS)
110668
+ };
110669
+ }
110670
+ function estimateMeasuredCaptureCostMultiplier(samples) {
110671
+ if (samples.length === 0) {
110672
+ return { multiplier: 1, reasons: [] };
110673
+ }
110674
+ const sorted = [...samples].sort((a, b) => a.captureTimeMs - b.captureTimeMs);
110675
+ const p95Index = Math.max(0, Math.ceil(sorted.length * 0.95) - 1);
110676
+ const p95Sample = sorted[p95Index] ?? sorted[sorted.length - 1];
110677
+ if (!p95Sample) {
110678
+ return { multiplier: 1, reasons: [] };
110679
+ }
110680
+ const p95Ms = Math.round(p95Sample.captureTimeMs);
110681
+ const multiplier = Math.min(
110682
+ MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
110683
+ Math.max(1, Math.round(p95Ms / CAPTURE_CALIBRATION_TARGET_MS * 100) / 100)
110684
+ );
110685
+ return {
110686
+ multiplier,
110687
+ reasons: multiplier > 1 ? [`calibration-p95=${p95Ms}ms`] : [],
110688
+ p95Ms
110689
+ };
110690
+ }
110691
+ function selectCaptureCalibrationFrames(totalFrames) {
110692
+ if (totalFrames <= 0) return [];
110693
+ const lastFrame = totalFrames - 1;
110694
+ const candidates = [
110695
+ 0,
110696
+ Math.floor(totalFrames * 0.25),
110697
+ Math.floor(totalFrames * 0.5),
110698
+ Math.floor(totalFrames * 0.75),
110699
+ lastFrame
110700
+ ];
110701
+ return Array.from(
110702
+ new Set(candidates.map((frame) => Math.max(0, Math.min(lastFrame, frame))))
110703
+ ).sort((a, b) => a - b);
110704
+ }
110705
+ function findMissingFrameRanges(totalFrames, framesDir, frameExt) {
110706
+ const ranges = [];
110707
+ let rangeStart = null;
110708
+ for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
110709
+ const framePath = join16(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`);
110710
+ const missing = !existsSync16(framePath);
110711
+ if (missing && rangeStart === null) {
110712
+ rangeStart = frameIndex;
110713
+ } else if (!missing && rangeStart !== null) {
110714
+ ranges.push({ startFrame: rangeStart, endFrame: frameIndex });
110715
+ rangeStart = null;
110716
+ }
110717
+ }
110718
+ if (rangeStart !== null) {
110719
+ ranges.push({ startFrame: rangeStart, endFrame: totalFrames });
110720
+ }
110721
+ return ranges;
110722
+ }
110723
+ function buildMissingFrameRetryBatches(ranges, maxWorkers, workDir, attempt) {
110724
+ const workersPerBatch = Math.max(1, Math.floor(maxWorkers));
110725
+ const batches = [];
110726
+ for (let i = 0; i < ranges.length; i += workersPerBatch) {
110727
+ const batchIndex = batches.length;
110728
+ const batch = ranges.slice(i, i + workersPerBatch).map((range, workerId) => ({
110729
+ workerId,
110730
+ startFrame: range.startFrame,
110731
+ endFrame: range.endFrame,
110732
+ outputDir: join16(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`)
110733
+ }));
110734
+ batches.push(batch);
110735
+ }
110736
+ return batches;
110737
+ }
110738
+ function getNextRetryWorkerCount(currentWorkers) {
110739
+ return Math.max(1, Math.floor(currentWorkers / 2));
110740
+ }
110741
+ function isRecoverableParallelCaptureError(error) {
110742
+ const message = error instanceof Error ? error.message : String(error);
110743
+ 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(
110744
+ message
110745
+ );
110746
+ }
110747
+ function shouldFallbackToScreenshotAfterCalibrationError(error) {
110748
+ const message = error instanceof Error ? error.message : String(error);
110749
+ return /HeadlessExperimental\.beginFrame timed out|beginFrame probe timeout|Another frame is pending|Frame still pending|Protocol error.*HeadlessExperimental\.beginFrame/i.test(
110750
+ message
110751
+ );
110752
+ }
110753
+ function countCapturedFrames(totalFrames, framesDir, frameExt) {
110754
+ let captured = 0;
110755
+ for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
110756
+ const framePath = join16(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`);
110757
+ if (existsSync16(framePath)) captured++;
110758
+ }
110759
+ return captured;
110760
+ }
110761
+ function countFrameRanges(ranges) {
110762
+ return ranges.reduce((sum, range) => sum + (range.endFrame - range.startFrame), 0);
110763
+ }
110764
+ async function measureCaptureCostFromSession(session, totalFrames, fps) {
110765
+ const sampledFrames = selectCaptureCalibrationFrames(totalFrames);
110766
+ const samples = [];
110767
+ for (const frameIndex of sampledFrames) {
110768
+ const time = frameIndex / fps;
110769
+ const startedAt = Date.now();
110770
+ const result = await captureFrameToBuffer(session, frameIndex, time);
110771
+ samples.push({
110772
+ frameIndex,
110773
+ captureTimeMs: result.captureTimeMs || Date.now() - startedAt
110774
+ });
110775
+ }
110776
+ return {
110777
+ estimate: estimateMeasuredCaptureCostMultiplier(samples),
110778
+ samples
110779
+ };
110780
+ }
110781
+ async function executeDiskCaptureWithAdaptiveRetry(options) {
110782
+ const attempts = [];
110783
+ let currentWorkers = options.initialWorkerCount;
110784
+ let missingRanges = null;
110785
+ let attempt = 0;
110786
+ while (true) {
110787
+ const frameCount = missingRanges ? countFrameRanges(missingRanges) : options.totalFrames;
110788
+ attempts.push({
110789
+ attempt,
110790
+ workers: currentWorkers,
110791
+ frameCount,
110792
+ reason: attempt === 0 ? "initial" : "retry"
110793
+ });
110794
+ const attemptWorkDir = join16(options.workDir, `capture-attempt-${attempt}`);
110795
+ const batches = missingRanges ? buildMissingFrameRetryBatches(missingRanges, currentWorkers, attemptWorkDir, attempt) : [distributeFrames(options.totalFrames, currentWorkers, attemptWorkDir)];
110796
+ try {
110797
+ for (const tasks of batches) {
110798
+ const capturedBeforeBatch = countCapturedFrames(
110799
+ options.totalFrames,
110800
+ options.framesDir,
110801
+ options.frameExt
110802
+ );
110803
+ try {
110804
+ await executeParallelCapture(
110805
+ options.serverUrl,
110806
+ attemptWorkDir,
110807
+ tasks,
110808
+ options.captureOptions,
110809
+ options.createBeforeCaptureHook,
110810
+ options.abortSignal,
110811
+ options.onProgress ? (progress) => {
110812
+ options.onProgress?.({
110813
+ ...progress,
110814
+ totalFrames: options.totalFrames,
110815
+ capturedFrames: Math.min(
110816
+ options.totalFrames,
110817
+ capturedBeforeBatch + progress.capturedFrames
110818
+ )
110819
+ });
110820
+ } : void 0,
110821
+ void 0,
110822
+ options.cfg
110823
+ );
110824
+ } finally {
110825
+ await mergeWorkerFrames(attemptWorkDir, tasks, options.framesDir);
110826
+ }
110827
+ }
110828
+ const remaining = findMissingFrameRanges(
110829
+ options.totalFrames,
110830
+ options.framesDir,
110831
+ options.frameExt
110832
+ );
110833
+ if (remaining.length === 0) {
110834
+ return attempts;
110835
+ }
110836
+ if (!options.allowRetry || currentWorkers <= 1) {
110837
+ throw new Error(
110838
+ `[Render] Capture completed but ${countFrameRanges(remaining)} frame(s) are missing`
110839
+ );
110840
+ }
110841
+ const nextWorkers = getNextRetryWorkerCount(currentWorkers);
110842
+ options.log.warn("[Render] Retrying missing captured frames with fewer workers.", {
110843
+ fromWorkers: currentWorkers,
110844
+ toWorkers: nextWorkers,
110845
+ missingFrames: countFrameRanges(remaining)
110846
+ });
110847
+ currentWorkers = nextWorkers;
110848
+ missingRanges = remaining;
110849
+ attempt++;
110850
+ } catch (error) {
110851
+ const remaining = findMissingFrameRanges(
110852
+ options.totalFrames,
110853
+ options.framesDir,
110854
+ options.frameExt
110855
+ );
110856
+ if (remaining.length === 0) {
110857
+ return attempts;
110858
+ }
110859
+ if (!options.allowRetry || currentWorkers <= 1 || !isRecoverableParallelCaptureError(error)) {
110860
+ throw error;
110861
+ }
110862
+ const nextWorkers = getNextRetryWorkerCount(currentWorkers);
110863
+ options.log.warn("[Render] Parallel capture timed out; retrying missing frames.", {
110864
+ fromWorkers: currentWorkers,
110865
+ toWorkers: nextWorkers,
110866
+ missingFrames: countFrameRanges(remaining),
110867
+ error: error instanceof Error ? error.message : String(error)
110868
+ });
110869
+ currentWorkers = nextWorkers;
110870
+ missingRanges = remaining;
110871
+ attempt++;
110872
+ }
110873
+ }
110874
+ }
110568
110875
  function blitHdrVideoLayer(canvas, el, time, fps, hdrFrameDirs, hdrStartTimes, width, height, log, sourceTransfer, targetTransfer) {
110569
110876
  const frameDir = hdrFrameDirs.get(el.id);
110570
110877
  const startTime = hdrStartTimes.get(el.id);
@@ -111191,7 +111498,7 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
111191
111498
  let extractionResult = null;
111192
111499
  const nativeHdrVideoIds = /* @__PURE__ */ new Set();
111193
111500
  const videoTransfers = /* @__PURE__ */ new Map();
111194
- if (job.config.hdr && composition.videos.length > 0) {
111501
+ if (job.config.hdrMode !== "force-sdr" && composition.videos.length > 0) {
111195
111502
  await Promise.all(
111196
111503
  composition.videos.map(async (v) => {
111197
111504
  let videoPath = v.src;
@@ -111212,7 +111519,7 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
111212
111519
  const imageTransfers = /* @__PURE__ */ new Map();
111213
111520
  const hdrImageSrcPaths = /* @__PURE__ */ new Map();
111214
111521
  const imageColorSpaces = [];
111215
- if (job.config.hdr && composition.images.length > 0) {
111522
+ if (job.config.hdrMode !== "force-sdr" && composition.images.length > 0) {
111216
111523
  const probed = await Promise.all(
111217
111524
  composition.images.map(async (img) => {
111218
111525
  let imgPath = img.src;
@@ -111269,28 +111576,53 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
111269
111576
  perfStages.videoExtractMs = Date.now() - stage2Start;
111270
111577
  }
111271
111578
  let effectiveHdr;
111272
- if (job.config.hdr) {
111579
+ let forcedHdrWithoutSources = false;
111580
+ {
111581
+ const hdrMode = job.config.hdrMode ?? "auto";
111273
111582
  const videoColorSpaces = (extractionResult?.extracted ?? []).map(
111274
111583
  (ext) => ext.metadata.colorSpace
111275
111584
  );
111276
111585
  const allColorSpaces = [...videoColorSpaces, ...imageColorSpaces];
111277
- if (allColorSpaces.length > 0) {
111278
- const info = analyzeCompositionHdr(allColorSpaces);
111279
- if (info.hasHdr && info.dominantTransfer) {
111586
+ const info = allColorSpaces.length > 0 ? analyzeCompositionHdr(allColorSpaces) : null;
111587
+ if (hdrMode === "force-sdr") {
111588
+ effectiveHdr = void 0;
111589
+ } else if (hdrMode === "force-hdr") {
111590
+ if (info?.hasHdr && info.dominantTransfer) {
111591
+ effectiveHdr = { transfer: info.dominantTransfer };
111592
+ } else {
111593
+ effectiveHdr = { transfer: "hlg" };
111594
+ forcedHdrWithoutSources = true;
111595
+ }
111596
+ } else {
111597
+ if (info?.hasHdr && info.dominantTransfer) {
111280
111598
  effectiveHdr = { transfer: info.dominantTransfer };
111281
111599
  }
111282
111600
  }
111283
111601
  }
111284
111602
  if (effectiveHdr && outputFormat !== "mp4") {
111603
+ const hdrSourceReason = forcedHdrWithoutSources ? "HDR was forced without detected HDR sources" : "HDR source detected";
111285
111604
  log.warn(
111286
- `[Render] HDR source detected but format is "${outputFormat}" \u2014 falling back to SDR. HDR + alpha is not supported. Use --format mp4 for HDR10 output.`
111605
+ `[Render] ${hdrSourceReason}, but format is "${outputFormat}" \u2014 falling back to SDR. HDR + alpha is not supported. Use --format mp4 for HDR10 output.`
111287
111606
  );
111288
111607
  effectiveHdr = void 0;
111289
111608
  }
111290
- if (effectiveHdr) {
111291
- log.info(
111292
- `[Render] HDR source detected \u2014 output: ${effectiveHdr.transfer.toUpperCase()} (BT.2020, 10-bit H.265)`
111293
- );
111609
+ {
111610
+ const hdrMode = job.config.hdrMode ?? "auto";
111611
+ if (forcedHdrWithoutSources) {
111612
+ log.warn(
111613
+ "[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."
111614
+ );
111615
+ }
111616
+ if (effectiveHdr) {
111617
+ 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)";
111618
+ log.info(
111619
+ `[Render] HDR ${reason} \u2014 output: ${effectiveHdr.transfer.toUpperCase()} (BT.2020, 10-bit H.265)`
111620
+ );
111621
+ } else if (hdrMode === "force-sdr") {
111622
+ log.info("[Render] SDR forced by --sdr flag");
111623
+ } else {
111624
+ log.info("[Render] No HDR sources detected \u2014 rendering SDR");
111625
+ }
111294
111626
  }
111295
111627
  const stage3Start = Date.now();
111296
111628
  updateJobStatus(job, "preprocessing", "Processing audio tracks", 20, onProgress);
@@ -111337,7 +111669,101 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
111337
111669
  ...captureOptions,
111338
111670
  skipReadinessVideoIds: Array.from(nativeHdrVideoIds)
111339
111671
  });
111340
- const workerCount = calculateOptimalWorkers(totalFrames, job.config.workers, cfg);
111672
+ let captureCalibration;
111673
+ let switchedToScreenshotAfterCalibration = false;
111674
+ if (job.config.workers === void 0 && totalFrames >= 60) {
111675
+ const calibrationDir = join16(workDir, "capture-calibration");
111676
+ const calibrationCfg = createCaptureCalibrationConfig(cfg);
111677
+ const videoInjector = createVideoFrameInjector(frameLookup);
111678
+ let calibrationSession = null;
111679
+ try {
111680
+ calibrationSession = await createCaptureSession(
111681
+ fileServer.url,
111682
+ calibrationDir,
111683
+ buildHdrCaptureOptions(),
111684
+ videoInjector,
111685
+ calibrationCfg
111686
+ );
111687
+ if (!calibrationSession.isInitialized) {
111688
+ await initializeSession(calibrationSession);
111689
+ }
111690
+ assertNotAborted();
111691
+ captureCalibration = await measureCaptureCostFromSession(
111692
+ calibrationSession,
111693
+ totalFrames,
111694
+ job.config.fps
111695
+ );
111696
+ if (captureCalibration.estimate.multiplier > 1) {
111697
+ log.warn("[Render] Measured slow frame capture during auto-worker calibration.", {
111698
+ multiplier: captureCalibration.estimate.multiplier,
111699
+ p95Ms: captureCalibration.estimate.p95Ms,
111700
+ sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex)
111701
+ });
111702
+ } else {
111703
+ log.debug("[Render] Auto-worker calibration kept baseline capture cost.", {
111704
+ p95Ms: captureCalibration.estimate.p95Ms,
111705
+ sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex)
111706
+ });
111707
+ }
111708
+ } catch (error) {
111709
+ const shouldFallbackToScreenshot = !cfg.forceScreenshot && shouldFallbackToScreenshotAfterCalibrationError(error);
111710
+ if (shouldFallbackToScreenshot) {
111711
+ cfg.forceScreenshot = true;
111712
+ switchedToScreenshotAfterCalibration = true;
111713
+ if (probeSession) {
111714
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
111715
+ await closeCaptureSession(probeSession).catch(() => {
111716
+ });
111717
+ probeSession = null;
111718
+ }
111719
+ }
111720
+ captureCalibration = {
111721
+ estimate: {
111722
+ multiplier: MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
111723
+ reasons: shouldFallbackToScreenshot ? ["calibration-beginframe-timeout", "screenshot-fallback"] : ["calibration-failed"]
111724
+ },
111725
+ samples: []
111726
+ };
111727
+ if (shouldFallbackToScreenshot) {
111728
+ log.warn(
111729
+ "[Render] BeginFrame auto-worker calibration timed out; falling back to screenshot capture mode.",
111730
+ {
111731
+ protocolTimeout: calibrationCfg.protocolTimeout,
111732
+ error: error instanceof Error ? error.message : String(error)
111733
+ }
111734
+ );
111735
+ } else {
111736
+ log.warn("[Render] Auto-worker calibration failed; using conservative worker budget.", {
111737
+ protocolTimeout: calibrationCfg.protocolTimeout,
111738
+ error: error instanceof Error ? error.message : String(error)
111739
+ });
111740
+ }
111741
+ } finally {
111742
+ if (calibrationSession) {
111743
+ lastBrowserConsole = calibrationSession.browserConsoleBuffer;
111744
+ await closeCaptureSession(calibrationSession).catch(() => {
111745
+ });
111746
+ }
111747
+ }
111748
+ }
111749
+ let workerCount = resolveRenderWorkerCount(
111750
+ totalFrames,
111751
+ job.config.workers,
111752
+ cfg,
111753
+ compiled,
111754
+ composition,
111755
+ log,
111756
+ captureCalibration?.estimate
111757
+ );
111758
+ if (switchedToScreenshotAfterCalibration && workerCount > 1) {
111759
+ workerCount = 1;
111760
+ }
111761
+ if (workerCount > 1 && probeSession) {
111762
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
111763
+ await closeCaptureSession(probeSession);
111764
+ probeSession = null;
111765
+ }
111766
+ const captureAttempts = [];
111341
111767
  const FORMAT_EXT = {
111342
111768
  mp4: ".mp4",
111343
111769
  webm: ".webm",
@@ -111929,15 +112355,18 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
111929
112355
  perfStages.encodeMs = encodeResult.durationMs;
111930
112356
  } else {
111931
112357
  if (workerCount > 1) {
111932
- const tasks = distributeFrames(job.totalFrames, workerCount, workDir);
111933
- await executeParallelCapture(
111934
- fileServer.url,
112358
+ const attempts = await executeDiskCaptureWithAdaptiveRetry({
112359
+ serverUrl: fileServer.url,
111935
112360
  workDir,
111936
- tasks,
111937
- buildHdrCaptureOptions(),
111938
- () => createVideoFrameInjector(frameLookup),
112361
+ framesDir,
112362
+ totalFrames: job.totalFrames,
112363
+ initialWorkerCount: workerCount,
112364
+ allowRetry: job.config.workers === void 0,
112365
+ frameExt: needsAlpha ? "png" : "jpg",
112366
+ captureOptions: buildHdrCaptureOptions(),
112367
+ createBeforeCaptureHook: () => createVideoFrameInjector(frameLookup),
111939
112368
  abortSignal,
111940
- (progress) => {
112369
+ onProgress: (progress) => {
111941
112370
  job.framesRendered = progress.capturedFrames;
111942
112371
  const frameProgress = progress.capturedFrames / progress.totalFrames;
111943
112372
  const progressPct = 25 + frameProgress * 45;
@@ -111945,16 +112374,20 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
111945
112374
  updateJobStatus(
111946
112375
  job,
111947
112376
  "rendering",
111948
- `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`,
112377
+ `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
111949
112378
  Math.round(progressPct),
111950
112379
  onProgress
111951
112380
  );
111952
112381
  }
111953
112382
  },
111954
- void 0,
111955
- cfg
111956
- );
111957
- await mergeWorkerFrames(workDir, tasks, framesDir);
112383
+ cfg,
112384
+ log
112385
+ });
112386
+ captureAttempts.push(...attempts);
112387
+ const lastAttempt = attempts[attempts.length - 1];
112388
+ if (lastAttempt) {
112389
+ workerCount = lastAttempt.workers;
112390
+ }
111958
112391
  if (probeSession) {
111959
112392
  lastBrowserConsole = probeSession.browserConsoleBuffer;
111960
112393
  await closeCaptureSession(probeSession);
@@ -112124,6 +112557,13 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
112124
112557
  stages: perfStages,
112125
112558
  videoExtractBreakdown: extractionResult?.phaseBreakdown,
112126
112559
  tmpPeakBytes,
112560
+ captureCalibration: captureCalibration ? {
112561
+ sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex),
112562
+ p95Ms: captureCalibration.estimate.p95Ms,
112563
+ multiplier: captureCalibration.estimate.multiplier,
112564
+ reasons: captureCalibration.estimate.reasons
112565
+ } : void 0,
112566
+ captureAttempts: captureAttempts.length > 0 ? captureAttempts : void 0,
112127
112567
  hdrDiagnostics: hdrDiagnostics.videoExtractionFailures > 0 || hdrDiagnostics.imageDecodeFailures > 0 ? { ...hdrDiagnostics } : void 0,
112128
112568
  captureAvgMs: totalFrames > 0 ? Math.round((perfStages.captureMs ?? 0) / totalFrames) : void 0,
112129
112569
  peakRssMb: Math.round(peakRssBytes / (1024 * 1024)),