Skip to content
Merged
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
8 changes: 8 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
"import": "./src/beats/index.ts",
"types": "./src/beats/index.ts"
},
"./slideshow": {
"import": "./src/slideshow/index.ts",
"types": "./src/slideshow/index.ts"
},
"./lint": {
"import": "./src/lint/index.ts",
"types": "./src/lint/index.ts"
Expand Down Expand Up @@ -137,6 +141,10 @@
"import": "./dist/lint/index.js",
"types": "./dist/lint/index.d.ts"
},
"./slideshow": {
"import": "./dist/slideshow/index.js",
"types": "./dist/slideshow/index.d.ts"
},
"./compiler": {
"import": "./dist/compiler/index.js",
"types": "./dist/compiler/index.d.ts"
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ export type {
WaveformData,
} from "./core.types";

export type {
SlideshowManifest,
SlideRef,
SlideHotspot,
SlideSequence,
ResolvedSlide,
ResolvedSlideSequence,
ResolvedSlideshow,
} from "./slideshow/slideshow.types";

export { parseSlideshowManifest, resolveSlideshow } from "./slideshow/parseSlideshow";

export {
CANVAS_DIMENSIONS,
VALID_CANVAS_RESOLUTIONS,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/lint/hyperframeLinter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { compositionRules } from "./rules/composition";
import { adapterRules } from "./rules/adapters";
import { textureRules } from "./rules/textures";
import { fontRules } from "./rules/fonts";
import { slideshowRules } from "./rules/slideshow";

const ALL_RULES = [
...coreRules,
Expand All @@ -19,6 +20,7 @@ const ALL_RULES = [
...adapterRules,
...textureRules,
...fontRules,
...slideshowRules,
];

export async function lintHyperframeHtml(
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/lint/rules/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,9 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
const attrs = script.attrs || "";
if (
/\bsrc\s*=/.test(attrs) ||
/\btype\s*=\s*["'](?:application\/json|importmap|module)["']/.test(attrs)
/\btype\s*=\s*["'](?:application\/json|application\/hyperframes-slideshow\+json|importmap|module)["']/.test(
attrs,
)
)
continue;
const syntaxError = getInlineScriptSyntaxError(script.content);
Expand Down
73 changes: 73 additions & 0 deletions packages/core/src/lint/rules/slideshow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";

async function findSlideshow(html: string) {
const result = await lintHyperframeHtml(html, { isSubComposition: true });
return result.findings.filter((f) => f.code.startsWith("slideshow_"));
}

describe("slideshow lint rule", () => {
it("passes a composition with no slideshow island", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div id="a" class="clip" data-start="0" data-duration="5"></div>
</div>`;
expect(await findSlideshow(html)).toEqual([]);
});

it("passes a valid island where sceneId resolves to a data-composition-id scene", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div data-composition-id="a" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"a"}]}</script>
</div>`;
expect(await findSlideshow(html)).toEqual([]);
});

it("flags a sceneId that matches only a .clip[id] (not a data-composition-id)", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div id="a" class="clip" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"a"}]}</script>
</div>`;
const findings = await findSlideshow(html);
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]?.message).toContain("a");
});

it("flags an unresolved sceneId", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div id="a" class="clip" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"ghost"}]}</script>
</div>`;
const findings = await findSlideshow(html);
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]!.message).toContain("ghost");
});

it("flags invalid JSON in the island", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<script type="application/hyperframes-slideshow+json">NOT_JSON</script>
</div>`;
const findings = await findSlideshow(html);
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]!.code).toBe("slideshow_invalid");
});

it("passes when sceneId resolves to a data-composition-id element (no .clip[id])", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div data-composition-id="scene-a" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"scene-a"}]}</script>
</div>`;
expect(await findSlideshow(html)).toEqual([]);
});

it("flags a hotspot targeting an unknown sequence", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div data-composition-id="a" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">${JSON.stringify({
slides: [{ sceneId: "a", hotspots: [{ id: "h1", label: "Go", target: "no-such-seq" }] }],
})}</script>
</div>`;
const findings = await findSlideshow(html);
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]!.message).toContain("no-such-seq");
});
});
82 changes: 82 additions & 0 deletions packages/core/src/lint/rules/slideshow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// fallow-ignore-file code-duplication
import type { LintContext, HyperframeLintFinding } from "../context";
import type { LintRule } from "../types";
import { readAttr } from "../utils";
import { parseSlideshowManifest, resolveSlideshow } from "../../slideshow/parseSlideshow";

type Scene = { id: string; start: number; duration: number };

/** Mirrors isSceneLikeCompositionId in packages/core/src/runtime/timeline.ts */
function isSceneLikeCompositionId(compositionId: string): boolean {
const normalized = compositionId.trim().toLowerCase();
if (!normalized || normalized === "main") return false;
if (normalized.includes("caption")) return false;
if (normalized.includes("ambient")) return false;
return true;
}

function parseTiming(raw: string): { start: number; duration: number } | null {
const startStr = readAttr(raw, "data-start");
const durationStr = readAttr(raw, "data-duration");
if (startStr === null || durationStr === null) return null;
const start = Number(startStr);
const duration = Number(durationStr);
if (!Number.isFinite(start) || !Number.isFinite(duration)) return null;
return { start, duration };
}

function collectCompositionIdScenes(ctx: LintContext, seen: Set<string>, out: Scene[]): void {
for (const tag of ctx.tags) {
const compositionId = readAttr(tag.raw, "data-composition-id");
if (!compositionId || !isSceneLikeCompositionId(compositionId) || seen.has(compositionId))
continue;
const timing = parseTiming(tag.raw);
if (!timing || timing.duration <= 0) continue;
seen.add(compositionId);
out.push({ id: compositionId, ...timing });
}
}

function extractScenesFromClips(ctx: LintContext): Scene[] {
const seen = new Set<string>();
const scenes: Scene[] = [];
collectCompositionIdScenes(ctx, seen, scenes);
return scenes;
}

export const slideshowRules: LintRule<LintContext>[] = [
(ctx) => {
const findings: HyperframeLintFinding[] = [];

let manifest;
try {
manifest = parseSlideshowManifest(ctx.source);
} catch (e) {
findings.push({
code: "slideshow_invalid",
severity: "error",
message: `Slideshow island contains invalid JSON or structure: ${e instanceof Error ? e.message : String(e)}`,
fixHint:
'Ensure the <script type="application/hyperframes-slideshow+json"> block contains valid JSON matching the SlideshowManifest schema.',
});
return findings;
}

if (!manifest) return findings;

const scenes = extractScenesFromClips(ctx);
const { errors } = resolveSlideshow(manifest, scenes);

for (const error of errors) {
findings.push({
code: "slideshow_unresolved_ref",
severity: "error",
message: `Slideshow manifest error: ${error}`,
fixHint:
"Ensure every sceneId in the slideshow island matches the data-composition-id of a scene element in the composition, or provide explicit startTime/endTime.",
});
}

return findings;
},
];
2 changes: 2 additions & 0 deletions packages/core/src/slideshow/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./slideshow.types";
export * from "./parseSlideshow";
142 changes: 142 additions & 0 deletions packages/core/src/slideshow/parseSlideshow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// packages/core/src/slideshow/parseSlideshow.test.ts
import { describe, it, expect } from "vitest";
import { parseSlideshowManifest, resolveSlideshow } from "./parseSlideshow";

const ISLAND = `<!doctype html><html><body>
<script type="application/hyperframes-slideshow+json">
{ "slides": [
{ "sceneId": "a", "fragments": [2.0, 1.0], "hotspots": [{ "id": "h1", "label": "Why?", "target": "deep" }] },
{ "sceneId": "b" }
],
"slideSequences": [ { "id": "deep", "label": "Deep dive", "slides": [ { "sceneId": "c" } ] } ]
}
</script>
</body></html>`;

const SCENES = [
{ id: "a", start: 0, duration: 5 },
{ id: "b", start: 5, duration: 5 },
{ id: "c", start: 10, duration: 3 },
];

describe("parseSlideshowManifest", () => {
it("returns null when no island present", () => {
expect(parseSlideshowManifest("<html></html>")).toBeNull();
});

it("parses the island JSON", () => {
const m = parseSlideshowManifest(ISLAND);
expect(m?.slides.length).toBe(2);
expect(m?.slideSequences?.[0].id).toBe("deep");
});
});

describe("resolveSlideshow", () => {
it("resolves scene time ranges and sorts fragments", () => {
const m = parseSlideshowManifest(ISLAND);
if (!m) throw new Error("manifest expected");
const { resolved, errors } = resolveSlideshow(m, SCENES);
expect(errors).toEqual([]);
expect(resolved.slides[0].start).toBe(0);
expect(resolved.slides[0].end).toBe(5);
expect(resolved.slides[0].fragments).toEqual([1.0, 2.0]); // sorted
expect(resolved.sequences.deep.slides[0].start).toBe(10);
});

it("honors explicit startTime/endTime overrides", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", startTime: 1, endTime: 4 }],
};
const { resolved } = resolveSlideshow(m, SCENES);
expect(resolved.slides[0].start).toBe(1);
expect(resolved.slides[0].end).toBe(4);
});

it("reports an error for an unresolved sceneId", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "missing" }],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.some((e) => e.includes("missing"))).toBe(true);
});

it("reports an error for a fragment outside the slide range", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", fragments: [99] }],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.some((e) => e.includes("fragment"))).toBe(true);
});

it("reports an error for a hotspot target with no sequence", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", hotspots: [{ id: "h", label: "x", target: "nope" }] }],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.some((e) => e.includes("nope"))).toBe(true);
});

it("reports an error for overlapping main-line slides", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [
{ sceneId: "a", startTime: 0, endTime: 6 },
{ sceneId: "b", startTime: 5, endTime: 10 },
],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.some((e) => e.includes("overlap"))).toBe(true);
});

// Partial-override cases
it("fills missing endTime from scene when only startTime is provided and scene exists", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", startTime: 2 }],
};
const { resolved, errors } = resolveSlideshow(m, SCENES);
expect(errors).toEqual([]);
expect(resolved.slides[0].start).toBe(2);
expect(resolved.slides[0].end).toBe(5); // scene a: start=0, duration=5
});

it("fills missing startTime from scene when only endTime is provided and scene exists", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", endTime: 3 }],
};
const { resolved, errors } = resolveSlideshow(m, SCENES);
expect(errors).toEqual([]);
expect(resolved.slides[0].start).toBe(0); // scene a: start=0
expect(resolved.slides[0].end).toBe(3);
});

it("reports a clear error when only startTime is provided but scene is absent", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "x", startTime: 2 }],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.length).toBeGreaterThan(0);
// Must mention the missing bound (endTime), not the misleading "unresolved sceneId"
expect(errors.some((e) => e.includes("endTime"))).toBe(true);
expect(errors.some((e) => e.includes("unresolved sceneId"))).toBe(false);
});

it("reports a clear error when only endTime is provided but scene is absent", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "x", endTime: 5 }],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.length).toBeGreaterThan(0);
// Must mention the missing bound (startTime), not the misleading "unresolved sceneId"
expect(errors.some((e) => e.includes("startTime"))).toBe(true);
expect(errors.some((e) => e.includes("unresolved sceneId"))).toBe(false);
});

it("full override with no scene produces no error", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "noexist", startTime: 1, endTime: 4 }],
};
const { resolved, errors } = resolveSlideshow(m, SCENES);
expect(errors).toEqual([]);
expect(resolved.slides[0].start).toBe(1);
expect(resolved.slides[0].end).toBe(4);
});
});
Loading
Loading