@hyperframes/producer 0.2.3 → 0.2.5

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
@@ -99285,7 +99285,8 @@ function truncateSnippet(value, maxLength = 220) {
99285
99285
 
99286
99286
  // ../core/src/lint/context.ts
99287
99287
  function buildLintContext(html, options = {}) {
99288
- let source2 = html || "";
99288
+ const rawSource = html || "";
99289
+ let source2 = rawSource;
99289
99290
  const templateMatch = source2.match(/<template[^>]*>([\s\S]*)<\/template>/i);
99290
99291
  if (templateMatch?.[1]) source2 = templateMatch[1];
99291
99292
  const tags = extractOpenTags(source2);
@@ -99296,6 +99297,7 @@ function buildLintContext(html, options = {}) {
99296
99297
  const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");
99297
99298
  return {
99298
99299
  source: source2,
99300
+ rawSource,
99299
99301
  tags,
99300
99302
  styles,
99301
99303
  scripts,
@@ -100086,8 +100088,30 @@ ${right2.raw}`)
100086
100088
  findings.push({
100087
100089
  code: "gsap_infinite_repeat",
100088
100090
  severity: "error",
100089
- message: "GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic capture engine which seeks to exact frame times. Use a finite repeat count calculated from the composition duration: `repeat: Math.ceil(duration / cycleDuration) - 1`.",
100090
- fixHint: "Replace `repeat: -1` with a finite count, e.g. `repeat: Math.ceil(totalDuration / singleCycleDuration) - 1`.",
100091
+ message: "GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic capture engine which seeks to exact frame times. Use a finite repeat count calculated from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
100092
+ fixHint: "Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.",
100093
+ snippet: truncateSnippet(snippet)
100094
+ });
100095
+ }
100096
+ }
100097
+ return findings;
100098
+ },
100099
+ // gsap_repeat_ceil_overshoot
100100
+ ({ scripts }) => {
100101
+ const findings = [];
100102
+ for (const script of scripts) {
100103
+ const content = script.content;
100104
+ const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
100105
+ let match2;
100106
+ while ((match2 = pattern.exec(content)) !== null) {
100107
+ const contextStart = Math.max(0, match2.index - 40);
100108
+ const contextEnd = Math.min(content.length, match2.index + match2[0].length + 40);
100109
+ const snippet = content.slice(contextStart, contextEnd).trim();
100110
+ findings.push({
100111
+ code: "gsap_repeat_ceil_overshoot",
100112
+ severity: "warning",
100113
+ message: "GSAP repeat calculation uses `Math.ceil` which can overshoot the composition duration. For example, Math.ceil(10.5 / 2) - 1 = 5 repeats \u2192 6 cycles \xD7 2s = 12s, exceeding 10.5s.",
100114
+ fixHint: "Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: `repeat: Math.floor(totalDuration / cycleDuration) - 1`. Math.floor(10.5 / 2) - 1 = 4 repeats \u2192 5 cycles \xD7 2s = 10s \u2713",
100091
100115
  snippet: truncateSnippet(snippet)
100092
100116
  });
100093
100117
  }
@@ -100469,6 +100493,76 @@ var compositionRules = [
100469
100493
  }
100470
100494
  return findings;
100471
100495
  },
100496
+ // root_composition_missing_data_start
100497
+ ({ rootTag }) => {
100498
+ const findings = [];
100499
+ if (!rootTag) return findings;
100500
+ const compId = readAttr(rootTag.raw, "data-composition-id");
100501
+ if (!compId) return findings;
100502
+ const hasStart = readAttr(rootTag.raw, "data-start") !== null;
100503
+ if (!hasStart) {
100504
+ findings.push({
100505
+ code: "root_composition_missing_data_start",
100506
+ severity: "warning",
100507
+ message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
100508
+ fixHint: 'Add data-start="0" to the root composition element.',
100509
+ snippet: truncateSnippet(rootTag.raw)
100510
+ });
100511
+ }
100512
+ return findings;
100513
+ },
100514
+ // root_composition_missing_data_duration
100515
+ ({ rootTag }) => {
100516
+ const findings = [];
100517
+ if (!rootTag) return findings;
100518
+ const compId = readAttr(rootTag.raw, "data-composition-id");
100519
+ if (!compId) return findings;
100520
+ const hasDuration = readAttr(rootTag.raw, "data-duration") !== null;
100521
+ if (!hasDuration) {
100522
+ findings.push({
100523
+ code: "root_composition_missing_data_duration",
100524
+ severity: "warning",
100525
+ message: `Root composition "${compId}" is missing data-duration. Without an explicit duration, the runtime may infer Infinity for compositions with repeating animations, causing playback issues.`,
100526
+ fixHint: 'Add data-duration="X" to the root composition element, where X is the total duration in seconds.',
100527
+ snippet: truncateSnippet(rootTag.raw)
100528
+ });
100529
+ }
100530
+ return findings;
100531
+ },
100532
+ // standalone_composition_wrapped_in_template
100533
+ ({ rawSource, options }) => {
100534
+ const findings = [];
100535
+ if (options.isSubComposition) return findings;
100536
+ const trimmed = rawSource.trimStart().toLowerCase();
100537
+ if (trimmed.startsWith("<template")) {
100538
+ findings.push({
100539
+ code: "standalone_composition_wrapped_in_template",
100540
+ severity: "warning",
100541
+ message: "Root index.html is wrapped in a <template> tag. Only sub-compositions loaded via data-composition-src should use <template> wrappers. The runtime cannot play a standalone composition inside a template.",
100542
+ fixHint: "Remove the <template> wrapper. Use <!DOCTYPE html><html>...<div data-composition-id>...</div>...</html> instead."
100543
+ });
100544
+ }
100545
+ return findings;
100546
+ },
100547
+ // root_composition_missing_html_wrapper
100548
+ ({ rawSource, rootTag, options }) => {
100549
+ const findings = [];
100550
+ if (options.isSubComposition) return findings;
100551
+ const trimmed = rawSource.trimStart().toLowerCase();
100552
+ if (trimmed.startsWith("<template")) return findings;
100553
+ const hasDoctype = trimmed.startsWith("<!doctype") || trimmed.startsWith("<html");
100554
+ const hasComposition = rawSource.includes("data-composition-id");
100555
+ if (hasComposition && !hasDoctype) {
100556
+ findings.push({
100557
+ code: "root_composition_missing_html_wrapper",
100558
+ severity: "error",
100559
+ message: "Composition starts with a bare element instead of a proper HTML document. An index.html that contains data-composition-id but no <!DOCTYPE html>, <html>, or <body> is a fragment \u2014 browsers quirks-mode it, the preview server cannot load it, and the bundler will fail to inject runtime scripts.",
100560
+ fixHint: 'Wrap the composition in <!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>...</body></html>.',
100561
+ snippet: rootTag ? truncateSnippet(rootTag.raw) : void 0
100562
+ });
100563
+ }
100564
+ return findings;
100565
+ },
100472
100566
  // requestanimationframe_in_composition
100473
100567
  ({ scripts }) => {
100474
100568
  const findings = [];
@@ -100650,12 +100744,12 @@ function quantizeTimeToFrame(timeSeconds, fps) {
100650
100744
  return frameIndex / safeFps;
100651
100745
  }
100652
100746
 
100653
- // ../../node_modules/.bun/@chenglou+pretext@0.0.3/node_modules/@chenglou/pretext/dist/analysis.js
100747
+ // ../../node_modules/.bun/@chenglou+pretext@0.0.5/node_modules/@chenglou/pretext/dist/analysis.js
100654
100748
  var arabicScriptRe = new RegExp("\\p{Script=Arabic}", "u");
100655
100749
  var combiningMarkRe = new RegExp("\\p{M}", "u");
100656
100750
  var decimalDigitRe = new RegExp("\\p{Nd}", "u");
100657
100751
 
100658
- // ../../node_modules/.bun/@chenglou+pretext@0.0.3/node_modules/@chenglou/pretext/dist/measurement.js
100752
+ // ../../node_modules/.bun/@chenglou+pretext@0.0.5/node_modules/@chenglou/pretext/dist/measurement.js
100659
100753
  var emojiPresentationRe = new RegExp("\\p{Emoji_Presentation}", "u");
100660
100754
 
100661
100755
  // ../engine/src/services/screenshotService.ts
@@ -100669,18 +100763,36 @@ async function getCdpSession(page) {
100669
100763
  return client;
100670
100764
  }
100671
100765
  var lastFrameCache = /* @__PURE__ */ new WeakMap();
100766
+ var PENDING_FRAME_RETRIES = 5;
100767
+ async function sendBeginFrame(client, params) {
100768
+ for (let attempt = 0; ; attempt++) {
100769
+ try {
100770
+ return await client.send("HeadlessExperimental.beginFrame", params);
100771
+ } catch (err) {
100772
+ const msg = err instanceof Error ? err.message : String(err);
100773
+ const isPending = msg.includes("Another frame is pending");
100774
+ if (isPending && attempt < PENDING_FRAME_RETRIES) {
100775
+ await new Promise((r) => setTimeout(r, 50 * 2 ** attempt));
100776
+ continue;
100777
+ }
100778
+ if (isPending) {
100779
+ throw new Error(
100780
+ `[BeginFrame] Frame still pending after ${PENDING_FRAME_RETRIES} retries \u2014 CPU overloaded by parallel renders. Reduce concurrent renders or use --docker for isolation.`
100781
+ );
100782
+ }
100783
+ throw err;
100784
+ }
100785
+ }
100786
+ }
100672
100787
  async function beginFrameCapture(page, options, frameTimeTicks, interval) {
100673
100788
  const client = await getCdpSession(page);
100674
- const format3 = options.format === "png" ? "png" : "jpeg";
100675
- const result = await client.send("HeadlessExperimental.beginFrame", {
100676
- frameTimeTicks,
100677
- interval,
100678
- screenshot: {
100679
- format: format3,
100680
- quality: format3 === "jpeg" ? options.quality ?? 80 : void 0,
100681
- optimizeForSpeed: true
100682
- }
100683
- });
100789
+ const isPng = options.format === "png";
100790
+ const screenshot = {
100791
+ format: isPng ? "png" : "jpeg",
100792
+ quality: isPng ? void 0 : options.quality ?? 80,
100793
+ optimizeForSpeed: true
100794
+ };
100795
+ const result = await sendBeginFrame(client, { frameTimeTicks, interval, screenshot });
100684
100796
  let buffer;
100685
100797
  if (result.screenshotData) {
100686
100798
  buffer = Buffer.from(result.screenshotData, "base64");
@@ -100690,16 +100802,12 @@ async function beginFrameCapture(page, options, frameTimeTicks, interval) {
100690
100802
  if (cached) {
100691
100803
  buffer = cached;
100692
100804
  } else {
100693
- const retry2 = await client.send("HeadlessExperimental.beginFrame", {
100805
+ const fallback = await sendBeginFrame(client, {
100694
100806
  frameTimeTicks: frameTimeTicks + 1e-3,
100695
100807
  interval,
100696
- screenshot: {
100697
- format: format3,
100698
- quality: format3 === "jpeg" ? options.quality ?? 80 : void 0,
100699
- optimizeForSpeed: true
100700
- }
100808
+ screenshot
100701
100809
  });
100702
- buffer = retry2.screenshotData ? Buffer.from(retry2.screenshotData, "base64") : Buffer.alloc(0);
100810
+ buffer = fallback.screenshotData ? Buffer.from(fallback.screenshotData, "base64") : Buffer.alloc(0);
100703
100811
  if (buffer.length > 0) lastFrameCache.set(page, buffer);
100704
100812
  }
100705
100813
  }
@@ -102202,7 +102310,7 @@ async function extractVideoFramesRange(videoPath, videoId, startTime, duration,
102202
102310
  });
102203
102311
  });
102204
102312
  }
102205
- async function extractAllVideoFrames(videos, baseDir, options, signal, config2) {
102313
+ async function extractAllVideoFrames(videos, baseDir, options, signal, config2, compiledDir) {
102206
102314
  const startTime = Date.now();
102207
102315
  const extracted = [];
102208
102316
  const errors = [];
@@ -102215,7 +102323,8 @@ async function extractAllVideoFrames(videos, baseDir, options, signal, config2)
102215
102323
  try {
102216
102324
  let videoPath = video.src;
102217
102325
  if (!videoPath.startsWith("/") && !isHttpUrl(videoPath)) {
102218
- videoPath = join8(baseDir, videoPath);
102326
+ const fromCompiled = compiledDir ? join8(compiledDir, videoPath) : null;
102327
+ videoPath = fromCompiled && existsSync8(fromCompiled) ? fromCompiled : join8(baseDir, videoPath);
102219
102328
  }
102220
102329
  if (isHttpUrl(videoPath)) {
102221
102330
  const downloadDir = join8(options.outputDir, "_downloads");
@@ -102676,7 +102785,7 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config2
102676
102785
  tracksProcessed: tracks.length
102677
102786
  };
102678
102787
  }
102679
- async function processCompositionAudio(elements, baseDir, workDir, outputPath, totalDuration, signal, config2) {
102788
+ async function processCompositionAudio(elements, baseDir, workDir, outputPath, totalDuration, signal, config2, compiledDir) {
102680
102789
  const startMs = Date.now();
102681
102790
  const tracks = [];
102682
102791
  const errors = [];
@@ -102690,7 +102799,8 @@ async function processCompositionAudio(elements, baseDir, workDir, outputPath, t
102690
102799
  try {
102691
102800
  let srcPath = element.src;
102692
102801
  if (!srcPath.startsWith("/") && !isHttpUrl(srcPath)) {
102693
- srcPath = join9(baseDir, srcPath);
102802
+ const fromCompiled = compiledDir ? join9(compiledDir, srcPath) : null;
102803
+ srcPath = fromCompiled && existsSync9(fromCompiled) ? fromCompiled : join9(baseDir, srcPath);
102694
102804
  }
102695
102805
  if (isHttpUrl(srcPath)) {
102696
102806
  try {
@@ -102703,7 +102813,7 @@ async function processCompositionAudio(elements, baseDir, workDir, outputPath, t
102703
102813
  }
102704
102814
  }
102705
102815
  if (!existsSync9(srcPath)) {
102706
- errors.push(`Source not found: ${element.id}`);
102816
+ errors.push(`Source not found: ${element.id} (${element.src})`);
102707
102817
  return;
102708
102818
  }
102709
102819
  if (element.end - element.start <= 0) {
@@ -107405,12 +107515,15 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
107405
107515
  const stage2Start = Date.now();
107406
107516
  updateJobStatus(job, "preprocessing", "Extracting video frames", 10, onProgress);
107407
107517
  let frameLookup = null;
107518
+ const compiledDir = join13(workDir, "compiled");
107408
107519
  if (composition.videos.length > 0) {
107409
107520
  const extractionResult = await extractAllVideoFrames(
107410
107521
  composition.videos,
107411
107522
  projectDir,
107412
107523
  { fps: job.config.fps, outputDir: join13(workDir, "video-frames") },
107413
- abortSignal
107524
+ abortSignal,
107525
+ void 0,
107526
+ compiledDir
107414
107527
  );
107415
107528
  assertNotAborted();
107416
107529
  if (extractionResult.extracted.length > 0) {
@@ -107450,7 +107563,9 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
107450
107563
  join13(workDir, "audio-work"),
107451
107564
  audioOutputPath,
107452
107565
  job.duration,
107453
- abortSignal
107566
+ abortSignal,
107567
+ void 0,
107568
+ compiledDir
107454
107569
  );
107455
107570
  assertNotAborted();
107456
107571
  hasAudio = audioResult.success;
@@ -108117,6 +108232,40 @@ function resolveRenderPaths(projectDir, outputPath, rendersDir = DEFAULT_RENDERS
108117
108232
  return { absoluteProjectDir, absoluteOutputPath };
108118
108233
  }
108119
108234
 
108235
+ // src/utils/semaphore.ts
108236
+ var Semaphore = class {
108237
+ constructor(maxConcurrent) {
108238
+ this.maxConcurrent = maxConcurrent;
108239
+ }
108240
+ queue = [];
108241
+ active = 0;
108242
+ async acquire() {
108243
+ if (this.active < this.maxConcurrent) {
108244
+ this.active++;
108245
+ return () => this.release();
108246
+ }
108247
+ return new Promise((resolve13) => {
108248
+ this.queue.push(() => {
108249
+ this.active++;
108250
+ resolve13(() => this.release());
108251
+ });
108252
+ });
108253
+ }
108254
+ release() {
108255
+ this.active--;
108256
+ const next = this.queue.shift();
108257
+ if (next) next();
108258
+ }
108259
+ /** Current number of active slots. */
108260
+ get activeCount() {
108261
+ return this.active;
108262
+ }
108263
+ /** Number of waiters in the queue. */
108264
+ get waitingCount() {
108265
+ return this.queue.length;
108266
+ }
108267
+ };
108268
+
108120
108269
  // src/server.ts
108121
108270
  function parseRenderOptions(body) {
108122
108271
  const fps = [24, 30, 60].includes(body.fps) ? body.fps : 30;
@@ -108230,6 +108379,8 @@ function createRenderHandlers(options = {}) {
108230
108379
  const rendersDir = options.rendersDir ?? process.env.PRODUCER_RENDERS_DIR ?? "/tmp";
108231
108380
  const artifactTtlMs = options.artifactTtlMs ?? Number(process.env.PRODUCER_OUTPUT_ARTIFACT_TTL_MS || 15 * 60 * 1e3);
108232
108381
  const store = createArtifactStore(artifactTtlMs);
108382
+ const maxConcurrentRenders = options.maxConcurrentRenders ?? Number(process.env.PRODUCER_MAX_CONCURRENT_RENDERS || 2);
108383
+ const renderSemaphore = new Semaphore(maxConcurrentRenders);
108233
108384
  const startTime = Date.now();
108234
108385
  const health = (c) => c.json({
108235
108386
  status: "ok",
@@ -108286,6 +108437,7 @@ function createRenderHandlers(options = {}) {
108286
108437
  );
108287
108438
  const outputDir = dirname11(absoluteOutputPath);
108288
108439
  if (!existsSync16(outputDir)) mkdirSync10(outputDir, { recursive: true });
108440
+ const release = await renderSemaphore.acquire();
108289
108441
  log.info("render started", {
108290
108442
  requestId,
108291
108443
  projectDir: input2.projectDir,
@@ -108353,6 +108505,7 @@ function createRenderHandlers(options = {}) {
108353
108505
  500
108354
108506
  );
108355
108507
  } finally {
108508
+ release();
108356
108509
  cleanupTempDir(cleanupProjectDir, log);
108357
108510
  }
108358
108511
  };
@@ -108409,6 +108562,16 @@ function createRenderHandlers(options = {}) {
108409
108562
  const abortController = new AbortController();
108410
108563
  const onRequestAbort = () => abortController.abort(new RenderCancelledError("request_aborted"));
108411
108564
  c.req.raw.signal.addEventListener("abort", onRequestAbort, { once: true });
108565
+ if (renderSemaphore.activeCount >= maxConcurrentRenders) {
108566
+ await stream2.writeSSE({
108567
+ data: JSON.stringify({
108568
+ type: "queued",
108569
+ requestId,
108570
+ position: renderSemaphore.waitingCount
108571
+ })
108572
+ });
108573
+ }
108574
+ const release = await renderSemaphore.acquire();
108412
108575
  try {
108413
108576
  await executeRenderJob(
108414
108577
  job,
@@ -108476,6 +108639,7 @@ function createRenderHandlers(options = {}) {
108476
108639
  })
108477
108640
  });
108478
108641
  } finally {
108642
+ release();
108479
108643
  c.req.raw.signal.removeEventListener("abort", onRequestAbort);
108480
108644
  cleanupTempDir(cleanupProjectDir, log);
108481
108645
  }
@@ -108500,7 +108664,12 @@ function createRenderHandlers(options = {}) {
108500
108664
  }
108501
108665
  });
108502
108666
  };
108503
- return { render: render2, renderStream, lint, health, outputs };
108667
+ const queue = (c) => c.json({
108668
+ maxConcurrentRenders,
108669
+ activeRenders: renderSemaphore.activeCount,
108670
+ queuedRenders: renderSemaphore.waitingCount
108671
+ });
108672
+ return { render: render2, renderStream, lint, health, outputs, queue };
108504
108673
  }
108505
108674
  function createProducerApp(options = {}) {
108506
108675
  const app = new Hono2();
@@ -108508,6 +108677,7 @@ function createProducerApp(options = {}) {
108508
108677
  app.get("/health", handlers.health);
108509
108678
  app.post("/render", handlers.render);
108510
108679
  app.post("/render/stream", handlers.renderStream);
108680
+ app.get("/render/queue", handlers.queue);
108511
108681
  app.post("/lint", handlers.lint);
108512
108682
  app.get("/outputs/:token", handlers.outputs);
108513
108683
  return app;