diff --git a/packages/core/package.json b/packages/core/package.json index e4d5aa19b..9be376dbc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" @@ -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" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8f38c7e05..8f0d84d7d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts index 9467c3bea..1dc88067c 100644 --- a/packages/core/src/lint/hyperframeLinter.ts +++ b/packages/core/src/lint/hyperframeLinter.ts @@ -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( diff --git a/packages/core/src/lint/rules/core.ts b/packages/core/src/lint/rules/core.ts index a55c653d6..5a057ad4b 100644 --- a/packages/core/src/lint/rules/core.ts +++ b/packages/core/src/lint/rules/core.ts @@ -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); diff --git a/packages/core/src/lint/rules/slideshow.test.ts b/packages/core/src/lint/rules/slideshow.test.ts new file mode 100644 index 000000000..8b7ed6946 --- /dev/null +++ b/packages/core/src/lint/rules/slideshow.test.ts @@ -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 = `
+
+
`; + expect(await findSlideshow(html)).toEqual([]); + }); + + it("passes a valid island where sceneId resolves to a data-composition-id scene", async () => { + const html = `
+
+ +
`; + expect(await findSlideshow(html)).toEqual([]); + }); + + it("flags a sceneId that matches only a .clip[id] (not a data-composition-id)", async () => { + const html = `
+
+ +
`; + const findings = await findSlideshow(html); + expect(findings.length).toBeGreaterThan(0); + expect(findings[0]?.message).toContain("a"); + }); + + it("flags an unresolved sceneId", async () => { + const html = `
+
+ +
`; + 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 = `
+ +
`; + 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 = `
+
+ +
`; + expect(await findSlideshow(html)).toEqual([]); + }); + + it("flags a hotspot targeting an unknown sequence", async () => { + const html = `
+
+ +
`; + const findings = await findSlideshow(html); + expect(findings.length).toBeGreaterThan(0); + expect(findings[0]!.message).toContain("no-such-seq"); + }); +}); diff --git a/packages/core/src/lint/rules/slideshow.ts b/packages/core/src/lint/rules/slideshow.ts new file mode 100644 index 000000000..c19a3e9cd --- /dev/null +++ b/packages/core/src/lint/rules/slideshow.ts @@ -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, 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(); + const scenes: Scene[] = []; + collectCompositionIdScenes(ctx, seen, scenes); + return scenes; +} + +export const slideshowRules: LintRule[] = [ + (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 +`; + +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("")).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); + }); +}); diff --git a/packages/core/src/slideshow/parseSlideshow.ts b/packages/core/src/slideshow/parseSlideshow.ts new file mode 100644 index 000000000..bb74559f6 --- /dev/null +++ b/packages/core/src/slideshow/parseSlideshow.ts @@ -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 + const re = new RegExp( + `]*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, + 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 = {}; + 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 }; +} diff --git a/packages/core/src/slideshow/slideshow.types.ts b/packages/core/src/slideshow/slideshow.types.ts new file mode 100644 index 000000000..a9074b930 --- /dev/null +++ b/packages/core/src/slideshow/slideshow.types.ts @@ -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; // keyed by sequence id +}