mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
feat(core): slideshow schema, parser, and lint rule (#1580)
## Slideshow mode — 1/5: core schema, parser & lint
Foundation for slideshow mode: a composition can declare an embedded **slideshow manifest** that turns its continuous timeline into a discrete, navigable deck. This PR adds the data model, parser, and validation — no runtime/UI yet.
### What & why
A slide is just an existing scene (`data-composition-id` + `data-start`/`data-duration`) plus metadata declared in one embedded `<script type="application/hyperframes-slideshow+json">` island. Keeping the manifest *in the composition* means no new file format and no build step — slides are additive metadata over a normal composition.
### Key changes
- `slideshow/slideshow.types.ts` — `SlideshowManifest`, `SlideRef`, `SlideHotspot`, `SlideSequence` and their resolved counterparts. TTS fields (`ttsScript`/`ttsAudioUrl`/`ttsDurationMs`) are present but **reserved** (playback not built).
- `slideshow/parseSlideshow.ts` — `parseSlideshowManifest(html)` extracts the island; `resolveSlideshow(manifest, scenes)` resolves each `sceneId` to a `{start,end}` range (honouring optional `startTime`/`endTime` overrides) and returns validation errors for: unresolved sceneId, fragment outside a slide's range, hotspot targeting an unknown sequence, and overlapping main-line slides.
- `lint/rules/slideshow.ts` — surfaces those resolve errors under `hyperframes lint`; derives scenes from `data-composition-id` (matching the runtime's scene source).
- `lint/rules/core.ts` — exempts the slideshow island MIME type from the inline-script-syntax check.
- `./slideshow` subpath export (dev + publishConfig) so downstream packages import only the lightweight parser, keeping core's Node-only barrel out of their typecheck graph.
### Testing
`parseSlideshow.test.ts` + `slideshow.test.ts` (vitest) cover parse, resolution, every error path, and the lint rule.
### Stack
Bottom of a 5-PR stack: **core** → player (#1581) → studio (#1582) → skill (#1583) → examples (#1584).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user