mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* feat(slideshow): per-slide autoplay (manual-advance, opt-in) Adds an opt-in `autoplay` flag to slideshow slides: when the presenter lands on a video slide, its `<video>` plays from the start. The slideshow still holds and never auto-advances — the presenter clicks Next when ready. This covers compositions whose own controls can't be clicked (the player renders the composition pointer-events:none). Plumbing (done, tested): - core: `SlideRef.autoplay?: boolean`, parsed + validated in parseSlideshow (a non-boolean autoplay rejects the manifest); carried through resolve. - controller: optional `PlayerPort.playSceneMedia(sceneId)`, fired only on forward `enterSlide` for autoplay slides (not resume/back/sync, so the audience — which mirrors the presenter's media events — isn't double-driven). - component: `playSceneDocumentMedia` reaches the same-origin composition iframe, finds the scene's `<video>`, and asserts playback; `stopMedia` (already wired on slide change) resets it. An autoplay token cancels a pending start when the slide changes. - tests: controller autoplay behavior + parser flag round-trip/validation (131 player + 22 core slideshow tests pass). KNOWN LIMITATION — runtime media-start needs the player media model (@vance): On current main the clip<->timeline binding from #1601 keeps every clip synced and *paused* to the held timeline frame, which wins against playSceneMedia's play() — so the clip does not actually start on main yet (it does on the pre-#1601 player). The correct fix is a sanctioned "let this clip free-run while the timeline holds" path in the player/runtime media controller. Flagging for Vance to wire the start into the #1601 media model (or rebase onto it) when back. The plumbing above is the stable surface that hook plugs into. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(slideshow): address code-review on per-slide autoplay - guard playSceneDocumentMedia behind resolveMode() !== "audience": the audience mirrors the presenter's media events, so it must not independently drive its own copy of the clip. - drop the per-enter window pointerdown/keydown "gesture retry" listeners, which leaked when muted autoplay succeeded without a gesture. The poll already re-asserts play(), so a gesture within the window is picked up next tick. - stop polling once the clip is advancing across two ticks (was re-asserting play() for the full window even after playback was confirmed). - cancel any in-flight autoplay loop on disconnectedCallback (bump the token). - split the poll into findSceneVideo + stepAutoplay helpers (keeps each small). - fix the enterSlide comment: autoplay fires from enterSlide (next/prev/ goToSlide), not resumeSlide (back/backToMain/syncTo). - parser: isOptionalBoolean type guard instead of a one-off helper; drop `as` assertions in the new controller test. 131 player + 22 core slideshow tests pass; lint/format/typecheck/fallow clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(slideshow): autoplay skill guidance + address review nits Addresses review feedback on #1708: - skill: document per-slide `autoplay` in the slideshow standalone-harness reference — when to use it (video is the slide's primary content, its end is the advance cue) vs not (background/ambient loops, footage talked over), per Vance's guidance, before merge. - play() rejection is no longer blanket-swallowed: AbortError (timeline-sync seek interrupt) and NotAllowedError (gesture-gated autoplay) are expected and ignored; any other rejection is surfaced once via console.warn (Via nit 1). - clarify in the SlideRef.autoplay doc that it plays the scene's FIRST <video> (Via nit 2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
219 lines
8.7 KiB
TypeScript
219 lines
8.7 KiB
TypeScript
// 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();
|
|
});
|
|
});
|