Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1374,7 +1374,7 @@ edges re-measured to about 0.01 s, and skipped for a `--range` check.
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `line_unread` | A reading-size line (30 px or taller at 1080) is on screen for less than its floor: 0.4 s for one or two words, otherwise three quarters of 0.5 s + 0.3 s a word. The film's last line needs 2 s. |
| `dead_stretch` | Nothing on screen changes for more than 3 s, by the same classifier `keepsMoving` uses. |
| `text_below_floor` | Text under 2.6% of the frame's short side (28 px on 1080) stays on screen 0.5 s or more. One finding per file lists every such element. |
| `text_below_floor` | Text under 2.6% of the frame's short side (28 px on 1080) stays on screen 0.5 s or more. One finding per rendered size lists every such element across files. |
| `first_frame_empty` | Frame 0 paints nothing but the background (no text, image, video, canvas or SVG), or an opaque element covers every other clip and is named. The first frame is the thumbnail on social players. |
| `film_silent` | A film longer than 15 s has no unmuted `<audio>` and no `<video>` with sound. |

Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/utils/checkTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,14 @@ export interface CraftFinding extends CheckFinding {
floorPx?: number;
until?: number;
measured?: { time: number; opacity: number | null; heightPx: number | null }[];
elements?: { selector: string; text: string; fontPx: number; time: number; seconds: number }[];
elements?: {
selector: string;
text: string;
fontPx: number;
time: number;
seconds: number;
sourceFile: string;
}[];
}

export interface CraftResult {
Expand Down
71 changes: 67 additions & 4 deletions packages/cli/src/utils/craftAudit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ interface Shown {
block?: number;
/** The block's rendered text; defaults to `text`. */
blockText?: string;
/** Source file the element comes from; defaults to the single-scene file. */
sourceFile?: string;
/** Top edge in px; defaults to a row per key. */
top?: number;
}
Expand All @@ -37,7 +39,7 @@ function part(shown: Shown): CraftTextPart {
block: shown.block ?? shown.key,
selector: `#t${shown.key}`,
blockSelector: `#t${shown.block ?? shown.key}`,
sourceFile: "compositions/scene.html",
sourceFile: shown.sourceFile ?? "compositions/scene.html",
text: shown.text,
blockText: shown.blockText ?? shown.text,
opacity: 1,
Expand Down Expand Up @@ -306,18 +308,79 @@ describe("text_below_floor", () => {
expect(typeFloorPx({ width: 3840, height: 2160 })).toBe(56);
});

it("warns once per file on small text held 0.5 s or more", () => {
it("warns once per size on small text held 0.5 s or more", () => {
const shown: Shown[] = [
{ key: 1, text: "v2.4 release notes", from: 0.5, to: 6, fontPx: 18 },
{ key: 2, text: "Q1", from: 1, to: 6, fontPx: 14 },
];
const findings = detectTextBelowFloor(film(10, shown), 10, CANVAS);
expect(findings).toHaveLength(1);
expect(findings).toHaveLength(2);
expect(findings.map((finding) => finding.fontPx)).toEqual([14, 18]);
expect(findings[0]).toMatchObject({ code: "text_below_floor", fontPx: 14, floorPx: 28 });
expect(findings[0]?.elements).toHaveLength(2);
expect(findings[0]?.elements).toHaveLength(1);
expect(findings[0]?.message).toContain("references/look.md");
});

it("reports one finding for the same size across scene files, listing each file", () => {
const shown: Shown[] = [
{
key: 1,
text: "인천 앞바다 · 1950.9",
from: 0.5,
to: 6,
fontPx: 27,
sourceFile: "compositions/V01.html",
},
{
key: 2,
text: "상륙 직전, 인천 앞바다 · 1950.9",
from: 0.5,
to: 6,
fontPx: 27,
sourceFile: "compositions/V02.html",
},
];
const findings = detectTextBelowFloor(film(10, shown), 10, CANVAS);
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({ code: "text_below_floor", fontPx: 27, floorPx: 28 });
expect(findings[0]?.message).toContain("2 text elements in 2 files");
expect(findings[0]?.message).toContain("all at 27 px");
expect(findings[0]?.elements).toEqual([
expect.objectContaining({
text: "인천 앞바다 · 1950.9",
sourceFile: "compositions/V01.html",
}),
expect.objectContaining({
text: "상륙 직전, 인천 앞바다 · 1950.9",
sourceFile: "compositions/V02.html",
}),
]);
});

it("keeps different sizes in different files as separate findings", () => {
const shown: Shown[] = [
{
key: 1,
text: "location label",
from: 0.5,
to: 6,
fontPx: 27,
sourceFile: "compositions/V01.html",
},
{
key: 2,
text: "source credit",
from: 0.5,
to: 6,
fontPx: 17,
sourceFile: "compositions/V02.html",
},
];
const findings = detectTextBelowFloor(film(10, shown), 10, CANVAS);
expect(findings).toHaveLength(2);
expect(findings.map((finding) => finding.fontPx)).toEqual([17, 27]);
});

it("does not warn on 28 px text, a brief small flash, captions or symbols", () => {
const shown: Shown[] = [
{ key: 1, text: "Body copy at the floor", from: 0, to: 10, fontPx: 28 },
Expand Down
32 changes: 22 additions & 10 deletions packages/cli/src/utils/craftAudit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,21 +571,29 @@ export function detectTextBelowFloor(
});
}
const shortSide = Math.min(canvas.width, canvas.height);
return groupBySourceFile(small).map((group) => smallTextFinding(group, floorPx, shortSide));
return groupByStyle(small).map((group) => smallTextFinding(group, floorPx, shortSide));
}

function longestRun(runs: [number, number][]): [number, number] {
return runs.reduce((best, run) => (run[1] - run[0] > best[1] - best[0] ? run : best), [0, -1]);
}

/** One finding per file (a scene, with sub-compositions), anchored on its smallest text. */
function groupBySourceFile(small: SmallText[]): SmallText[][] {
const groups = new Map<string, SmallText[]>();
for (const entry of small)
groups.set(entry.sourceFile, [...(groups.get(entry.sourceFile) ?? []), entry]);
return [...groups.values()].map((group) =>
group.sort((a, b) => a.fontPx - b.fontPx || a.time - b.time),
);
/**
* One finding per rendered size, across files: elements at the same rounded
* size share one style fix, however many scene files carry them. The craft
* layer sees no class list or font family, so the rounded size is the style
* key. Groups come out smallest first; entries inside a group stay sorted by
* size, then time, so the smallest element anchors the finding.
*/
function groupByStyle(small: SmallText[]): SmallText[][] {
const groups = new Map<number, SmallText[]>();
for (const entry of small) {
const size = Math.round(entry.fontPx);
groups.set(size, [...(groups.get(size) ?? []), entry]);
}
return [...groups.values()]
.map((group) => group.sort((a, b) => a.fontPx - b.fontPx || a.time - b.time))
.sort((a, b) => Math.round(a[0]?.fontPx ?? 0) - Math.round(b[0]?.fontPx ?? 0));
}

function quote(text: string, max: number): string {
Expand All @@ -595,10 +603,13 @@ function quote(text: string, max: number): string {
function smallTextFinding(group: SmallText[], floorPx: number, shortSide: number): CraftFinding {
const [smallest] = group;
if (!smallest) throw new Error("text_below_floor group is empty");
const files = new Set(group.map((entry) => entry.sourceFile)).size;
const subject =
group.length === 1
? `${quote(smallest.text, 48)} renders at ${Math.round(smallest.fontPx)} px for ${seconds(smallest.seconds)}`
: `${group.length} text elements stay under it for 0.5 s or more, the smallest ${quote(smallest.text, 40)} at ${Math.round(smallest.fontPx)} px`;
: files === 1
? `${group.length} text elements stay under it for 0.5 s or more, the smallest ${quote(smallest.text, 40)} at ${Math.round(smallest.fontPx)} px`
: `${group.length} text elements in ${files} files stay under it, all at ${Math.round(smallest.fontPx)} px (e.g. ${quote(smallest.text, 40)} in ${smallest.sourceFile})`;
const byTime = [...group].sort((a, b) => a.time - b.time);
return {
code: "text_below_floor",
Expand All @@ -620,6 +631,7 @@ function smallTextFinding(group: SmallText[], floorPx: number, shortSide: number
fontPx: round(entry.fontPx),
time: round(entry.time),
seconds: round(entry.seconds),
sourceFile: entry.sourceFile,
})),
};
}
Expand Down
2 changes: 1 addition & 1 deletion skills/hyperframes-cli/references/lint-validate-inspect.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Run it as `npx hyperframes check`, never through `npm run`: a tool timeout's SIG
- **Craft**: warnings only, even under `--strict` (a film may break one on purpose), measured on an 8 fps sweep of the whole film and skipped under `--range`. Each message names the fix and the reference to read.
- `line_unread`: a reading-size line (≥ 30 px tall at 1080) on screen under its floor: 0.4 s for 1–2 words, otherwise ¾ of 0.5 s + 0.3 s a word (3 words 1.05 s, 6 words 1.73 s); the film's last line needs 2 s. Edges are re-measured to ~0.01 s. Captions-track text and `data-layout-ignore` are exempt.
- `dead_stretch`: more than 3 s where the `keepsMoving` classifier sees nothing change. The final 3 s and any time a `data-hold` element is on screen are exempt; it stays quiet when `sweep_static` fires or a whole-composition `keepsMoving` assertion sets the limit.
- `text_below_floor`: text under 2.6% of the frame's short side (28 px on 1080) held 0.5 s or more, one finding per file listing every element.
- `text_below_floor`: text under 2.6% of the frame's short side (28 px on 1080) held 0.5 s or more, one finding per rendered size listing every element across files.
- `first_frame_empty`: frame 0 paints nothing but the background (no text, image, video, canvas or svg), or an opaque element covers every other clip and the message names it; it is the thumbnail on social players.
- `film_silent`: a film over 15 s with no unmuted `<audio>` or audible `<video>`.
- **Audio**: the clips the render mixes, in the report's `audio` section. It runs even when lint stops the browser pass. Remote sources are not fetched.
Expand Down