@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.
@@ -102074,7 +102074,8 @@ function truncateSnippet(value, maxLength = 220) {
102074
102074
 
102075
102075
  // ../core/src/lint/context.ts
102076
102076
  function buildLintContext(html, options = {}) {
102077
- let source2 = html || "";
102077
+ const rawSource = html || "";
102078
+ let source2 = rawSource;
102078
102079
  const templateMatch = source2.match(/<template[^>]*>([\s\S]*)<\/template>/i);
102079
102080
  if (templateMatch?.[1]) source2 = templateMatch[1];
102080
102081
  const tags = extractOpenTags(source2);
@@ -102085,6 +102086,7 @@ function buildLintContext(html, options = {}) {
102085
102086
  const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");
102086
102087
  return {
102087
102088
  source: source2,
102089
+ rawSource,
102088
102090
  tags,
102089
102091
  styles,
102090
102092
  scripts,
@@ -102875,8 +102877,30 @@ ${right2.raw}`)
102875
102877
  findings.push({
102876
102878
  code: "gsap_infinite_repeat",
102877
102879
  severity: "error",
102878
- 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`.",
102879
- fixHint: "Replace `repeat: -1` with a finite count, e.g. `repeat: Math.ceil(totalDuration / singleCycleDuration) - 1`.",
102880
+ 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`.",
102881
+ 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.",
102882
+ snippet: truncateSnippet(snippet)
102883
+ });
102884
+ }
102885
+ }
102886
+ return findings;
102887
+ },
102888
+ // gsap_repeat_ceil_overshoot
102889
+ ({ scripts }) => {
102890
+ const findings = [];
102891
+ for (const script of scripts) {
102892
+ const content = script.content;
102893
+ const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
102894
+ let match2;
102895
+ while ((match2 = pattern.exec(content)) !== null) {
102896
+ const contextStart = Math.max(0, match2.index - 40);
102897
+ const contextEnd = Math.min(content.length, match2.index + match2[0].length + 40);
102898
+ const snippet = content.slice(contextStart, contextEnd).trim();
102899
+ findings.push({
102900
+ code: "gsap_repeat_ceil_overshoot",
102901
+ severity: "warning",
102902
+ 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.",
102903
+ 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",
102880
102904
  snippet: truncateSnippet(snippet)
102881
102905
  });
102882
102906
  }
@@ -103258,6 +103282,76 @@ var compositionRules = [
103258
103282
  }
103259
103283
  return findings;
103260
103284
  },
103285
+ // root_composition_missing_data_start
103286
+ ({ rootTag }) => {
103287
+ const findings = [];
103288
+ if (!rootTag) return findings;
103289
+ const compId = readAttr(rootTag.raw, "data-composition-id");
103290
+ if (!compId) return findings;
103291
+ const hasStart = readAttr(rootTag.raw, "data-start") !== null;
103292
+ if (!hasStart) {
103293
+ findings.push({
103294
+ code: "root_composition_missing_data_start",
103295
+ severity: "warning",
103296
+ message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
103297
+ fixHint: 'Add data-start="0" to the root composition element.',
103298
+ snippet: truncateSnippet(rootTag.raw)
103299
+ });
103300
+ }
103301
+ return findings;
103302
+ },
103303
+ // root_composition_missing_data_duration
103304
+ ({ rootTag }) => {
103305
+ const findings = [];
103306
+ if (!rootTag) return findings;
103307
+ const compId = readAttr(rootTag.raw, "data-composition-id");
103308
+ if (!compId) return findings;
103309
+ const hasDuration = readAttr(rootTag.raw, "data-duration") !== null;
103310
+ if (!hasDuration) {
103311
+ findings.push({
103312
+ code: "root_composition_missing_data_duration",
103313
+ severity: "warning",
103314
+ 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.`,
103315
+ fixHint: 'Add data-duration="X" to the root composition element, where X is the total duration in seconds.',
103316
+ snippet: truncateSnippet(rootTag.raw)
103317
+ });
103318
+ }
103319
+ return findings;
103320
+ },
103321
+ // standalone_composition_wrapped_in_template
103322
+ ({ rawSource, options }) => {
103323
+ const findings = [];
103324
+ if (options.isSubComposition) return findings;
103325
+ const trimmed = rawSource.trimStart().toLowerCase();
103326
+ if (trimmed.startsWith("<template")) {
103327
+ findings.push({
103328
+ code: "standalone_composition_wrapped_in_template",
103329
+ severity: "warning",
103330
+ 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.",
103331
+ fixHint: "Remove the <template> wrapper. Use <!DOCTYPE html><html>...<div data-composition-id>...</div>...</html> instead."
103332
+ });
103333
+ }
103334
+ return findings;
103335
+ },
103336
+ // root_composition_missing_html_wrapper
103337
+ ({ rawSource, rootTag, options }) => {
103338
+ const findings = [];
103339
+ if (options.isSubComposition) return findings;
103340
+ const trimmed = rawSource.trimStart().toLowerCase();
103341
+ if (trimmed.startsWith("<template")) return findings;
103342
+ const hasDoctype = trimmed.startsWith("<!doctype") || trimmed.startsWith("<html");
103343
+ const hasComposition = rawSource.includes("data-composition-id");
103344
+ if (hasComposition && !hasDoctype) {
103345
+ findings.push({
103346
+ code: "root_composition_missing_html_wrapper",
103347
+ severity: "error",
103348
+ 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.",
103349
+ fixHint: 'Wrap the composition in <!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>...</body></html>.',
103350
+ snippet: rootTag ? truncateSnippet(rootTag.raw) : void 0
103351
+ });
103352
+ }
103353
+ return findings;
103354
+ },
103261
103355
  // requestanimationframe_in_composition
103262
103356
  ({ scripts }) => {
103263
103357
  const findings = [];
@@ -103439,12 +103533,12 @@ function quantizeTimeToFrame(timeSeconds, fps) {
103439
103533
  return frameIndex / safeFps;
103440
103534
  }
103441
103535
 
103442
- // ../../node_modules/.bun/@chenglou+pretext@0.0.3/node_modules/@chenglou/pretext/dist/analysis.js
103536
+ // ../../node_modules/.bun/@chenglou+pretext@0.0.5/node_modules/@chenglou/pretext/dist/analysis.js
103443
103537
  var arabicScriptRe = new RegExp("\\p{Script=Arabic}", "u");
103444
103538
  var combiningMarkRe = new RegExp("\\p{M}", "u");
103445
103539
  var decimalDigitRe = new RegExp("\\p{Nd}", "u");
103446
103540
 
103447
- // ../../node_modules/.bun/@chenglou+pretext@0.0.3/node_modules/@chenglou/pretext/dist/measurement.js
103541
+ // ../../node_modules/.bun/@chenglou+pretext@0.0.5/node_modules/@chenglou/pretext/dist/measurement.js
103448
103542
  var emojiPresentationRe = new RegExp("\\p{Emoji_Presentation}", "u");
103449
103543
 
103450
103544
  // ../engine/src/services/screenshotService.ts
@@ -103458,18 +103552,36 @@ async function getCdpSession(page) {
103458
103552
  return client;
103459
103553
  }
103460
103554
  var lastFrameCache = /* @__PURE__ */ new WeakMap();
103555
+ var PENDING_FRAME_RETRIES = 5;
103556
+ async function sendBeginFrame(client, params) {
103557
+ for (let attempt = 0; ; attempt++) {
103558
+ try {
103559
+ return await client.send("HeadlessExperimental.beginFrame", params);
103560
+ } catch (err) {
103561
+ const msg = err instanceof Error ? err.message : String(err);
103562
+ const isPending = msg.includes("Another frame is pending");
103563
+ if (isPending && attempt < PENDING_FRAME_RETRIES) {
103564
+ await new Promise((r) => setTimeout(r, 50 * 2 ** attempt));
103565
+ continue;
103566
+ }
103567
+ if (isPending) {
103568
+ throw new Error(
103569
+ `[BeginFrame] Frame still pending after ${PENDING_FRAME_RETRIES} retries \u2014 CPU overloaded by parallel renders. Reduce concurrent renders or use --docker for isolation.`
103570
+ );
103571
+ }
103572
+ throw err;
103573
+ }
103574
+ }
103575
+ }
103461
103576
  async function beginFrameCapture(page, options, frameTimeTicks, interval) {
103462
103577
  const client = await getCdpSession(page);
103463
- const format3 = options.format === "png" ? "png" : "jpeg";
103464
- const result = await client.send("HeadlessExperimental.beginFrame", {
103465
- frameTimeTicks,
103466
- interval,
103467
- screenshot: {
103468
- format: format3,
103469
- quality: format3 === "jpeg" ? options.quality ?? 80 : void 0,
103470
- optimizeForSpeed: true
103471
- }
103472
- });
103578
+ const isPng = options.format === "png";
103579
+ const screenshot = {
103580
+ format: isPng ? "png" : "jpeg",
103581
+ quality: isPng ? void 0 : options.quality ?? 80,
103582
+ optimizeForSpeed: true
103583
+ };
103584
+ const result = await sendBeginFrame(client, { frameTimeTicks, interval, screenshot });
103473
103585
  let buffer;
103474
103586
  if (result.screenshotData) {
103475
103587
  buffer = Buffer.from(result.screenshotData, "base64");
@@ -103479,16 +103591,12 @@ async function beginFrameCapture(page, options, frameTimeTicks, interval) {
103479
103591
  if (cached) {
103480
103592
  buffer = cached;
103481
103593
  } else {
103482
- const retry2 = await client.send("HeadlessExperimental.beginFrame", {
103594
+ const fallback = await sendBeginFrame(client, {
103483
103595
  frameTimeTicks: frameTimeTicks + 1e-3,
103484
103596
  interval,
103485
- screenshot: {
103486
- format: format3,
103487
- quality: format3 === "jpeg" ? options.quality ?? 80 : void 0,
103488
- optimizeForSpeed: true
103489
- }
103597
+ screenshot
103490
103598
  });
103491
- buffer = retry2.screenshotData ? Buffer.from(retry2.screenshotData, "base64") : Buffer.alloc(0);
103599
+ buffer = fallback.screenshotData ? Buffer.from(fallback.screenshotData, "base64") : Buffer.alloc(0);
103492
103600
  if (buffer.length > 0) lastFrameCache.set(page, buffer);
103493
103601
  }
103494
103602
  }
@@ -104991,7 +105099,7 @@ async function extractVideoFramesRange(videoPath, videoId, startTime, duration,
104991
105099
  });
104992
105100
  });
104993
105101
  }
104994
- async function extractAllVideoFrames(videos, baseDir, options, signal, config2) {
105102
+ async function extractAllVideoFrames(videos, baseDir, options, signal, config2, compiledDir) {
104995
105103
  const startTime = Date.now();
104996
105104
  const extracted = [];
104997
105105
  const errors = [];
@@ -105004,7 +105112,8 @@ async function extractAllVideoFrames(videos, baseDir, options, signal, config2)
105004
105112
  try {
105005
105113
  let videoPath = video.src;
105006
105114
  if (!videoPath.startsWith("/") && !isHttpUrl(videoPath)) {
105007
- videoPath = join8(baseDir, videoPath);
105115
+ const fromCompiled = compiledDir ? join8(compiledDir, videoPath) : null;
105116
+ videoPath = fromCompiled && existsSync8(fromCompiled) ? fromCompiled : join8(baseDir, videoPath);
105008
105117
  }
105009
105118
  if (isHttpUrl(videoPath)) {
105010
105119
  const downloadDir = join8(options.outputDir, "_downloads");
@@ -105465,7 +105574,7 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config2
105465
105574
  tracksProcessed: tracks.length
105466
105575
  };
105467
105576
  }
105468
- async function processCompositionAudio(elements, baseDir, workDir, outputPath, totalDuration, signal, config2) {
105577
+ async function processCompositionAudio(elements, baseDir, workDir, outputPath, totalDuration, signal, config2, compiledDir) {
105469
105578
  const startMs = Date.now();
105470
105579
  const tracks = [];
105471
105580
  const errors = [];
@@ -105479,7 +105588,8 @@ async function processCompositionAudio(elements, baseDir, workDir, outputPath, t
105479
105588
  try {
105480
105589
  let srcPath = element.src;
105481
105590
  if (!srcPath.startsWith("/") && !isHttpUrl(srcPath)) {
105482
- srcPath = join9(baseDir, srcPath);
105591
+ const fromCompiled = compiledDir ? join9(compiledDir, srcPath) : null;
105592
+ srcPath = fromCompiled && existsSync9(fromCompiled) ? fromCompiled : join9(baseDir, srcPath);
105483
105593
  }
105484
105594
  if (isHttpUrl(srcPath)) {
105485
105595
  try {
@@ -105492,7 +105602,7 @@ async function processCompositionAudio(elements, baseDir, workDir, outputPath, t
105492
105602
  }
105493
105603
  }
105494
105604
  if (!existsSync9(srcPath)) {
105495
- errors.push(`Source not found: ${element.id}`);
105605
+ errors.push(`Source not found: ${element.id} (${element.src})`);
105496
105606
  return;
105497
105607
  }
105498
105608
  if (element.end - element.start <= 0) {
@@ -107570,12 +107680,15 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
107570
107680
  const stage2Start = Date.now();
107571
107681
  updateJobStatus(job, "preprocessing", "Extracting video frames", 10, onProgress);
107572
107682
  let frameLookup = null;
107683
+ const compiledDir = join13(workDir, "compiled");
107573
107684
  if (composition.videos.length > 0) {
107574
107685
  const extractionResult = await extractAllVideoFrames(
107575
107686
  composition.videos,
107576
107687
  projectDir,
107577
107688
  { fps: job.config.fps, outputDir: join13(workDir, "video-frames") },
107578
- abortSignal
107689
+ abortSignal,
107690
+ void 0,
107691
+ compiledDir
107579
107692
  );
107580
107693
  assertNotAborted();
107581
107694
  if (extractionResult.extracted.length > 0) {
@@ -107615,7 +107728,9 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
107615
107728
  join13(workDir, "audio-work"),
107616
107729
  audioOutputPath,
107617
107730
  job.duration,
107618
- abortSignal
107731
+ abortSignal,
107732
+ void 0,
107733
+ compiledDir
107619
107734
  );
107620
107735
  assertNotAborted();
107621
107736
  hasAudio = audioResult.success;
@@ -108118,6 +108233,40 @@ function resolveRenderPaths(projectDir, outputPath, rendersDir = DEFAULT_RENDERS
108118
108233
  return { absoluteProjectDir, absoluteOutputPath };
108119
108234
  }
108120
108235
 
108236
+ // src/utils/semaphore.ts
108237
+ var Semaphore = class {
108238
+ constructor(maxConcurrent) {
108239
+ this.maxConcurrent = maxConcurrent;
108240
+ }
108241
+ queue = [];
108242
+ active = 0;
108243
+ async acquire() {
108244
+ if (this.active < this.maxConcurrent) {
108245
+ this.active++;
108246
+ return () => this.release();
108247
+ }
108248
+ return new Promise((resolve13) => {
108249
+ this.queue.push(() => {
108250
+ this.active++;
108251
+ resolve13(() => this.release());
108252
+ });
108253
+ });
108254
+ }
108255
+ release() {
108256
+ this.active--;
108257
+ const next = this.queue.shift();
108258
+ if (next) next();
108259
+ }
108260
+ /** Current number of active slots. */
108261
+ get activeCount() {
108262
+ return this.active;
108263
+ }
108264
+ /** Number of waiters in the queue. */
108265
+ get waitingCount() {
108266
+ return this.queue.length;
108267
+ }
108268
+ };
108269
+
108121
108270
  // src/server.ts
108122
108271
  function parseRenderOptions(body) {
108123
108272
  const fps = [24, 30, 60].includes(body.fps) ? body.fps : 30;
@@ -108231,6 +108380,8 @@ function createRenderHandlers(options = {}) {
108231
108380
  const rendersDir = options.rendersDir ?? process.env.PRODUCER_RENDERS_DIR ?? "/tmp";
108232
108381
  const artifactTtlMs = options.artifactTtlMs ?? Number(process.env.PRODUCER_OUTPUT_ARTIFACT_TTL_MS || 15 * 60 * 1e3);
108233
108382
  const store = createArtifactStore(artifactTtlMs);
108383
+ const maxConcurrentRenders = options.maxConcurrentRenders ?? Number(process.env.PRODUCER_MAX_CONCURRENT_RENDERS || 2);
108384
+ const renderSemaphore = new Semaphore(maxConcurrentRenders);
108234
108385
  const startTime = Date.now();
108235
108386
  const health = (c) => c.json({
108236
108387
  status: "ok",
@@ -108287,6 +108438,7 @@ function createRenderHandlers(options = {}) {
108287
108438
  );
108288
108439
  const outputDir = dirname11(absoluteOutputPath);
108289
108440
  if (!existsSync16(outputDir)) mkdirSync10(outputDir, { recursive: true });
108441
+ const release = await renderSemaphore.acquire();
108290
108442
  log.info("render started", {
108291
108443
  requestId,
108292
108444
  projectDir: input2.projectDir,
@@ -108354,6 +108506,7 @@ function createRenderHandlers(options = {}) {
108354
108506
  500
108355
108507
  );
108356
108508
  } finally {
108509
+ release();
108357
108510
  cleanupTempDir(cleanupProjectDir, log);
108358
108511
  }
108359
108512
  };
@@ -108410,6 +108563,16 @@ function createRenderHandlers(options = {}) {
108410
108563
  const abortController = new AbortController();
108411
108564
  const onRequestAbort = () => abortController.abort(new RenderCancelledError("request_aborted"));
108412
108565
  c.req.raw.signal.addEventListener("abort", onRequestAbort, { once: true });
108566
+ if (renderSemaphore.activeCount >= maxConcurrentRenders) {
108567
+ await stream2.writeSSE({
108568
+ data: JSON.stringify({
108569
+ type: "queued",
108570
+ requestId,
108571
+ position: renderSemaphore.waitingCount
108572
+ })
108573
+ });
108574
+ }
108575
+ const release = await renderSemaphore.acquire();
108413
108576
  try {
108414
108577
  await executeRenderJob(
108415
108578
  job,
@@ -108477,6 +108640,7 @@ function createRenderHandlers(options = {}) {
108477
108640
  })
108478
108641
  });
108479
108642
  } finally {
108643
+ release();
108480
108644
  c.req.raw.signal.removeEventListener("abort", onRequestAbort);
108481
108645
  cleanupTempDir(cleanupProjectDir, log);
108482
108646
  }
@@ -108501,7 +108665,12 @@ function createRenderHandlers(options = {}) {
108501
108665
  }
108502
108666
  });
108503
108667
  };
108504
- return { render: render2, renderStream, lint, health, outputs };
108668
+ const queue = (c) => c.json({
108669
+ maxConcurrentRenders,
108670
+ activeRenders: renderSemaphore.activeCount,
108671
+ queuedRenders: renderSemaphore.waitingCount
108672
+ });
108673
+ return { render: render2, renderStream, lint, health, outputs, queue };
108505
108674
  }
108506
108675
  function createProducerApp(options = {}) {
108507
108676
  const app = new Hono2();
@@ -108509,6 +108678,7 @@ function createProducerApp(options = {}) {
108509
108678
  app.get("/health", handlers.health);
108510
108679
  app.post("/render", handlers.render);
108511
108680
  app.post("/render/stream", handlers.renderStream);
108681
+ app.get("/render/queue", handlers.queue);
108512
108682
  app.post("/lint", handlers.lint);
108513
108683
  app.get("/outputs/:token", handlers.outputs);
108514
108684
  return app;