@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/hyperframe.manifest.json +1 -1
- package/dist/hyperframe.runtime.iife.js +8 -4
- package/dist/index.js +347 -124
- package/dist/index.js.map +4 -4
- package/dist/public-server.js +347 -124
- package/dist/public-server.js.map +4 -4
- package/dist/services/htmlCompiler.d.ts.map +1 -1
- package/dist/services/renderOrchestrator.d.ts.map +1 -1
- package/package.json +3 -3
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
|
|
99436
|
-
if (!isSuspiciousGlobalSelector(
|
|
99463
|
+
for (const win of gsapWindows) {
|
|
99464
|
+
if (!isSuspiciousGlobalSelector(win.targetSelector)) {
|
|
99437
99465
|
continue;
|
|
99438
99466
|
}
|
|
99439
|
-
const className = getSingleClassSelector(
|
|
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 "${
|
|
99447
|
-
selector:
|
|
99448
|
-
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${
|
|
99449
|
-
snippet: truncateSnippet(
|
|
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: "
|
|
99490
|
-
message: `<video> with data-start
|
|
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.
|
|
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
|
-
|
|
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
|
|
101136
|
-
|
|
101137
|
-
|
|
101138
|
-
|
|
101139
|
-
|
|
101140
|
-
|
|
101141
|
-
|
|
101142
|
-
|
|
101143
|
-
|
|
101144
|
-
|
|
101145
|
-
|
|
101146
|
-
|
|
101147
|
-
|
|
101148
|
-
|
|
101149
|
-
|
|
101150
|
-
|
|
101151
|
-
|
|
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
|
-
|
|
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
|
|
101208
|
-
|
|
101209
|
-
|
|
101210
|
-
|
|
101211
|
-
|
|
101212
|
-
|
|
101213
|
-
|
|
101214
|
-
|
|
101215
|
-
|
|
101216
|
-
|
|
101217
|
-
|
|
101218
|
-
|
|
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 =
|
|
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
|
|
101345
|
-
end
|
|
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 = (
|
|
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(
|
|
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;
|