@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/public-server.js
CHANGED
|
@@ -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 = (
|
|
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
|
|
102225
|
-
if (!isSuspiciousGlobalSelector(
|
|
102252
|
+
for (const win of gsapWindows) {
|
|
102253
|
+
if (!isSuspiciousGlobalSelector(win.targetSelector)) {
|
|
102226
102254
|
continue;
|
|
102227
102255
|
}
|
|
102228
|
-
const className = getSingleClassSelector(
|
|
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 "${
|
|
102236
|
-
selector:
|
|
102237
|
-
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${
|
|
102238
|
-
snippet: truncateSnippet(
|
|
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: "
|
|
102279
|
-
message: `<video> with data-start
|
|
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.
|
|
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
|
-
|
|
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
|
|
103925
|
-
|
|
103926
|
-
|
|
103927
|
-
|
|
103928
|
-
|
|
103929
|
-
|
|
103930
|
-
|
|
103931
|
-
|
|
103932
|
-
|
|
103933
|
-
|
|
103934
|
-
|
|
103935
|
-
|
|
103936
|
-
|
|
103937
|
-
|
|
103938
|
-
|
|
103939
|
-
|
|
103940
|
-
|
|
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
|
-
|
|
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
|
|
103997
|
-
|
|
103998
|
-
|
|
103999
|
-
|
|
104000
|
-
|
|
104001
|
-
|
|
104002
|
-
|
|
104003
|
-
|
|
104004
|
-
|
|
104005
|
-
|
|
104006
|
-
|
|
104007
|
-
|
|
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 =
|
|
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
|
|
104134
|
-
end
|
|
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(
|
|
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;
|