mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
Merge 5e89ad9df6 into 0e558d5916
This commit is contained in:
@@ -832,6 +832,96 @@ describe("ffprobe missing-binary fallback", () => {
|
||||
expect(basename(calls[0]?.command ?? "")).toMatch(/^ffprobe(?:\.exe)?$/);
|
||||
});
|
||||
|
||||
it("analyzeKeyframeIntervals treats a single keyframe as the whole stream duration", async () => {
|
||||
// A 10s single-GOP video: exactly one keyframe at t=0. Every seek past
|
||||
// it lands inside that one GOP, so the effective interval is the whole
|
||||
// stream, not zero.
|
||||
const { spawn, calls } = createSpawnSpy([
|
||||
{ kind: "exit", code: 0, stdout: "0.000000\n" },
|
||||
{
|
||||
kind: "exit",
|
||||
code: 0,
|
||||
stdout: JSON.stringify({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 640,
|
||||
height: 360,
|
||||
r_frame_rate: "30/1",
|
||||
avg_frame_rate: "30/1",
|
||||
duration: "10.0",
|
||||
},
|
||||
],
|
||||
format: { duration: "10.0" },
|
||||
}),
|
||||
},
|
||||
]);
|
||||
vi.resetModules();
|
||||
vi.doMock("child_process", () => ({ spawn }));
|
||||
|
||||
const { analyzeKeyframeIntervals } = await import("./ffprobe.js");
|
||||
const result = await analyzeKeyframeIntervals("/tmp/single-gop.mp4");
|
||||
|
||||
expect(result).toEqual({
|
||||
avgIntervalSeconds: 10,
|
||||
maxIntervalSeconds: 10,
|
||||
keyframeCount: 1,
|
||||
isProblematic: true,
|
||||
});
|
||||
expect(calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it("analyzeKeyframeIntervals does not flag a single keyframe under the threshold", async () => {
|
||||
const { spawn } = createSpawnSpy([
|
||||
{ kind: "exit", code: 0, stdout: "0.000000\n" },
|
||||
{
|
||||
kind: "exit",
|
||||
code: 0,
|
||||
stdout: JSON.stringify({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 640,
|
||||
height: 360,
|
||||
r_frame_rate: "30/1",
|
||||
avg_frame_rate: "30/1",
|
||||
duration: "1.0",
|
||||
},
|
||||
],
|
||||
format: { duration: "1.0" },
|
||||
}),
|
||||
},
|
||||
]);
|
||||
vi.resetModules();
|
||||
vi.doMock("child_process", () => ({ spawn }));
|
||||
|
||||
const { analyzeKeyframeIntervals } = await import("./ffprobe.js");
|
||||
const result = await analyzeKeyframeIntervals("/tmp/short-single-gop.mp4");
|
||||
|
||||
expect(result.keyframeCount).toBe(1);
|
||||
expect(result.isProblematic).toBe(false);
|
||||
});
|
||||
|
||||
it("analyzeKeyframeIntervals reports no keyframes as not problematic", async () => {
|
||||
const { spawn, calls } = createSpawnSpy([{ kind: "exit", code: 0, stdout: "" }]);
|
||||
vi.resetModules();
|
||||
vi.doMock("child_process", () => ({ spawn }));
|
||||
|
||||
const { analyzeKeyframeIntervals } = await import("./ffprobe.js");
|
||||
const result = await analyzeKeyframeIntervals("/tmp/no-keyframes.mp4");
|
||||
|
||||
expect(result).toEqual({
|
||||
avgIntervalSeconds: 0,
|
||||
maxIntervalSeconds: 0,
|
||||
keyframeCount: 0,
|
||||
isProblematic: false,
|
||||
});
|
||||
// Only the keyframe probe should run — no metadata lookup for zero timestamps.
|
||||
expect(calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("ffprobe-missing error message includes install hint", async () => {
|
||||
const { spawn } = createSpawnSpy([{ kind: "missing" }]);
|
||||
hidePathBinaries();
|
||||
|
||||
@@ -1050,15 +1050,31 @@ async function analyzeKeyframeIntervalsUncached(filePath: string): Promise<Keyfr
|
||||
.map((line) => parseFloat(line.trim()))
|
||||
.filter((t) => Number.isFinite(t));
|
||||
|
||||
if (timestamps.length < 2) {
|
||||
if (timestamps.length === 0) {
|
||||
return {
|
||||
avgIntervalSeconds: 0,
|
||||
maxIntervalSeconds: 0,
|
||||
keyframeCount: timestamps.length,
|
||||
keyframeCount: 0,
|
||||
isProblematic: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (timestamps.length === 1) {
|
||||
// A single keyframe means every seek past it lands inside one GOP that
|
||||
// spans the whole stream, which is the worst case the multi-keyframe
|
||||
// branch below reports on. The interval is the stream duration, not
|
||||
// zero — the video-stream duration, not the container's, since they can
|
||||
// disagree (e.g. an audio-only tail past the last video frame).
|
||||
const { videoStreamDurationSeconds } = await extractMediaMetadata(filePath);
|
||||
const duration = Math.round(videoStreamDurationSeconds * 100) / 100;
|
||||
return {
|
||||
avgIntervalSeconds: duration,
|
||||
maxIntervalSeconds: duration,
|
||||
keyframeCount: 1,
|
||||
isProblematic: duration > 2,
|
||||
};
|
||||
}
|
||||
|
||||
let maxInterval = 0;
|
||||
let totalInterval = 0;
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
|
||||
@@ -777,4 +777,73 @@ describe("core rules", () => {
|
||||
expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("css_transition_used", () => {
|
||||
const withStyle = (css: string, body = "") => `
|
||||
<html><head><style>${css}</style></head><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0" data-duration="5">
|
||||
${body}
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
|
||||
it("flags a shorthand transition in a <style> block", async () => {
|
||||
const result = await lintHyperframeHtml(withStyle(".card { transition: all .2s; }"));
|
||||
const finding = result.findings.find((f) => f.code === "css_transition_used");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.selector).toBe(".card");
|
||||
});
|
||||
|
||||
it("flags a longhand transition-* property", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
withStyle(".card { transition-property: opacity; transition-duration: .3s; }"),
|
||||
);
|
||||
const codes = result.findings.filter((f) => f.code === "css_transition_used");
|
||||
expect(codes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("flags a -webkit- prefixed transition", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
withStyle(".card { -webkit-transition: opacity .2s; }"),
|
||||
);
|
||||
expect(result.findings.find((f) => f.code === "css_transition_used")).toBeDefined();
|
||||
});
|
||||
|
||||
it('flags an inline style="" transition and reports the element id', async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
withStyle("", '<div id="hero" style="transition: opacity .2s;"></div>'),
|
||||
);
|
||||
const finding = result.findings.find((f) => f.code === "css_transition_used");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe("#hero");
|
||||
expect(finding?.elementId).toBe("hero");
|
||||
});
|
||||
|
||||
it("does not flag transition: none", async () => {
|
||||
const result = await lintHyperframeHtml(withStyle(".card { transition: none; }"));
|
||||
expect(result.findings.find((f) => f.code === "css_transition_used")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag transition-property: none", async () => {
|
||||
const result = await lintHyperframeHtml(withStyle(".card { transition-property: none; }"));
|
||||
expect(result.findings.find((f) => f.code === "css_transition_used")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag a --transition-* custom property", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
withStyle(".card { --transition-speed: .2s; color: red; }"),
|
||||
);
|
||||
expect(result.findings.find((f) => f.code === "css_transition_used")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag a class named transition-like or a transition inside a comment", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
withStyle(
|
||||
".transition-card { color: red; } /* transition: all 1s; */ .other { color: blue; }",
|
||||
),
|
||||
);
|
||||
expect(result.findings.find((f) => f.code === "css_transition_used")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -513,4 +513,78 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// css_transition_used
|
||||
({ styles, tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
// Matches the shorthand and every longhand (-property, -duration, -delay,
|
||||
// -timing-function, -behavior), with or without -webkit-. Anchored at both
|
||||
// ends so a custom property (--transition-speed) never matches: its name
|
||||
// starts with "--", not "transition" or "-webkit-".
|
||||
const transitionProperty = /^(-webkit-)?transition(-[a-z-]+)?$/i;
|
||||
|
||||
const report = (
|
||||
prop: string,
|
||||
value: string,
|
||||
selector: string | undefined,
|
||||
elementId: string | undefined,
|
||||
) => {
|
||||
findings.push({
|
||||
code: "css_transition_used",
|
||||
severity: "error",
|
||||
message:
|
||||
`CSS \`${prop}\` runs on the browser clock, not the render's frame clock. A GSAP class/attr ` +
|
||||
"swap driven by it looks correct in preview and --workers 1, but a fresh page (parallel chunk " +
|
||||
"workers, snapshot --at past the settle time) restarts the transition from scratch, so the " +
|
||||
"export can flash or land on the wrong visual state.",
|
||||
selector,
|
||||
elementId,
|
||||
fixHint:
|
||||
"Keep the class/attribute swap for state, and put the visual change on the paused GSAP " +
|
||||
`timeline instead. Set \`${prop.startsWith("transition-") ? prop : "transition"}: none\` if a ` +
|
||||
"non-animated state change is intended.",
|
||||
snippet: truncateSnippet(`${prop}: ${value};`),
|
||||
});
|
||||
};
|
||||
|
||||
const scanDecls = (root: postcss.Root, inlineSelector?: string, inlineElementId?: string) => {
|
||||
root.walkDecls((decl) => {
|
||||
if (!transitionProperty.test(decl.prop)) return;
|
||||
if (decl.value.trim().toLowerCase() === "none") return;
|
||||
const parent = decl.parent;
|
||||
const selector =
|
||||
inlineSelector ??
|
||||
(parent?.type === "rule" ? (parent as postcss.Rule).selector : undefined);
|
||||
report(decl.prop, decl.value, selector, inlineElementId);
|
||||
});
|
||||
};
|
||||
|
||||
for (const style of styles) {
|
||||
let root: postcss.Root;
|
||||
try {
|
||||
root = postcss.parse(style.content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
scanDecls(root);
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
const inlineStyle = readAttr(tag.raw, "style");
|
||||
if (!inlineStyle) continue;
|
||||
let root: postcss.Root;
|
||||
try {
|
||||
// Wrapped in a dummy rule: postcss.parse expects a full stylesheet, and
|
||||
// an inline style="" value is a bare declaration list.
|
||||
root = postcss.parse(`__hf_inline__{${inlineStyle}}`);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const id = readAttr(tag.raw, "id");
|
||||
const firstClass = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean)[0];
|
||||
scanDecls(root, id ? `#${id}` : firstClass ? `.${firstClass}` : undefined, id ?? undefined);
|
||||
}
|
||||
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user