chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export * from "./slideshow.types.js";
|
||||
export * from "./parseSlideshow.js";
|
||||
export { isSceneLikeCompositionId } from "./sceneId.js";
|
||||
@@ -0,0 +1,218 @@
|
||||
// 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");
|
||||
});
|
||||
|
||||
it("throws when slideSequences is present but not an array", () => {
|
||||
const html = `<script type="application/hyperframes-slideshow+json">
|
||||
{ "slides": [{ "sceneId": "a" }], "slideSequences": {} }
|
||||
</script>`;
|
||||
expect(() => parseSlideshowManifest(html)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a non-object manifest (e.g. a JSON array)", () => {
|
||||
const html = `<script type="application/hyperframes-slideshow+json">[42, null]</script>`;
|
||||
expect(() => parseSlideshowManifest(html)).toThrow();
|
||||
});
|
||||
|
||||
it("throws when a slide entry is malformed (sceneId not a string)", () => {
|
||||
const html = `<script type="application/hyperframes-slideshow+json">
|
||||
{ "slides": [{ "sceneId": 42 }] }
|
||||
</script>`;
|
||||
expect(() => parseSlideshowManifest(html)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
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("flags duplicate slideSequence ids instead of silently overwriting", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "a" }],
|
||||
slideSequences: [
|
||||
{ id: "dup", label: "First", slides: [{ sceneId: "c" }] },
|
||||
{ id: "dup", label: "Second", slides: [{ sceneId: "c" }] },
|
||||
],
|
||||
};
|
||||
const { errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors.some((e) => e.includes("duplicate slideSequence id"))).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("reports an error for an inverted explicit range (endTime <= startTime)", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "a", startTime: 5, endTime: 2 }],
|
||||
};
|
||||
const { errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors.some((e) => e.includes("endTime") && e.includes("startTime"))).toBe(true);
|
||||
});
|
||||
|
||||
it("de-duplicates fragments before resolving", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "a", fragments: [2, 1, 2, 1, 3] }],
|
||||
};
|
||||
const { resolved, errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors).toEqual([]);
|
||||
expect(resolved.slides[0].fragments).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("reports an error for a hotspot targeting an empty sequence", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "a", hotspots: [{ id: "h", label: "x", target: "empty" }] }],
|
||||
slideSequences: [{ id: "empty", label: "Empty", slides: [] }],
|
||||
};
|
||||
const { errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors.some((e) => e.includes("empty sequence"))).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("parses and carries through the per-slide autoplay flag", () => {
|
||||
const island = `<script type="application/hyperframes-slideshow+json">
|
||||
{ "slides": [ { "sceneId": "a", "autoplay": true }, { "sceneId": "b" } ] }
|
||||
</script>`;
|
||||
const m = parseSlideshowManifest(island);
|
||||
expect(m?.slides[0].autoplay).toBe(true);
|
||||
expect(m?.slides[1].autoplay).toBeUndefined();
|
||||
const { resolved } = resolveSlideshow(m!, SCENES);
|
||||
expect(resolved.slides[0].autoplay).toBe(true);
|
||||
expect(resolved.slides[1].autoplay).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a manifest whose slide autoplay is not a boolean", () => {
|
||||
const island = `<script type="application/hyperframes-slideshow+json">
|
||||
{ "slides": [ { "sceneId": "a", "autoplay": "yes" } ] }
|
||||
</script>`;
|
||||
expect(() => parseSlideshowManifest(island)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
// packages/core/src/slideshow/parseSlideshow.ts
|
||||
import type {
|
||||
SlideshowManifest,
|
||||
SlideRef,
|
||||
ResolvedSlide,
|
||||
ResolvedSlideshow,
|
||||
ResolvedSlideSequence,
|
||||
} from "./slideshow.types";
|
||||
|
||||
export const SLIDESHOW_ISLAND_TYPE = "application/hyperframes-slideshow+json";
|
||||
|
||||
/**
|
||||
* Builds the island <script> matcher. Capture group 1 = inner JSON.
|
||||
*
|
||||
* Factory (fresh RegExp per call) on purpose: a RegExp with the `g` flag carries
|
||||
* a mutable `lastIndex`, so callers that need `g` must call this each time rather
|
||||
* than caching a shared instance.
|
||||
*/
|
||||
export function slideshowIslandRegex(flags = "i"): RegExp {
|
||||
// Escape ALL regex metacharacters in the constant (CodeQL flags the incomplete
|
||||
// `\` escape). The constant has none today, but a complete escape is correct.
|
||||
const escaped = SLIDESHOW_ISLAND_TYPE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`<script[^>]*type=["']${escaped}["'][^>]*>([\\s\\S]*?)<\\/script>`, flags);
|
||||
}
|
||||
|
||||
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 = slideshowIslandRegex("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 isOptionalNumberArray(v: unknown): boolean {
|
||||
return v === undefined || (Array.isArray(v) && v.every((n) => typeof n === "number"));
|
||||
}
|
||||
|
||||
function isOptionalBoolean(v: unknown): v is boolean | undefined {
|
||||
return v === undefined || typeof v === "boolean";
|
||||
}
|
||||
|
||||
function isSlideRef(v: unknown): v is SlideRef {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const r = v as Record<string, unknown>;
|
||||
if (typeof r["sceneId"] !== "string") return false;
|
||||
if (!isOptionalNumberArray(r["fragments"])) return false;
|
||||
if (r["hotspots"] !== undefined && !Array.isArray(r["hotspots"])) return false;
|
||||
if (!isOptionalBoolean(r["autoplay"])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSlideSequence(v: unknown): boolean {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const s = v as Record<string, unknown>;
|
||||
return (
|
||||
typeof s["id"] === "string" &&
|
||||
typeof s["label"] === "string" &&
|
||||
Array.isArray(s["slides"]) &&
|
||||
s["slides"].every(isSlideRef)
|
||||
);
|
||||
}
|
||||
|
||||
function isManifest(v: unknown): v is SlideshowManifest {
|
||||
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
if (!Array.isArray(o["slides"]) || !o["slides"].every(isSlideRef)) return false;
|
||||
if (o["slideSequences"] !== undefined) {
|
||||
if (!Array.isArray(o["slideSequences"]) || !o["slideSequences"].every(isSlideSequence))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
if (ref.startTime !== undefined && ref.endTime !== undefined && end <= start) {
|
||||
errors.push(`slide "${ref.sceneId}" has endTime (${end}) <= startTime (${start})`);
|
||||
}
|
||||
const fragments = [...new Set(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 ?? []) {
|
||||
// Flag duplicate sequence ids rather than silently overwriting the earlier one.
|
||||
if (Object.prototype.hasOwnProperty.call(sequences, seq.id)) {
|
||||
errors.push(`duplicate slideSequence id "${seq.id}" — only the last definition is kept`);
|
||||
}
|
||||
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) {
|
||||
const seq = sequences[h.target];
|
||||
if (!seq) {
|
||||
errors.push(`hotspot "${h.id}" targets unknown sequence "${h.target}"`);
|
||||
} else if (seq.slides.length === 0) {
|
||||
errors.push(`hotspot "${h.id}" targets empty 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,15 @@
|
||||
// packages/core/src/slideshow/sceneId.ts
|
||||
|
||||
/**
|
||||
* Whether a composition id names a "scene-like" composition — i.e. a real slide
|
||||
* scene, not the root timeline (`main`) or a non-scene overlay (captions, ambient
|
||||
* layers). Shared by the runtime scene-window computation and the slideshow lint
|
||||
* rule so the two can never drift.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// packages/core/src/slideshow/slideshow.types.ts
|
||||
|
||||
/** Current manifest schema version. Stamped on persist so future schema
|
||||
* changes can detect and migrate older islands. */
|
||||
export const SLIDESHOW_MANIFEST_VERSION = 1;
|
||||
|
||||
/** Raw author-facing shapes parsed from the JSON island. */
|
||||
export interface SlideshowManifest {
|
||||
/** Schema version (absent on pre-versioning islands → treat as 1). */
|
||||
version?: number;
|
||||
slides: SlideRef[];
|
||||
slideSequences?: SlideSequence[];
|
||||
}
|
||||
|
||||
export interface SlideRef {
|
||||
sceneId: string;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
notes?: string;
|
||||
fragments?: number[];
|
||||
hotspots?: SlideHotspot[];
|
||||
/**
|
||||
* When true, the slide's first `<video>` plays automatically on enter (the
|
||||
* presenter lands on the slide and the clip plays). The slideshow still holds
|
||||
* — it never auto-advances — so the presenter clicks Next when ready.
|
||||
* Defaults to false. Use it when the video is the slide's primary content and
|
||||
* its natural end is the cue to advance, not for background/ambient clips.
|
||||
*/
|
||||
autoplay?: boolean;
|
||||
// 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