@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.
@@ -90223,7 +90223,7 @@ var Context = class {
90223
90223
  * @param layout - The layout to set.
90224
90224
  * @returns The layout function.
90225
90225
  */
90226
- setLayout = (layout) => this.#layout = layout;
90226
+ setLayout = (layout2) => this.#layout = layout2;
90227
90227
  /**
90228
90228
  * Gets the current layout for the response.
90229
90229
  *
@@ -102176,6 +102176,19 @@ function lintHyperframeHtml(html, options = {}) {
102176
102176
  )
102177
102177
  });
102178
102178
  }
102179
+ const clipIds = /* @__PURE__ */ new Map();
102180
+ const clipClasses = /* @__PURE__ */ new Map();
102181
+ for (const tag of tags) {
102182
+ const classAttr = readAttr(tag.raw, "class") || "";
102183
+ const classes = classAttr.split(/\s+/).filter(Boolean);
102184
+ if (!classes.includes("clip")) continue;
102185
+ const id = readAttr(tag.raw, "id");
102186
+ const info = { tag: tag.name, id: id || "", classes: classAttr };
102187
+ if (id) clipIds.set(`#${id}`, info);
102188
+ for (const cls of classes) {
102189
+ if (cls !== "clip") clipClasses.set(`.${cls}`, info);
102190
+ }
102191
+ }
102179
102192
  const classUsage = countClassUsage(tags);
102180
102193
  for (const script of scripts) {
102181
102194
  const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
@@ -102218,24 +102231,39 @@ ${right2.raw}`)
102218
102231
  });
102219
102232
  }
102220
102233
  }
102234
+ for (const win of gsapWindows) {
102235
+ const sel = win.targetSelector;
102236
+ const clipInfo = clipIds.get(sel) || clipClasses.get(sel);
102237
+ if (!clipInfo) continue;
102238
+ const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`;
102239
+ pushFinding({
102240
+ code: "gsap_animates_clip_element",
102241
+ severity: "error",
102242
+ message: `GSAP animation targets a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility \u2014 animate an inner wrapper instead.`,
102243
+ selector: sel,
102244
+ elementId: clipInfo.id || void 0,
102245
+ fixHint: "Wrap content in a child <div> and target that with GSAP.",
102246
+ snippet: truncateSnippet(win.raw)
102247
+ });
102248
+ }
102221
102249
  if (!localTimelineCompId || localTimelineCompId === rootCompositionId) {
102222
102250
  continue;
102223
102251
  }
102224
- for (const window3 of gsapWindows) {
102225
- if (!isSuspiciousGlobalSelector(window3.targetSelector)) {
102252
+ for (const win of gsapWindows) {
102253
+ if (!isSuspiciousGlobalSelector(win.targetSelector)) {
102226
102254
  continue;
102227
102255
  }
102228
- const className = getSingleClassSelector(window3.targetSelector);
102256
+ const className = getSingleClassSelector(win.targetSelector);
102229
102257
  if (className && (classUsage.get(className) || 0) < 2) {
102230
102258
  continue;
102231
102259
  }
102232
102260
  pushFinding({
102233
102261
  code: "unscoped_gsap_selector",
102234
102262
  severity: "warning",
102235
- message: `Timeline "${localTimelineCompId}" uses unscoped selector "${window3.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
102236
- selector: window3.targetSelector,
102237
- fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${window3.targetSelector}\` or use a unique id.`,
102238
- snippet: truncateSnippet(window3.raw)
102263
+ message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
102264
+ selector: win.targetSelector,
102265
+ fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
102266
+ snippet: truncateSnippet(win.raw)
102239
102267
  });
102240
102268
  }
102241
102269
  }
@@ -102275,8 +102303,8 @@ ${right2.raw}`)
102275
102303
  if (!parentClosePattern.test(between)) {
102276
102304
  pushFinding({
102277
102305
  code: "video_nested_in_timed_element",
102278
- severity: "warning",
102279
- 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.`,
102306
+ severity: "error",
102307
+ 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.`,
102280
102308
  elementId: readAttr(tag.raw, "id") || void 0,
102281
102309
  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).",
102282
102310
  snippet: truncateSnippet(tag.raw)
@@ -102338,6 +102366,41 @@ ${right2.raw}`)
102338
102366
  });
102339
102367
  }
102340
102368
  }
102369
+ for (const tag of tags) {
102370
+ if (tag.name !== "video" && tag.name !== "audio") continue;
102371
+ const hasDataStart = readAttr(tag.raw, "data-start");
102372
+ const hasId = readAttr(tag.raw, "id");
102373
+ const hasSrc = readAttr(tag.raw, "src");
102374
+ if (hasDataStart && !hasId) {
102375
+ pushFinding({
102376
+ code: "media_missing_id",
102377
+ severity: "error",
102378
+ 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.`,
102379
+ fixHint: `Add a unique id attribute: <${tag.name} id="my-${tag.name}" ...>`,
102380
+ snippet: truncateSnippet(tag.raw)
102381
+ });
102382
+ }
102383
+ if (hasDataStart && hasId && !hasSrc) {
102384
+ pushFinding({
102385
+ code: "media_missing_src",
102386
+ severity: "error",
102387
+ message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
102388
+ elementId: hasId,
102389
+ fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
102390
+ snippet: truncateSnippet(tag.raw)
102391
+ });
102392
+ }
102393
+ if (readAttr(tag.raw, "preload") === "none") {
102394
+ pushFinding({
102395
+ code: "media_preload_none",
102396
+ severity: "warning",
102397
+ 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.`,
102398
+ elementId: hasId || void 0,
102399
+ fixHint: `Remove preload="none" or change to preload="auto". The framework manages media loading.`,
102400
+ snippet: truncateSnippet(tag.raw)
102401
+ });
102402
+ }
102403
+ }
102341
102404
  for (const tag of tags) {
102342
102405
  if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
102343
102406
  if (!readAttr(tag.raw, "data-start")) continue;
@@ -102453,12 +102516,83 @@ ${right2.raw}`)
102453
102516
  }
102454
102517
  }
102455
102518
  }
102519
+ for (const script of scripts) {
102520
+ const content = script.content;
102521
+ const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content);
102522
+ const hasHardKill = /\.set\s*\([^,]+,\s*\{[^}]*(?:visibility\s*:\s*["']hidden["']|opacity\s*:\s*0)/.test(content);
102523
+ const hasCaptionLoop = /forEach|\.forEach\s*\(/.test(content) && /createElement|caption|group|cg-/.test(content);
102524
+ if (hasCaptionLoop && hasExitTween && !hasHardKill) {
102525
+ pushFinding({
102526
+ code: "caption_exit_missing_hard_kill",
102527
+ severity: "warning",
102528
+ 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.",
102529
+ fixHint: 'Add `tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end)` after every exit tl.to animation as a deterministic kill.'
102530
+ });
102531
+ }
102532
+ }
102533
+ for (const style of styles) {
102534
+ const content = style.content;
102535
+ const captionBlocks = content.matchAll(
102536
+ /(\.caption[-_]?(?:group|container|text|line|word)|#caption[-_]?container)\s*\{([^}]+)\}/gi
102537
+ );
102538
+ for (const [, selector, body] of captionBlocks) {
102539
+ if (!body) continue;
102540
+ const hasNowrap = /white-space\s*:\s*nowrap/i.test(body);
102541
+ const hasMaxWidth = /max-width/i.test(body);
102542
+ if (hasNowrap && !hasMaxWidth) {
102543
+ pushFinding({
102544
+ code: "caption_text_overflow_risk",
102545
+ severity: "warning",
102546
+ selector: (selector ?? "").trim(),
102547
+ message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
102548
+ fixHint: "Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden."
102549
+ });
102550
+ }
102551
+ }
102552
+ }
102553
+ for (const style of styles) {
102554
+ const content = style.content;
102555
+ const captionBlocks = content.matchAll(
102556
+ /(\.caption[-_]?(?:group|container|text|line)|#caption[-_]?container)\s*\{([^}]+)\}/gi
102557
+ );
102558
+ for (const [, selector, body] of captionBlocks) {
102559
+ if (!body) continue;
102560
+ if (/position\s*:\s*relative/i.test(body)) {
102561
+ pushFinding({
102562
+ code: "caption_container_relative_position",
102563
+ severity: "warning",
102564
+ selector: (selector ?? "").trim(),
102565
+ message: `Caption selector "${(selector ?? "").trim()}" uses position: relative which causes overflow and breaks caption stacking.`,
102566
+ fixHint: "Use position: absolute for all caption elements."
102567
+ });
102568
+ }
102569
+ }
102570
+ }
102571
+ {
102572
+ const externalScriptRe = /<script\b[^>]*\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi;
102573
+ let match2;
102574
+ const seen2 = /* @__PURE__ */ new Set();
102575
+ while ((match2 = externalScriptRe.exec(source2)) !== null) {
102576
+ const src = match2[1] ?? "";
102577
+ if (seen2.has(src)) continue;
102578
+ seen2.add(src);
102579
+ pushFinding({
102580
+ code: "external_script_dependency",
102581
+ severity: "info",
102582
+ 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.`,
102583
+ 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.",
102584
+ snippet: truncateSnippet(match2[0] ?? "")
102585
+ });
102586
+ }
102587
+ }
102456
102588
  const errorCount = findings.filter((finding) => finding.severity === "error").length;
102457
- const warningCount = findings.length - errorCount;
102589
+ const warningCount = findings.filter((finding) => finding.severity === "warning").length;
102590
+ const infoCount = findings.filter((finding) => finding.severity === "info").length;
102458
102591
  return {
102459
102592
  ok: errorCount === 0,
102460
102593
  errorCount,
102461
102594
  warningCount,
102595
+ infoCount,
102462
102596
  findings
102463
102597
  };
102464
102598
  }
@@ -102777,6 +102911,14 @@ function quantizeTimeToFrame(timeSeconds, fps) {
102777
102911
  return frameIndex / safeFps;
102778
102912
  }
102779
102913
 
102914
+ // ../../node_modules/.bun/@chenglou+pretext@0.0.3/node_modules/@chenglou/pretext/dist/analysis.js
102915
+ var arabicScriptRe = new RegExp("\\p{Script=Arabic}", "u");
102916
+ var combiningMarkRe = new RegExp("\\p{M}", "u");
102917
+ var decimalDigitRe = new RegExp("\\p{Nd}", "u");
102918
+
102919
+ // ../../node_modules/.bun/@chenglou+pretext@0.0.3/node_modules/@chenglou/pretext/dist/measurement.js
102920
+ var emojiPresentationRe = new RegExp("\\p{Emoji_Presentation}", "u");
102921
+
102780
102922
  // ../engine/src/services/screenshotService.ts
102781
102923
  var cdpSessionCache = /* @__PURE__ */ new WeakMap();
102782
102924
  async function getCdpSession(page) {
@@ -103894,6 +104036,42 @@ import { join as join7 } from "path";
103894
104036
 
103895
104037
  // ../engine/src/utils/ffprobe.ts
103896
104038
  import { spawn as spawn7 } from "child_process";
104039
+ function runFfprobe(args) {
104040
+ return new Promise((resolve12, reject) => {
104041
+ const proc = spawn7("ffprobe", args);
104042
+ let stdout = "";
104043
+ let stderr = "";
104044
+ proc.stdout.on("data", (data) => {
104045
+ stdout += data.toString();
104046
+ });
104047
+ proc.stderr.on("data", (data) => {
104048
+ stderr += data.toString();
104049
+ });
104050
+ proc.on("close", (code) => {
104051
+ if (code !== 0) {
104052
+ reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
104053
+ } else {
104054
+ resolve12(stdout);
104055
+ }
104056
+ });
104057
+ proc.on("error", (err) => {
104058
+ if (err.code === "ENOENT") {
104059
+ reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
104060
+ } else {
104061
+ reject(err);
104062
+ }
104063
+ });
104064
+ });
104065
+ }
104066
+ function parseProbeJson(stdout) {
104067
+ try {
104068
+ return JSON.parse(stdout);
104069
+ } catch (e) {
104070
+ throw new Error(
104071
+ `[FFmpeg] Failed to parse ffprobe output: ${e instanceof Error ? e.message : e}`
104072
+ );
104073
+ }
104074
+ }
103897
104075
  var videoMetadataCache = /* @__PURE__ */ new Map();
103898
104076
  var audioMetadataCache = /* @__PURE__ */ new Map();
103899
104077
  function parseFrameRate(frameRateStr) {
@@ -103908,11 +104086,9 @@ function parseFrameRate(frameRateStr) {
103908
104086
  }
103909
104087
  async function extractVideoMetadata(filePath) {
103910
104088
  const cached = videoMetadataCache.get(filePath);
103911
- if (cached) {
103912
- return cached;
103913
- }
103914
- const probePromise = new Promise((resolve12, reject) => {
103915
- const args = [
104089
+ if (cached) return cached;
104090
+ const probePromise = (async () => {
104091
+ const stdout = await runFfprobe([
103916
104092
  "-v",
103917
104093
  "quiet",
103918
104094
  "-print_format",
@@ -103920,56 +104096,24 @@ async function extractVideoMetadata(filePath) {
103920
104096
  "-show_format",
103921
104097
  "-show_streams",
103922
104098
  filePath
103923
- ];
103924
- const ffprobe = spawn7("ffprobe", args);
103925
- let stdout = "";
103926
- let stderr = "";
103927
- ffprobe.stdout.on("data", (data) => {
103928
- stdout += data.toString();
103929
- });
103930
- ffprobe.stderr.on("data", (data) => {
103931
- stderr += data.toString();
103932
- });
103933
- ffprobe.on("close", (code) => {
103934
- if (code !== 0) {
103935
- reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
103936
- return;
103937
- }
103938
- try {
103939
- const output2 = JSON.parse(stdout);
103940
- const videoStream = output2.streams.find((s) => s.codec_type === "video");
103941
- if (!videoStream) {
103942
- reject(new Error("[FFmpeg] No video stream found"));
103943
- return;
103944
- }
103945
- const hasAudio = output2.streams.some((s) => s.codec_type === "audio");
103946
- const fps = parseFrameRate(videoStream.avg_frame_rate) || parseFrameRate(videoStream.r_frame_rate);
103947
- const durationSeconds = output2.format.duration ? parseFloat(output2.format.duration) : 0;
103948
- const metadata = {
103949
- durationSeconds,
103950
- width: videoStream.width || 0,
103951
- height: videoStream.height || 0,
103952
- fps,
103953
- videoCodec: videoStream.codec_name || "unknown",
103954
- hasAudio
103955
- };
103956
- resolve12(metadata);
103957
- } catch (parseError) {
103958
- reject(
103959
- new Error(
103960
- `[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`
103961
- )
103962
- );
103963
- }
103964
- });
103965
- ffprobe.on("error", (err) => {
103966
- if (err.code === "ENOENT") {
103967
- reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
103968
- } else {
103969
- reject(err);
103970
- }
103971
- });
103972
- });
104099
+ ]);
104100
+ const output2 = parseProbeJson(stdout);
104101
+ const videoStream = output2.streams.find((s) => s.codec_type === "video");
104102
+ if (!videoStream) throw new Error("[FFmpeg] No video stream found");
104103
+ const rFps = parseFrameRate(videoStream.r_frame_rate);
104104
+ const avgFps = parseFrameRate(videoStream.avg_frame_rate);
104105
+ const fps = avgFps || rFps;
104106
+ const isVFR = rFps > 0 && avgFps > 0 && Math.abs(rFps - avgFps) / Math.max(rFps, avgFps) > 0.1;
104107
+ return {
104108
+ durationSeconds: output2.format.duration ? parseFloat(output2.format.duration) : 0,
104109
+ width: videoStream.width || 0,
104110
+ height: videoStream.height || 0,
104111
+ fps,
104112
+ videoCodec: videoStream.codec_name || "unknown",
104113
+ hasAudio: output2.streams.some((s) => s.codec_type === "audio"),
104114
+ isVFR
104115
+ };
104116
+ })();
103973
104117
  videoMetadataCache.set(filePath, probePromise);
103974
104118
  probePromise.catch(() => {
103975
104119
  if (videoMetadataCache.get(filePath) === probePromise) {
@@ -103980,11 +104124,9 @@ async function extractVideoMetadata(filePath) {
103980
104124
  }
103981
104125
  async function extractAudioMetadata(filePath) {
103982
104126
  const cached = audioMetadataCache.get(filePath);
103983
- if (cached) {
103984
- return cached;
103985
- }
103986
- const probePromise = new Promise((resolve12, reject) => {
103987
- const args = [
104127
+ if (cached) return cached;
104128
+ const probePromise = (async () => {
104129
+ const stdout = await runFfprobe([
103988
104130
  "-v",
103989
104131
  "quiet",
103990
104132
  "-print_format",
@@ -103992,53 +104134,19 @@ async function extractAudioMetadata(filePath) {
103992
104134
  "-show_format",
103993
104135
  "-show_streams",
103994
104136
  filePath
103995
- ];
103996
- const ffprobe = spawn7("ffprobe", args);
103997
- let stdout = "";
103998
- let stderr = "";
103999
- ffprobe.stdout.on("data", (data) => {
104000
- stdout += data.toString();
104001
- });
104002
- ffprobe.stderr.on("data", (data) => {
104003
- stderr += data.toString();
104004
- });
104005
- ffprobe.on("close", (code) => {
104006
- if (code !== 0) {
104007
- reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
104008
- return;
104009
- }
104010
- try {
104011
- const output2 = JSON.parse(stdout);
104012
- const audioStream = output2.streams.find((s) => s.codec_type === "audio");
104013
- if (!audioStream) {
104014
- reject(new Error("[FFmpeg] No audio stream found"));
104015
- return;
104016
- }
104017
- const durationSeconds = output2.format.duration ? parseFloat(output2.format.duration) : 0;
104018
- const metadata = {
104019
- durationSeconds,
104020
- sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
104021
- channels: audioStream.channels || 2,
104022
- audioCodec: audioStream.codec_name || "unknown",
104023
- bitrate: output2.format.bit_rate ? parseInt(output2.format.bit_rate) : void 0
104024
- };
104025
- resolve12(metadata);
104026
- } catch (parseError) {
104027
- reject(
104028
- new Error(
104029
- `[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`
104030
- )
104031
- );
104032
- }
104033
- });
104034
- ffprobe.on("error", (err) => {
104035
- if (err.code === "ENOENT") {
104036
- reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
104037
- } else {
104038
- reject(err);
104039
- }
104040
- });
104041
- });
104137
+ ]);
104138
+ const output2 = parseProbeJson(stdout);
104139
+ const audioStream = output2.streams.find((s) => s.codec_type === "audio");
104140
+ if (!audioStream) throw new Error("[FFmpeg] No audio stream found");
104141
+ const durationSeconds = output2.format.duration ? parseFloat(output2.format.duration) : 0;
104142
+ return {
104143
+ durationSeconds,
104144
+ sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
104145
+ channels: audioStream.channels || 2,
104146
+ audioCodec: audioStream.codec_name || "unknown",
104147
+ bitrate: output2.format.bit_rate ? parseInt(output2.format.bit_rate) : void 0
104148
+ };
104149
+ })();
104042
104150
  audioMetadataCache.set(filePath, probePromise);
104043
104151
  probePromise.catch(() => {
104044
104152
  if (audioMetadataCache.get(filePath) === probePromise) {
@@ -104047,6 +104155,57 @@ async function extractAudioMetadata(filePath) {
104047
104155
  });
104048
104156
  return probePromise;
104049
104157
  }
104158
+ var keyframeCache = /* @__PURE__ */ new Map();
104159
+ async function analyzeKeyframeIntervals(filePath) {
104160
+ const cached = keyframeCache.get(filePath);
104161
+ if (cached) return cached;
104162
+ const promise = analyzeKeyframeIntervalsUncached(filePath);
104163
+ keyframeCache.set(filePath, promise);
104164
+ promise.catch(() => {
104165
+ if (keyframeCache.get(filePath) === promise) {
104166
+ keyframeCache.delete(filePath);
104167
+ }
104168
+ });
104169
+ return promise;
104170
+ }
104171
+ async function analyzeKeyframeIntervalsUncached(filePath) {
104172
+ const stdout = await runFfprobe([
104173
+ "-v",
104174
+ "quiet",
104175
+ "-select_streams",
104176
+ "v:0",
104177
+ "-skip_frame",
104178
+ "nokey",
104179
+ "-show_entries",
104180
+ "frame=pts_time",
104181
+ "-of",
104182
+ "csv=p=0",
104183
+ filePath
104184
+ ]);
104185
+ const timestamps = stdout.split("\n").map((line) => parseFloat(line.trim())).filter((t) => Number.isFinite(t));
104186
+ if (timestamps.length < 2) {
104187
+ return {
104188
+ avgIntervalSeconds: 0,
104189
+ maxIntervalSeconds: 0,
104190
+ keyframeCount: timestamps.length,
104191
+ isProblematic: false
104192
+ };
104193
+ }
104194
+ let maxInterval = 0;
104195
+ let totalInterval = 0;
104196
+ for (let i = 1; i < timestamps.length; i++) {
104197
+ const interval = (timestamps[i] ?? 0) - (timestamps[i - 1] ?? 0);
104198
+ totalInterval += interval;
104199
+ if (interval > maxInterval) maxInterval = interval;
104200
+ }
104201
+ const avgInterval = totalInterval / (timestamps.length - 1);
104202
+ return {
104203
+ avgIntervalSeconds: Math.round(avgInterval * 100) / 100,
104204
+ maxIntervalSeconds: Math.round(maxInterval * 100) / 100,
104205
+ keyframeCount: timestamps.length,
104206
+ isProblematic: maxInterval > 2
104207
+ };
104208
+ }
104050
104209
 
104051
104210
  // ../engine/src/utils/urlDownloader.ts
104052
104211
  import { createWriteStream as createWriteStream2, existsSync as existsSync7, mkdirSync as mkdirSync4 } from "fs";
@@ -104118,20 +104277,38 @@ function isHttpUrl(path12) {
104118
104277
  function parseVideoElements(html) {
104119
104278
  const videos = [];
104120
104279
  const { document: document2 } = parseHTML(html);
104121
- const videoEls = document2.querySelectorAll("video[id][src]");
104280
+ const videoEls = Array.from(
104281
+ /* @__PURE__ */ new Set([
104282
+ ...Array.from(document2.querySelectorAll("video[id][src]")),
104283
+ ...Array.from(document2.querySelectorAll("video[src][data-start]"))
104284
+ ])
104285
+ );
104286
+ videoEls.forEach((el, i) => {
104287
+ if (!el.id) el.id = `hf-video-${i}`;
104288
+ });
104122
104289
  for (const el of videoEls) {
104123
104290
  const id = el.getAttribute("id");
104124
104291
  const src = el.getAttribute("src");
104125
104292
  if (!id || !src) continue;
104126
104293
  const startAttr = el.getAttribute("data-start");
104127
104294
  const endAttr = el.getAttribute("data-end");
104295
+ const durationAttr = el.getAttribute("data-duration");
104128
104296
  const mediaStartAttr = el.getAttribute("data-media-start");
104129
104297
  const hasAudioAttr = el.getAttribute("data-has-audio");
104298
+ const start = startAttr ? parseFloat(startAttr) : 0;
104299
+ let end = 0;
104300
+ if (endAttr) {
104301
+ end = parseFloat(endAttr);
104302
+ } else if (durationAttr) {
104303
+ end = start + parseFloat(durationAttr);
104304
+ } else {
104305
+ end = Infinity;
104306
+ }
104130
104307
  videos.push({
104131
104308
  id,
104132
104309
  src,
104133
- start: startAttr ? parseFloat(startAttr) : 0,
104134
- end: endAttr ? parseFloat(endAttr) : 0,
104310
+ start,
104311
+ end,
104135
104312
  mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
104136
104313
  hasAudio: hasAudioAttr === "true"
104137
104314
  });
@@ -104241,7 +104418,7 @@ async function extractAllVideoFrames(videos, baseDir, options, signal, config2)
104241
104418
  return { error: { videoId: video.id, error: `Video file not found: ${videoPath}` } };
104242
104419
  }
104243
104420
  let videoDuration = video.end - video.start;
104244
- if (videoDuration <= 0) {
104421
+ if (!Number.isFinite(videoDuration) || videoDuration <= 0) {
104245
104422
  const metadata = await extractVideoMetadata(videoPath);
104246
104423
  const sourceDuration = metadata.durationSeconds - video.mediaStart;
104247
104424
  videoDuration = sourceDuration > 0 ? sourceDuration : metadata.durationSeconds;
@@ -105611,6 +105788,7 @@ async function compileHtmlFile(html, baseDir, downloadDir) {
105611
105788
  if (clampList.length > 0) {
105612
105789
  compiledHtml = clampDurations(compiledHtml, clampList);
105613
105790
  }
105791
+ compiledHtml = compiledHtml.replace(/(<video\b[^>]*)\s+crossorigin(?:=["'][^"']*["'])?/gi, "$1");
105614
105792
  return { html: compiledHtml, unresolvedCompositions };
105615
105793
  }
105616
105794
  async function parseSubCompositions(html, projectDir, downloadDir, parentOffset = 0, parentEnd = Infinity, visited = /* @__PURE__ */ new Set()) {
@@ -105963,13 +106141,51 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
105963
106141
  } = await parseSubCompositions(compiledHtml, projectDir, downloadDir);
105964
106142
  const fullHtml = ensureFullDocument(compiledHtml);
105965
106143
  const inlinedHtml = inlineSubCompositions(fullHtml, subCompositions, projectDir);
106144
+ const sanitizedHtml = inlinedHtml.replace(
106145
+ /(<(?:video|audio)\b[^>]*?)\s+preload\s*=\s*["']none["']/gi,
106146
+ "$1"
106147
+ );
105966
106148
  const html = injectDeterministicFontFaces(
105967
- coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(inlinedHtml))
106149
+ coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml))
105968
106150
  );
105969
106151
  const mainVideos = parseVideoElements(html);
105970
106152
  const mainAudios = parseAudioElements(html);
105971
106153
  const videos = dedupeElementsById([...subVideos, ...mainVideos]);
105972
106154
  const audios = dedupeElementsById([...subAudios, ...mainAudios]);
106155
+ for (const video of videos) {
106156
+ if (isHttpUrl(video.src)) continue;
106157
+ const videoPath = resolve7(projectDir, video.src);
106158
+ const reencode = `ffmpeg -i "${video.src}" -c:v libx264 -r 30 -g 30 -keyint_min 30 -movflags +faststart -c:a copy output.mp4`;
106159
+ Promise.all([analyzeKeyframeIntervals(videoPath), extractVideoMetadata(videoPath)]).then(([analysis, metadata]) => {
106160
+ if (analysis.isProblematic) {
106161
+ console.warn(
106162
+ `[Compiler] WARNING: Video "${video.id}" has sparse keyframes (max interval: ${analysis.maxIntervalSeconds}s). This causes seek failures and frame freezing. Re-encode with: ${reencode}`
106163
+ );
106164
+ }
106165
+ if (metadata.isVFR) {
106166
+ console.warn(
106167
+ `[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}`
106168
+ );
106169
+ }
106170
+ }).catch(() => {
106171
+ });
106172
+ }
106173
+ const autoIdVideos = videos.filter((v) => v.id.startsWith("hf-video-"));
106174
+ let htmlWithIds = html;
106175
+ if (autoIdVideos.length > 0) {
106176
+ const { document: idDoc } = parseHTML(html);
106177
+ let changed = false;
106178
+ for (const v of autoIdVideos) {
106179
+ const el = idDoc.querySelector(`video[src="${v.src}"]:not([id])`);
106180
+ if (el) {
106181
+ el.id = v.id;
106182
+ changed = true;
106183
+ }
106184
+ }
106185
+ if (changed) {
106186
+ htmlWithIds = idDoc.documentElement?.outerHTML ?? html;
106187
+ }
106188
+ }
105973
106189
  const { document: document2 } = parseHTML(html);
105974
106190
  const rootEl = document2.querySelector("[data-composition-id]");
105975
106191
  const width = rootEl ? parseInt(rootEl.getAttribute("data-width") || "1080", 10) : 1080;
@@ -105978,7 +106194,7 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
105978
106194
  rootEl.getAttribute("data-duration") || rootEl.getAttribute("data-composition-duration") || "0"
105979
106195
  ) : 0;
105980
106196
  return {
105981
- html,
106197
+ html: htmlWithIds,
105982
106198
  subCompositions,
105983
106199
  videos,
105984
106200
  audios,
@@ -106884,6 +107100,13 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
106884
107100
  }
106885
107101
  const errorMessage = error instanceof Error ? error.message : String(error);
106886
107102
  const errorStack = error instanceof Error ? error.stack : void 0;
107103
+ const isTimeoutError = errorMessage.includes("Waiting failed") || errorMessage.includes("timeout exceeded") || errorMessage.includes("Navigation timeout");
107104
+ const wasParallel = job.config.workers !== 1;
107105
+ if (isTimeoutError && wasParallel) {
107106
+ log.warn(
107107
+ `Parallel capture timed out with ${job.config.workers ?? "auto"} workers. Video-heavy compositions often need sequential capture. Retry with --workers 1`
107108
+ );
107109
+ }
106887
107110
  job.error = errorMessage;
106888
107111
  updateJobStatus(job, "failed", `Failed: ${errorMessage}`, job.progress, onProgress);
106889
107112
  const elapsed = Date.now() - pipelineStart;