mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -19,6 +20,7 @@ const ALL_RULES = [
|
||||
...adapterRules,
|
||||
...textureRules,
|
||||
...fontRules,
|
||||
...slideshowRules,
|
||||
];
|
||||
|
||||
export async function lintHyperframeHtml(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./slideshow.types";
|
||||
export * from "./parseSlideshow";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// packages/core/src/slideshow/parseSlideshow.ts
|
||||
import type {
|
||||
SlideshowManifest,
|
||||
SlideRef,
|
||||
ResolvedSlide,
|
||||
ResolvedSlideshow,
|
||||
ResolvedSlideSequence,
|
||||
} from "./slideshow.types";
|
||||
|
||||
const ISLAND_TYPE = "application/hyperframes-slideshow+json";
|
||||
|
||||
interface SceneRange {
|
||||
id: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** Extract the JSON island from composition HTML. Returns null if absent. */
|
||||
export function parseSlideshowManifest(html: string): SlideshowManifest | null {
|
||||
// Match <script type="application/hyperframes-slideshow+json"> ... </script>
|
||||
const re = new RegExp(
|
||||
`<script[^>]*type=["']${ISLAND_TYPE.replace(/[.+]/g, "\\$&")}["'][^>]*>([\\s\\S]*?)<\\/script>`,
|
||||
"i",
|
||||
);
|
||||
const match = re.exec(html);
|
||||
if (!match || match[1] === undefined) return null;
|
||||
const raw = match[1].trim();
|
||||
if (raw.length === 0) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isManifest(parsed)) {
|
||||
throw new Error("slideshow island is not a valid SlideshowManifest");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isManifest(v: unknown): v is SlideshowManifest {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
if (!("slides" in v)) return false;
|
||||
return Array.isArray(v.slides);
|
||||
}
|
||||
|
||||
function missingBoundError(sceneId: string, missing: "startTime" | "endTime"): string {
|
||||
const present = missing === "startTime" ? "endTime" : "startTime";
|
||||
return `slide "${sceneId}" sets ${present} but ${missing} cannot be resolved (no scene "${sceneId}")`;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function resolveTimeRange(
|
||||
ref: SlideRef,
|
||||
scene: SceneRange | undefined,
|
||||
errors: string[],
|
||||
): { start: number; end: number } {
|
||||
const { startTime, endTime, sceneId } = ref;
|
||||
|
||||
// Both explicit — use them directly, no scene needed.
|
||||
if (startTime !== undefined && endTime !== undefined) {
|
||||
return { start: startTime, end: endTime };
|
||||
}
|
||||
|
||||
// Neither explicit — resolve both from scene.
|
||||
if (startTime === undefined && endTime === undefined) {
|
||||
if (!scene) {
|
||||
errors.push(`slide references unresolved sceneId "${sceneId}"`);
|
||||
return { start: 0, end: 0 };
|
||||
}
|
||||
return { start: scene.start, end: scene.start + scene.duration };
|
||||
}
|
||||
|
||||
// Exactly one bound explicit — fill from scene, or report a clear error.
|
||||
if (!scene) {
|
||||
const missing = startTime === undefined ? "startTime" : "endTime";
|
||||
errors.push(missingBoundError(sceneId, missing));
|
||||
const bound = startTime ?? endTime ?? 0;
|
||||
return { start: bound, end: bound };
|
||||
}
|
||||
|
||||
return {
|
||||
start: startTime ?? scene.start,
|
||||
end: endTime ?? scene.start + scene.duration,
|
||||
};
|
||||
}
|
||||
|
||||
function validateFragments(
|
||||
sceneId: string,
|
||||
fragments: number[],
|
||||
start: number,
|
||||
end: number,
|
||||
errors: string[],
|
||||
): void {
|
||||
for (const f of fragments) {
|
||||
if (f < start || f > end) {
|
||||
errors.push(`slide "${sceneId}" fragment ${f} is outside range [${start}, ${end}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSlide(
|
||||
ref: SlideRef,
|
||||
sceneById: Map<string, SceneRange>,
|
||||
errors: string[],
|
||||
): ResolvedSlide {
|
||||
const scene = sceneById.get(ref.sceneId);
|
||||
const { start, end } = resolveTimeRange(ref, scene, errors);
|
||||
const fragments = [...(ref.fragments ?? [])].sort((a, b) => a - b);
|
||||
validateFragments(ref.sceneId, fragments, start, end, errors);
|
||||
return { ...ref, start, end, fragments, hotspots: ref.hotspots ?? [] };
|
||||
}
|
||||
|
||||
export function resolveSlideshow(
|
||||
manifest: SlideshowManifest,
|
||||
scenes: SceneRange[],
|
||||
): { resolved: ResolvedSlideshow; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
const sceneById = new Map(scenes.map((s) => [s.id, s]));
|
||||
|
||||
const sequences: Record<string, ResolvedSlideSequence> = {};
|
||||
for (const seq of manifest.slideSequences ?? []) {
|
||||
sequences[seq.id] = {
|
||||
id: seq.id,
|
||||
label: seq.label,
|
||||
slides: seq.slides.map((s) => resolveSlide(s, sceneById, errors)),
|
||||
};
|
||||
}
|
||||
|
||||
const slides = manifest.slides.map((s) => resolveSlide(s, sceneById, errors));
|
||||
|
||||
// Validate hotspot targets.
|
||||
const allSlides = [...slides, ...Object.values(sequences).flatMap((s) => s.slides)];
|
||||
for (const slide of allSlides) {
|
||||
for (const h of slide.hotspots) {
|
||||
if (!sequences[h.target]) {
|
||||
errors.push(`hotspot "${h.id}" targets unknown sequence "${h.target}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate no main-line overlap (sorted by start; adjacent compare).
|
||||
const ordered = [...slides].sort((a, b) => a.start - b.start);
|
||||
for (let i = 1; i < ordered.length; i++) {
|
||||
const prev = ordered[i - 1];
|
||||
const curr = ordered[i];
|
||||
if (prev !== undefined && curr !== undefined && curr.start < prev.end) {
|
||||
errors.push(`main-line slides "${prev.sceneId}" and "${curr.sceneId}" overlap`);
|
||||
}
|
||||
}
|
||||
|
||||
return { resolved: { slides, sequences }, errors };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// packages/core/src/slideshow/slideshow.types.ts
|
||||
|
||||
/** Raw author-facing shapes parsed from the JSON island. */
|
||||
export interface SlideshowManifest {
|
||||
slides: SlideRef[];
|
||||
slideSequences?: SlideSequence[];
|
||||
}
|
||||
|
||||
export interface SlideRef {
|
||||
sceneId: string;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
notes?: string;
|
||||
fragments?: number[];
|
||||
hotspots?: SlideHotspot[];
|
||||
// Reserved — TTS deferred. Parsed and carried, never consumed.
|
||||
ttsScript?: string;
|
||||
ttsAudioUrl?: string;
|
||||
ttsDurationMs?: number;
|
||||
}
|
||||
|
||||
export interface SlideHotspot {
|
||||
id: string;
|
||||
label: string;
|
||||
target: string; // references a SlideSequence.id
|
||||
region?: { x: number; y: number; w: number; h: number }; // % of slide
|
||||
}
|
||||
|
||||
export interface SlideSequence {
|
||||
id: string;
|
||||
label: string;
|
||||
slides: SlideRef[];
|
||||
}
|
||||
|
||||
/** A slide with its time range resolved from the matching scene. */
|
||||
export interface ResolvedSlide extends SlideRef {
|
||||
start: number;
|
||||
end: number;
|
||||
fragments: number[]; // always present, sorted, defaulted to []
|
||||
hotspots: SlideHotspot[]; // always present, defaulted to []
|
||||
}
|
||||
|
||||
export interface ResolvedSlideSequence {
|
||||
id: string;
|
||||
label: string;
|
||||
slides: ResolvedSlide[];
|
||||
}
|
||||
|
||||
export interface ResolvedSlideshow {
|
||||
slides: ResolvedSlide[];
|
||||
sequences: Record<string, ResolvedSlideSequence>; // keyed by sequence id
|
||||
}
|
||||
Reference in New Issue
Block a user