@hyperframes/producer 0.1.12 → 0.1.14

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
@@ -99387,6 +99387,19 @@ function lintHyperframeHtml(html, options = {}) {
99387
99387
  )
99388
99388
  });
99389
99389
  }
99390
+ const clipIds = /* @__PURE__ */ new Map();
99391
+ const clipClasses = /* @__PURE__ */ new Map();
99392
+ for (const tag of tags) {
99393
+ const classAttr = readAttr(tag.raw, "class") || "";
99394
+ const classes = classAttr.split(/\s+/).filter(Boolean);
99395
+ if (!classes.includes("clip")) continue;
99396
+ const id = readAttr(tag.raw, "id");
99397
+ const info = { tag: tag.name, id: id || "", classes: classAttr };
99398
+ if (id) clipIds.set(`#${id}`, info);
99399
+ for (const cls of classes) {
99400
+ if (cls !== "clip") clipClasses.set(`.${cls}`, info);
99401
+ }
99402
+ }
99390
99403
  const classUsage = countClassUsage(tags);
99391
99404
  for (const script of scripts) {
99392
99405
  const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
@@ -99429,24 +99442,39 @@ ${right2.raw}`)
99429
99442
  });
99430
99443
  }
99431
99444
  }
99445
+ for (const win of gsapWindows) {
99446
+ const sel = win.targetSelector;
99447
+ const clipInfo = clipIds.get(sel) || clipClasses.get(sel);
99448
+ if (!clipInfo) continue;
99449
+ const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`;
99450
+ pushFinding({
99451
+ code: "gsap_animates_clip_element",
99452
+ severity: "error",
99453
+ message: `GSAP animation targets a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility \u2014 animate an inner wrapper instead.`,
99454
+ selector: sel,
99455
+ elementId: clipInfo.id || void 0,
99456
+ fixHint: "Wrap content in a child <div> and target that with GSAP.",
99457
+ snippet: truncateSnippet(win.raw)
99458
+ });
99459
+ }
99432
99460
  if (!localTimelineCompId || localTimelineCompId === rootCompositionId) {
99433
99461
  continue;
99434
99462
  }
99435
- for (const window3 of gsapWindows) {
99436
- if (!isSuspiciousGlobalSelector(window3.targetSelector)) {
99463
+ for (const win of gsapWindows) {
99464
+ if (!isSuspiciousGlobalSelector(win.targetSelector)) {
99437
99465
  continue;
99438
99466
  }
99439
- const className = getSingleClassSelector(window3.targetSelector);
99467
+ const className = getSingleClassSelector(win.targetSelector);
99440
99468
  if (className && (classUsage.get(className) || 0) < 2) {
99441
99469
  continue;
99442
99470
  }
99443
99471
  pushFinding({
99444
99472
  code: "unscoped_gsap_selector",
99445
99473
  severity: "warning",
99446
- message: `Timeline "${localTimelineCompId}" uses unscoped selector "${window3.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
99447
- selector: window3.targetSelector,
99448
- fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${window3.targetSelector}\` or use a unique id.`,
99449
- snippet: truncateSnippet(window3.raw)
99474
+ message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
99475
+ selector: win.targetSelector,
99476
+ fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
99477
+ snippet: truncateSnippet(win.raw)
99450
99478
  });
99451
99479
  }
99452
99480
  }
@@ -99486,8 +99514,8 @@ ${right2.raw}`)
99486
99514
  if (!parentClosePattern.test(between)) {
99487
99515
  pushFinding({
99488
99516
  code: "video_nested_in_timed_element",
99489
- severity: "warning",
99490
- message: `<video> with data-start appears to be nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. This can break media sync.`,
99517
+ severity: "error",
99518
+ message: `<video> with data-start is nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. The framework cannot manage playback of nested media \u2014 video will be FROZEN in renders.`,
99491
99519
  elementId: readAttr(tag.raw, "id") || void 0,
99492
99520
  fixHint: "Move the <video> to be a direct child of the stage, or remove data-start from the wrapper div (use it as a non-timed visual container).",
99493
99521
  snippet: truncateSnippet(tag.raw)
@@ -99549,6 +99577,41 @@ ${right2.raw}`)
99549
99577
  });
99550
99578
  }
99551
99579
  }
99580
+ for (const tag of tags) {
99581
+ if (tag.name !== "video" && tag.name !== "audio") continue;
99582
+ const hasDataStart = readAttr(tag.raw, "data-start");
99583
+ const hasId = readAttr(tag.raw, "id");
99584
+ const hasSrc = readAttr(tag.raw, "src");
99585
+ if (hasDataStart && !hasId) {
99586
+ pushFinding({
99587
+ code: "media_missing_id",
99588
+ severity: "error",
99589
+ message: `<${tag.name}> has data-start but no id attribute. The renderer requires id to discover media elements \u2014 this ${tag.name === "audio" ? "audio will be SILENT" : "video will be FROZEN"} in renders.`,
99590
+ fixHint: `Add a unique id attribute: <${tag.name} id="my-${tag.name}" ...>`,
99591
+ snippet: truncateSnippet(tag.raw)
99592
+ });
99593
+ }
99594
+ if (hasDataStart && hasId && !hasSrc) {
99595
+ pushFinding({
99596
+ code: "media_missing_src",
99597
+ severity: "error",
99598
+ message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
99599
+ elementId: hasId,
99600
+ fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
99601
+ snippet: truncateSnippet(tag.raw)
99602
+ });
99603
+ }
99604
+ if (readAttr(tag.raw, "preload") === "none") {
99605
+ pushFinding({
99606
+ code: "media_preload_none",
99607
+ severity: "warning",
99608
+ message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has preload="none" which prevents the renderer from loading this media. The compiler strips it for renders, but preview may also have issues.`,
99609
+ elementId: hasId || void 0,
99610
+ fixHint: `Remove preload="none" or change to preload="auto". The framework manages media loading.`,
99611
+ snippet: truncateSnippet(tag.raw)
99612
+ });
99613
+ }
99614
+ }
99552
99615
  for (const tag of tags) {
99553
99616
  if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
99554
99617
  if (!readAttr(tag.raw, "data-start")) continue;
@@ -99664,12 +99727,83 @@ ${right2.raw}`)
99664
99727
  }
99665
99728
  }
99666
99729
  }
99730
+ for (const script of scripts) {
99731
+ const content = script.content;
99732
+ const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content);
99733
+ const hasHardKill = /\.set\s*\([^,]+,\s*\{[^}]*(?:visibility\s*:\s*["']hidden["']|opacity\s*:\s*0)/.test(content);
99734
+ const hasCaptionLoop = /forEach|\.forEach\s*\(/.test(content) && /createElement|caption|group|cg-/.test(content);
99735
+ if (hasCaptionLoop && hasExitTween && !hasHardKill) {
99736
+ pushFinding({
99737
+ code: "caption_exit_missing_hard_kill",
99738
+ severity: "warning",
99739
+ message: "Caption exit animations (tl.to with opacity: 0) detected without a hard tl.set kill. Exit tweens can fail when karaoke word-level tweens conflict, leaving captions stuck on screen.",
99740
+ fixHint: 'Add `tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end)` after every exit tl.to animation as a deterministic kill.'
99741
+ });
99742
+ }
99743
+ }
99744
+ for (const style of styles) {
99745
+ const content = style.content;
99746
+ const captionBlocks = content.matchAll(
99747
+ /(\.caption[-_]?(?:group|container|text|line|word)|#caption[-_]?container)\s*\{([^}]+)\}/gi
99748
+ );
99749
+ for (const [, selector, body] of captionBlocks) {
99750
+ if (!body) continue;
99751
+ const hasNowrap = /white-space\s*:\s*nowrap/i.test(body);
99752
+ const hasMaxWidth = /max-width/i.test(body);
99753
+ if (hasNowrap && !hasMaxWidth) {
99754
+ pushFinding({
99755
+ code: "caption_text_overflow_risk",
99756
+ severity: "warning",
99757
+ selector: (selector ?? "").trim(),
99758
+ message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
99759
+ fixHint: "Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden."
99760
+ });
99761
+ }
99762
+ }
99763
+ }
99764
+ for (const style of styles) {
99765
+ const content = style.content;
99766
+ const captionBlocks = content.matchAll(
99767
+ /(\.caption[-_]?(?:group|container|text|line)|#caption[-_]?container)\s*\{([^}]+)\}/gi
99768
+ );
99769
+ for (const [, selector, body] of captionBlocks) {
99770
+ if (!body) continue;
99771
+ if (/position\s*:\s*relative/i.test(body)) {
99772
+ pushFinding({
99773
+ code: "caption_container_relative_position",
99774
+ severity: "warning",
99775
+ selector: (selector ?? "").trim(),
99776
+ message: `Caption selector "${(selector ?? "").trim()}" uses position: relative which causes overflow and breaks caption stacking.`,
99777
+ fixHint: "Use position: absolute for all caption elements."
99778
+ });
99779
+ }
99780
+ }
99781
+ }
99782
+ {
99783
+ const externalScriptRe = /<script\b[^>]*\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi;
99784
+ let match2;
99785
+ const seen2 = /* @__PURE__ */ new Set();
99786
+ while ((match2 = externalScriptRe.exec(source2)) !== null) {
99787
+ const src = match2[1] ?? "";
99788
+ if (seen2.has(src)) continue;
99789
+ seen2.add(src);
99790
+ pushFinding({
99791
+ code: "external_script_dependency",
99792
+ severity: "info",
99793
+ message: `This composition loads an external script from \`${src}\`. The HyperFrames bundler automatically hoists CDN scripts from sub-compositions into the parent document. In unbundled runtime mode, \`loadExternalCompositions\` re-injects them. If you're using a custom pipeline that bypasses both, you'll need to include this script manually.`,
99794
+ fixHint: "No action needed when using `hyperframes dev` or `hyperframes render`. If using a custom pipeline, add this script tag to your root composition or HTML page.",
99795
+ snippet: truncateSnippet(match2[0] ?? "")
99796
+ });
99797
+ }
99798
+ }
99667
99799
  const errorCount = findings.filter((finding) => finding.severity === "error").length;
99668
- const warningCount = findings.length - errorCount;
99800
+ const warningCount = findings.filter((finding) => finding.severity === "warning").length;
99801
+ const infoCount = findings.filter((finding) => finding.severity === "info").length;
99669
99802
  return {
99670
99803
  ok: errorCount === 0,
99671
99804
  errorCount,
99672
99805
  warningCount,
99806
+ infoCount,
99673
99807
  findings
99674
99808
  };
99675
99809
  }
@@ -99988,6 +100122,14 @@ function quantizeTimeToFrame(timeSeconds, fps) {
99988
100122
  return frameIndex / safeFps;
99989
100123
  }
99990
100124
 
100125
+ // ../../node_modules/.bun/@chenglou+pretext@0.0.3/node_modules/@chenglou/pretext/dist/analysis.js
100126
+ var arabicScriptRe = new RegExp("\\p{Script=Arabic}", "u");
100127
+ var combiningMarkRe = new RegExp("\\p{M}", "u");
100128
+ var decimalDigitRe = new RegExp("\\p{Nd}", "u");
100129
+
100130
+ // ../../node_modules/.bun/@chenglou+pretext@0.0.3/node_modules/@chenglou/pretext/dist/measurement.js
100131
+ var emojiPresentationRe = new RegExp("\\p{Emoji_Presentation}", "u");
100132
+
99991
100133
  // ../engine/src/services/screenshotService.ts
99992
100134
  var cdpSessionCache = /* @__PURE__ */ new WeakMap();
99993
100135
  async function getCdpSession(page) {
@@ -101105,6 +101247,42 @@ import { join as join7 } from "path";
101105
101247
 
101106
101248
  // ../engine/src/utils/ffprobe.ts
101107
101249
  import { spawn as spawn7 } from "child_process";
101250
+ function runFfprobe(args) {
101251
+ return new Promise((resolve12, reject) => {
101252
+ const proc = spawn7("ffprobe", args);
101253
+ let stdout = "";
101254
+ let stderr = "";
101255
+ proc.stdout.on("data", (data) => {
101256
+ stdout += data.toString();
101257
+ });
101258
+ proc.stderr.on("data", (data) => {
101259
+ stderr += data.toString();
101260
+ });
101261
+ proc.on("close", (code) => {
101262
+ if (code !== 0) {
101263
+ reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
101264
+ } else {
101265
+ resolve12(stdout);
101266
+ }
101267
+ });
101268
+ proc.on("error", (err) => {
101269
+ if (err.code === "ENOENT") {
101270
+ reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
101271
+ } else {
101272
+ reject(err);
101273
+ }
101274
+ });
101275
+ });
101276
+ }
101277
+ function parseProbeJson(stdout) {
101278
+ try {
101279
+ return JSON.parse(stdout);
101280
+ } catch (e) {
101281
+ throw new Error(
101282
+ `[FFmpeg] Failed to parse ffprobe output: ${e instanceof Error ? e.message : e}`
101283
+ );
101284
+ }
101285
+ }
101108
101286
  var videoMetadataCache = /* @__PURE__ */ new Map();
101109
101287
  var audioMetadataCache = /* @__PURE__ */ new Map();
101110
101288
  function parseFrameRate(frameRateStr) {
@@ -101119,11 +101297,9 @@ function parseFrameRate(frameRateStr) {
101119
101297
  }
101120
101298
  async function extractVideoMetadata(filePath) {
101121
101299
  const cached = videoMetadataCache.get(filePath);
101122
- if (cached) {
101123
- return cached;
101124
- }
101125
- const probePromise = new Promise((resolve12, reject) => {
101126
- const args = [
101300
+ if (cached) return cached;
101301
+ const probePromise = (async () => {
101302
+ const stdout = await runFfprobe([
101127
101303
  "-v",
101128
101304
  "quiet",
101129
101305
  "-print_format",
@@ -101131,56 +101307,24 @@ async function extractVideoMetadata(filePath) {
101131
101307
  "-show_format",
101132
101308
  "-show_streams",
101133
101309
  filePath
101134
- ];
101135
- const ffprobe = spawn7("ffprobe", args);
101136
- let stdout = "";
101137
- let stderr = "";
101138
- ffprobe.stdout.on("data", (data) => {
101139
- stdout += data.toString();
101140
- });
101141
- ffprobe.stderr.on("data", (data) => {
101142
- stderr += data.toString();
101143
- });
101144
- ffprobe.on("close", (code) => {
101145
- if (code !== 0) {
101146
- reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
101147
- return;
101148
- }
101149
- try {
101150
- const output2 = JSON.parse(stdout);
101151
- const videoStream = output2.streams.find((s) => s.codec_type === "video");
101152
- if (!videoStream) {
101153
- reject(new Error("[FFmpeg] No video stream found"));
101154
- return;
101155
- }
101156
- const hasAudio = output2.streams.some((s) => s.codec_type === "audio");
101157
- const fps = parseFrameRate(videoStream.avg_frame_rate) || parseFrameRate(videoStream.r_frame_rate);
101158
- const durationSeconds = output2.format.duration ? parseFloat(output2.format.duration) : 0;
101159
- const metadata = {
101160
- durationSeconds,
101161
- width: videoStream.width || 0,
101162
- height: videoStream.height || 0,
101163
- fps,
101164
- videoCodec: videoStream.codec_name || "unknown",
101165
- hasAudio
101166
- };
101167
- resolve12(metadata);
101168
- } catch (parseError) {
101169
- reject(
101170
- new Error(
101171
- `[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`
101172
- )
101173
- );
101174
- }
101175
- });
101176
- ffprobe.on("error", (err) => {
101177
- if (err.code === "ENOENT") {
101178
- reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
101179
- } else {
101180
- reject(err);
101181
- }
101182
- });
101183
- });
101310
+ ]);
101311
+ const output2 = parseProbeJson(stdout);
101312
+ const videoStream = output2.streams.find((s) => s.codec_type === "video");
101313
+ if (!videoStream) throw new Error("[FFmpeg] No video stream found");
101314
+ const rFps = parseFrameRate(videoStream.r_frame_rate);
101315
+ const avgFps = parseFrameRate(videoStream.avg_frame_rate);
101316
+ const fps = avgFps || rFps;
101317
+ const isVFR = rFps > 0 && avgFps > 0 && Math.abs(rFps - avgFps) / Math.max(rFps, avgFps) > 0.1;
101318
+ return {
101319
+ durationSeconds: output2.format.duration ? parseFloat(output2.format.duration) : 0,
101320
+ width: videoStream.width || 0,
101321
+ height: videoStream.height || 0,
101322
+ fps,
101323
+ videoCodec: videoStream.codec_name || "unknown",
101324
+ hasAudio: output2.streams.some((s) => s.codec_type === "audio"),
101325
+ isVFR
101326
+ };
101327
+ })();
101184
101328
  videoMetadataCache.set(filePath, probePromise);
101185
101329
  probePromise.catch(() => {
101186
101330
  if (videoMetadataCache.get(filePath) === probePromise) {
@@ -101191,11 +101335,9 @@ async function extractVideoMetadata(filePath) {
101191
101335
  }
101192
101336
  async function extractAudioMetadata(filePath) {
101193
101337
  const cached = audioMetadataCache.get(filePath);
101194
- if (cached) {
101195
- return cached;
101196
- }
101197
- const probePromise = new Promise((resolve12, reject) => {
101198
- const args = [
101338
+ if (cached) return cached;
101339
+ const probePromise = (async () => {
101340
+ const stdout = await runFfprobe([
101199
101341
  "-v",
101200
101342
  "quiet",
101201
101343
  "-print_format",
@@ -101203,53 +101345,19 @@ async function extractAudioMetadata(filePath) {
101203
101345
  "-show_format",
101204
101346
  "-show_streams",
101205
101347
  filePath
101206
- ];
101207
- const ffprobe = spawn7("ffprobe", args);
101208
- let stdout = "";
101209
- let stderr = "";
101210
- ffprobe.stdout.on("data", (data) => {
101211
- stdout += data.toString();
101212
- });
101213
- ffprobe.stderr.on("data", (data) => {
101214
- stderr += data.toString();
101215
- });
101216
- ffprobe.on("close", (code) => {
101217
- if (code !== 0) {
101218
- reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
101219
- return;
101220
- }
101221
- try {
101222
- const output2 = JSON.parse(stdout);
101223
- const audioStream = output2.streams.find((s) => s.codec_type === "audio");
101224
- if (!audioStream) {
101225
- reject(new Error("[FFmpeg] No audio stream found"));
101226
- return;
101227
- }
101228
- const durationSeconds = output2.format.duration ? parseFloat(output2.format.duration) : 0;
101229
- const metadata = {
101230
- durationSeconds,
101231
- sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
101232
- channels: audioStream.channels || 2,
101233
- audioCodec: audioStream.codec_name || "unknown",
101234
- bitrate: output2.format.bit_rate ? parseInt(output2.format.bit_rate) : void 0
101235
- };
101236
- resolve12(metadata);
101237
- } catch (parseError) {
101238
- reject(
101239
- new Error(
101240
- `[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`
101241
- )
101242
- );
101243
- }
101244
- });
101245
- ffprobe.on("error", (err) => {
101246
- if (err.code === "ENOENT") {
101247
- reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
101248
- } else {
101249
- reject(err);
101250
- }
101251
- });
101252
- });
101348
+ ]);
101349
+ const output2 = parseProbeJson(stdout);
101350
+ const audioStream = output2.streams.find((s) => s.codec_type === "audio");
101351
+ if (!audioStream) throw new Error("[FFmpeg] No audio stream found");
101352
+ const durationSeconds = output2.format.duration ? parseFloat(output2.format.duration) : 0;
101353
+ return {
101354
+ durationSeconds,
101355
+ sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
101356
+ channels: audioStream.channels || 2,
101357
+ audioCodec: audioStream.codec_name || "unknown",
101358
+ bitrate: output2.format.bit_rate ? parseInt(output2.format.bit_rate) : void 0
101359
+ };
101360
+ })();
101253
101361
  audioMetadataCache.set(filePath, probePromise);
101254
101362
  probePromise.catch(() => {
101255
101363
  if (audioMetadataCache.get(filePath) === probePromise) {
@@ -101258,6 +101366,57 @@ async function extractAudioMetadata(filePath) {
101258
101366
  });
101259
101367
  return probePromise;
101260
101368
  }
101369
+ var keyframeCache = /* @__PURE__ */ new Map();
101370
+ async function analyzeKeyframeIntervals(filePath) {
101371
+ const cached = keyframeCache.get(filePath);
101372
+ if (cached) return cached;
101373
+ const promise = analyzeKeyframeIntervalsUncached(filePath);
101374
+ keyframeCache.set(filePath, promise);
101375
+ promise.catch(() => {
101376
+ if (keyframeCache.get(filePath) === promise) {
101377
+ keyframeCache.delete(filePath);
101378
+ }
101379
+ });
101380
+ return promise;
101381
+ }
101382
+ async function analyzeKeyframeIntervalsUncached(filePath) {
101383
+ const stdout = await runFfprobe([
101384
+ "-v",
101385
+ "quiet",
101386
+ "-select_streams",
101387
+ "v:0",
101388
+ "-skip_frame",
101389
+ "nokey",
101390
+ "-show_entries",
101391
+ "frame=pts_time",
101392
+ "-of",
101393
+ "csv=p=0",
101394
+ filePath
101395
+ ]);
101396
+ const timestamps = stdout.split("\n").map((line) => parseFloat(line.trim())).filter((t) => Number.isFinite(t));
101397
+ if (timestamps.length < 2) {
101398
+ return {
101399
+ avgIntervalSeconds: 0,
101400
+ maxIntervalSeconds: 0,
101401
+ keyframeCount: timestamps.length,
101402
+ isProblematic: false
101403
+ };
101404
+ }
101405
+ let maxInterval = 0;
101406
+ let totalInterval = 0;
101407
+ for (let i = 1; i < timestamps.length; i++) {
101408
+ const interval = (timestamps[i] ?? 0) - (timestamps[i - 1] ?? 0);
101409
+ totalInterval += interval;
101410
+ if (interval > maxInterval) maxInterval = interval;
101411
+ }
101412
+ const avgInterval = totalInterval / (timestamps.length - 1);
101413
+ return {
101414
+ avgIntervalSeconds: Math.round(avgInterval * 100) / 100,
101415
+ maxIntervalSeconds: Math.round(maxInterval * 100) / 100,
101416
+ keyframeCount: timestamps.length,
101417
+ isProblematic: maxInterval > 2
101418
+ };
101419
+ }
101261
101420
 
101262
101421
  // ../engine/src/utils/urlDownloader.ts
101263
101422
  import { createWriteStream as createWriteStream2, existsSync as existsSync7, mkdirSync as mkdirSync4 } from "fs";
@@ -101329,20 +101488,38 @@ function isHttpUrl(path12) {
101329
101488
  function parseVideoElements(html) {
101330
101489
  const videos = [];
101331
101490
  const { document: document2 } = parseHTML(html);
101332
- const videoEls = document2.querySelectorAll("video[id][src]");
101491
+ const videoEls = Array.from(
101492
+ /* @__PURE__ */ new Set([
101493
+ ...Array.from(document2.querySelectorAll("video[id][src]")),
101494
+ ...Array.from(document2.querySelectorAll("video[src][data-start]"))
101495
+ ])
101496
+ );
101497
+ videoEls.forEach((el, i) => {
101498
+ if (!el.id) el.id = `hf-video-${i}`;
101499
+ });
101333
101500
  for (const el of videoEls) {
101334
101501
  const id = el.getAttribute("id");
101335
101502
  const src = el.getAttribute("src");
101336
101503
  if (!id || !src) continue;
101337
101504
  const startAttr = el.getAttribute("data-start");
101338
101505
  const endAttr = el.getAttribute("data-end");
101506
+ const durationAttr = el.getAttribute("data-duration");
101339
101507
  const mediaStartAttr = el.getAttribute("data-media-start");
101340
101508
  const hasAudioAttr = el.getAttribute("data-has-audio");
101509
+ const start = startAttr ? parseFloat(startAttr) : 0;
101510
+ let end = 0;
101511
+ if (endAttr) {
101512
+ end = parseFloat(endAttr);
101513
+ } else if (durationAttr) {
101514
+ end = start + parseFloat(durationAttr);
101515
+ } else {
101516
+ end = Infinity;
101517
+ }
101341
101518
  videos.push({
101342
101519
  id,
101343
101520
  src,
101344
- start: startAttr ? parseFloat(startAttr) : 0,
101345
- end: endAttr ? parseFloat(endAttr) : 0,
101521
+ start,
101522
+ end,
101346
101523
  mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
101347
101524
  hasAudio: hasAudioAttr === "true"
101348
101525
  });
@@ -101452,7 +101629,7 @@ async function extractAllVideoFrames(videos, baseDir, options, signal, config2)
101452
101629
  return { error: { videoId: video.id, error: `Video file not found: ${videoPath}` } };
101453
101630
  }
101454
101631
  let videoDuration = video.end - video.start;
101455
- if (videoDuration <= 0) {
101632
+ if (!Number.isFinite(videoDuration) || videoDuration <= 0) {
101456
101633
  const metadata = await extractVideoMetadata(videoPath);
101457
101634
  const sourceDuration = metadata.durationSeconds - video.mediaStart;
101458
101635
  videoDuration = sourceDuration > 0 ? sourceDuration : metadata.durationSeconds;
@@ -102962,7 +103139,7 @@ var Context = class {
102962
103139
  * @param layout - The layout to set.
102963
103140
  * @returns The layout function.
102964
103141
  */
102965
- setLayout = (layout) => this.#layout = layout;
103142
+ setLayout = (layout2) => this.#layout = layout2;
102966
103143
  /**
102967
103144
  * Gets the current layout for the response.
102968
103145
  *
@@ -105446,6 +105623,7 @@ async function compileHtmlFile(html, baseDir, downloadDir) {
105446
105623
  if (clampList.length > 0) {
105447
105624
  compiledHtml = clampDurations(compiledHtml, clampList);
105448
105625
  }
105626
+ compiledHtml = compiledHtml.replace(/(<video\b[^>]*)\s+crossorigin(?:=["'][^"']*["'])?/gi, "$1");
105449
105627
  return { html: compiledHtml, unresolvedCompositions };
105450
105628
  }
105451
105629
  async function parseSubCompositions(html, projectDir, downloadDir, parentOffset = 0, parentEnd = Infinity, visited = /* @__PURE__ */ new Set()) {
@@ -105798,13 +105976,51 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
105798
105976
  } = await parseSubCompositions(compiledHtml, projectDir, downloadDir);
105799
105977
  const fullHtml = ensureFullDocument(compiledHtml);
105800
105978
  const inlinedHtml = inlineSubCompositions(fullHtml, subCompositions, projectDir);
105979
+ const sanitizedHtml = inlinedHtml.replace(
105980
+ /(<(?:video|audio)\b[^>]*?)\s+preload\s*=\s*["']none["']/gi,
105981
+ "$1"
105982
+ );
105801
105983
  const html = injectDeterministicFontFaces(
105802
- coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(inlinedHtml))
105984
+ coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml))
105803
105985
  );
105804
105986
  const mainVideos = parseVideoElements(html);
105805
105987
  const mainAudios = parseAudioElements(html);
105806
105988
  const videos = dedupeElementsById([...subVideos, ...mainVideos]);
105807
105989
  const audios = dedupeElementsById([...subAudios, ...mainAudios]);
105990
+ for (const video of videos) {
105991
+ if (isHttpUrl(video.src)) continue;
105992
+ const videoPath = resolve7(projectDir, video.src);
105993
+ const reencode = `ffmpeg -i "${video.src}" -c:v libx264 -r 30 -g 30 -keyint_min 30 -movflags +faststart -c:a copy output.mp4`;
105994
+ Promise.all([analyzeKeyframeIntervals(videoPath), extractVideoMetadata(videoPath)]).then(([analysis, metadata]) => {
105995
+ if (analysis.isProblematic) {
105996
+ console.warn(
105997
+ `[Compiler] WARNING: Video "${video.id}" has sparse keyframes (max interval: ${analysis.maxIntervalSeconds}s). This causes seek failures and frame freezing. Re-encode with: ${reencode}`
105998
+ );
105999
+ }
106000
+ if (metadata.isVFR) {
106001
+ console.warn(
106002
+ `[Compiler] WARNING: Video "${video.id}" is variable frame rate (VFR). Screen recordings and phone videos are often VFR, which causes stuttering and frame skipping in renders. Re-encode with: ${reencode}`
106003
+ );
106004
+ }
106005
+ }).catch(() => {
106006
+ });
106007
+ }
106008
+ const autoIdVideos = videos.filter((v) => v.id.startsWith("hf-video-"));
106009
+ let htmlWithIds = html;
106010
+ if (autoIdVideos.length > 0) {
106011
+ const { document: idDoc } = parseHTML(html);
106012
+ let changed = false;
106013
+ for (const v of autoIdVideos) {
106014
+ const el = idDoc.querySelector(`video[src="${v.src}"]:not([id])`);
106015
+ if (el) {
106016
+ el.id = v.id;
106017
+ changed = true;
106018
+ }
106019
+ }
106020
+ if (changed) {
106021
+ htmlWithIds = idDoc.documentElement?.outerHTML ?? html;
106022
+ }
106023
+ }
105808
106024
  const { document: document2 } = parseHTML(html);
105809
106025
  const rootEl = document2.querySelector("[data-composition-id]");
105810
106026
  const width = rootEl ? parseInt(rootEl.getAttribute("data-width") || "1080", 10) : 1080;
@@ -105813,7 +106029,7 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
105813
106029
  rootEl.getAttribute("data-duration") || rootEl.getAttribute("data-composition-duration") || "0"
105814
106030
  ) : 0;
105815
106031
  return {
105816
- html,
106032
+ html: htmlWithIds,
105817
106033
  subCompositions,
105818
106034
  videos,
105819
106035
  audios,
@@ -106719,6 +106935,13 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
106719
106935
  }
106720
106936
  const errorMessage = error instanceof Error ? error.message : String(error);
106721
106937
  const errorStack = error instanceof Error ? error.stack : void 0;
106938
+ const isTimeoutError = errorMessage.includes("Waiting failed") || errorMessage.includes("timeout exceeded") || errorMessage.includes("Navigation timeout");
106939
+ const wasParallel = job.config.workers !== 1;
106940
+ if (isTimeoutError && wasParallel) {
106941
+ log.warn(
106942
+ `Parallel capture timed out with ${job.config.workers ?? "auto"} workers. Video-heavy compositions often need sequential capture. Retry with --workers 1`
106943
+ );
106944
+ }
106722
106945
  job.error = errorMessage;
106723
106946
  updateJobStatus(job, "failed", `Failed: ${errorMessage}`, job.progress, onProgress);
106724
106947
  const elapsed = Date.now() - pipelineStart;