This commit is contained in:
Rajan Pantha
2026-08-30 11:45:36 +05:45
committed by GitHub
4 changed files with 251 additions and 2 deletions
+90
View File
@@ -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();
+18 -2
View File
@@ -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++) {
+69
View File
@@ -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();
});
});
});
+74
View File
@@ -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;
},
];