Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/cli/src/utils/compositionServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// composition asset files, and binding to a free port.
import { existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";

/** Minimal surface of a listening server (satisfied by @hono/node-server's ServerType). */
interface PortBindable {
Expand All @@ -15,7 +16,9 @@ interface PortBindable {
}

function helperDir(): string {
return dirname(new URL(import.meta.url).pathname);
// fileURLToPath (not URL.pathname) so the Windows "/D:/..." leading-slash form
// doesn't break the bundle-path resolution below.
return dirname(fileURLToPath(import.meta.url));
}

export function resolveRuntimePath(): string | null {
Expand Down
11 changes: 1 addition & 10 deletions packages/core/src/lint/rules/slideshow.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
// 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";
import { isSceneLikeCompositionId } from "../../slideshow/sceneId";

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");
if (startStr === null) return null;
Expand Down
8 changes: 1 addition & 7 deletions packages/core/src/runtime/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
import { swallow } from "./diagnostics";
import { readElementPlaybackRate } from "./media";
import { createRuntimeStartTimeResolver } from "./startResolver";
import { isSceneLikeCompositionId } from "../slideshow/sceneId";

const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";
Expand Down Expand Up @@ -230,13 +231,6 @@ export function collectRuntimeTimelinePayload(params: {
}
return maxWindowEndSeconds > 0 ? maxWindowEndSeconds : null;
};
const 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;
};
const resolveNearestCompositionContext = (
node: Element,
root: Element | null,
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/slideshow/parseSlideshow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ describe("parseSlideshowManifest", () => {
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 }] }
Expand Down Expand Up @@ -74,6 +79,18 @@ describe("resolveSlideshow", () => {
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] }],
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/slideshow/parseSlideshow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function isSlideSequence(v: unknown): boolean {
}

function isManifest(v: unknown): v is SlideshowManifest {
if (typeof v !== "object" || v === null) return false;
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) {
Expand Down Expand Up @@ -158,6 +158,10 @@ export function resolveSlideshow(

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,
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/slideshow/sceneId.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 5 additions & 4 deletions packages/player/src/slideshow/hyperframes-slideshow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { handleRuntimeMessage } from "../runtime-message-handler.js";
import { dropInvalidSlides } from "./hyperframes-slideshow.js";
import { slideshowChannelName } from "./slideshowPresenter.js";

// Dynamic import defers custom-element registration until happy-dom is active.
// (Static top-level imports execute before the test environment is set up, which
Expand Down Expand Up @@ -524,7 +525,7 @@ describe("<hyperframes-slideshow> presenter mode", () => {
const tick = () => new Promise<void>((r) => setTimeout(r, 0));

it("audience mode: mirrors full position (sequence + slide + fragment) via syncTo", async () => {
const presenterChannel = new BroadcastChannel("hf-slideshow");
const presenterChannel = new BroadcastChannel(slideshowChannelName());
const { el, getLastSync } = makeAudienceEl();

await tick();
Expand All @@ -542,7 +543,7 @@ describe("<hyperframes-slideshow> presenter mode", () => {
});

it("audience mode: mirrors a branch position too (full sequenceId forwarded to syncTo)", async () => {
const presenterChannel = new BroadcastChannel("hf-slideshow");
const presenterChannel = new BroadcastChannel(slideshowChannelName());
const { el, getLastSync } = makeAudienceEl();

await tick();
Expand All @@ -566,7 +567,7 @@ describe("<hyperframes-slideshow> presenter mode", () => {

it("presenter mode: posts position to channel on controller onChange", async () => {
const received: unknown[] = [];
const listenerChannel = new BroadcastChannel("hf-slideshow");
const listenerChannel = new BroadcastChannel(slideshowChannelName());
listenerChannel.onmessage = (e: MessageEvent) => received.push(e.data);

const { el, triggerChange } = makePresenterEl();
Expand Down Expand Up @@ -608,7 +609,7 @@ describe("<hyperframes-slideshow> presenter mode", () => {
await tick();

const received: unknown[] = [];
const spy = new BroadcastChannel("hf-slideshow");
const spy = new BroadcastChannel(slideshowChannelName());
spy.onmessage = (e: MessageEvent) => received.push(e.data);

el.remove(); // triggers disconnectedCallback
Expand Down
33 changes: 31 additions & 2 deletions packages/player/src/slideshow/hyperframes-slideshow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@ export class HyperframesSlideshow extends HTMLElement {
}
}

// Observe the attributes the component reads so runtime toggles take effect.
static get observedAttributes(): string[] {
return ["sound", "mode"];
}

attributeChangedCallback(): void {
// Re-render once bound so a flipped `sound`/`mode` is reflected (mute button,
// audience-vs-presenter chrome). No-op before the controller binds.
if (this.controller) this.render();
}

connectedCallback(): void {
this.disconnected = false;
this.initInFlight = false;
Expand Down Expand Up @@ -178,7 +189,9 @@ export class HyperframesSlideshow extends HTMLElement {
*/
present(): void {
const sep = location.search ? "&" : "?";
window.open(location.href + sep + "mode=audience", "_blank");
// noopener,noreferrer: the audience window must not get a reference back to
// this window (it syncs over BroadcastChannel, not window.opener).
window.open(location.href + sep + "mode=audience", "_blank", "noopener,noreferrer");
this.setAttribute("data-hf-presenting", "true");
this.presenterStartMs = Date.now();
if (this.presenterInterval === null) {
Expand Down Expand Up @@ -278,6 +291,19 @@ export class HyperframesSlideshow extends HTMLElement {
};

this.bindController(new SlideshowController(port, cleaned));

// Slow-iframe recovery: if the scene timeline hadn't posted yet (empty
// scenes → sceneId-based slides were dropped), re-init once when it finally
// arrives so those slides resolve instead of being permanently lost.
if (scenes.length === 0 && manifest.slides.length > 0) {
playerEl.addEventListener(
"scenes",
() => {
if (gen === this.initGeneration) void this.init();
},
{ once: true },
);
}
} finally {
this.initInFlight = false;
}
Expand Down Expand Up @@ -318,7 +344,10 @@ export class HyperframesSlideshow extends HTMLElement {
// Arrows act even when nothing is focused (active === body/null) so a freshly
// loaded deck responds without a click; Space/Backspace have strong page-level
// defaults (scroll / history) so they only act when the deck actually has focus.
const ambient = focused || active === document.body || active === null;
// When several decks share a page, drop the unfocused-convenience so a key
// doesn't drive every instance at once — only the focused deck responds.
const multiInstance = document.querySelectorAll("hyperframes-slideshow").length > 1;
const ambient = focused || (!multiInstance && (active === document.body || active === null));
if (e.key === "ArrowRight") {
if (!ambient) return;
this.controller.next();
Expand Down
12 changes: 11 additions & 1 deletion packages/player/src/slideshow/slideshowPresenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ function isGotoMessage(data: unknown): data is GotoMessage {
* Presenter (default) mode: posts position updates to the channel.
* Audience mode: listens for goto messages and calls the provided handler.
*/
/**
* Per-deck channel name. The presenter and its audience window load the same URL
* (path), so keying on pathname keeps them paired while isolating other decks
* presenting on the same origin (which would otherwise cross-talk on a fixed name).
*/
export function slideshowChannelName(): string {
const path = typeof location !== "undefined" ? location.pathname : "";
return `hf-slideshow:${path}`;
}

export class SlideshowChannel {
private channel: BroadcastChannel | null = null;

Expand All @@ -35,7 +45,7 @@ export class SlideshowChannel {
private readonly onGoto: (msg: GotoMessage) => void,
) {
try {
this.channel = new BroadcastChannel("hf-slideshow");
this.channel = new BroadcastChannel(slideshowChannelName());
} catch {
// BroadcastChannel unavailable (e.g. unsupported env); degrade silently.
return;
Expand Down
7 changes: 5 additions & 2 deletions packages/player/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { defineConfig } from "vitest/config";
import { resolve } from "path";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";

const coreRoot = resolve(new URL("..", import.meta.url).pathname, "core/src");
// fileURLToPath (not URL.pathname): on Windows .pathname yields "/D:/..." with a
// leading slash, which breaks resolve() and the alias below.
const coreRoot = resolve(fileURLToPath(new URL("../core/src", import.meta.url)));

export default defineConfig({
resolve: {
Expand Down
5 changes: 4 additions & 1 deletion packages/producer/src/utils/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
relative as nodeRelative,
isAbsolute as nodeIsAbsolute,
} from "node:path";
import { fileURLToPath } from "node:url";

export interface RenderPaths {
absoluteProjectDir: string;
Expand All @@ -17,7 +18,9 @@ export interface RenderPaths {

const DEFAULT_RENDERS_DIR =
process.env.PRODUCER_RENDERS_DIR ??
nodeResolve(new URL(import.meta.url).pathname, "../../..", "renders");
// fileURLToPath (not URL.pathname): on Windows .pathname is "/D:/..." which
// resolves to a bogus renders dir.
nodeResolve(fileURLToPath(import.meta.url), "../../..", "renders");

type PathModuleLike = {
resolve: (...segments: string[]) => string;
Expand Down
24 changes: 24 additions & 0 deletions packages/studio/src/components/panels/SlideshowPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
toggleMainLineSlide,
reorderMainLineSlide,
reorderBranchSlide,
setSlideNotes,
addFragment,
removeFragment,
Expand Down Expand Up @@ -67,6 +68,29 @@ describe("reorderMainLineSlide", () => {
});
});

// ── reorderBranchSlide ─────────────────────────────────────────────────────
describe("reorderBranchSlide", () => {
const base: SlideshowManifest = {
slides: [{ sceneId: "x" }],
slideSequences: [
{ id: "b1", label: "Branch", slides: [{ sceneId: "a" }, { sceneId: "b" }, { sceneId: "c" }] },
],
};

it("moves a branch slide down within its sequence (main line untouched)", () => {
const m = reorderBranchSlide(base, "b1", "a", "down");
expect(m.slideSequences?.[0].slides.map((s) => s.sceneId)).toEqual(["b", "a", "c"]);
expect(m.slides).toEqual(base.slides);
});

it("is a no-op at the boundary and for an unknown branch/scene", () => {
expect(reorderBranchSlide(base, "b1", "a", "up").slideSequences?.[0].slides[0].sceneId).toBe(
"a",
);
expect(reorderBranchSlide(base, "nope", "a", "down")).toEqual(base);
});
});

// ── setSlideNotes ──────────────────────────────────────────────────────────

describe("setSlideNotes", () => {
Expand Down
Loading
Loading