diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09b97a54a..5a5eb68e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,8 +89,8 @@ jobs: - run: bun run build - run: bun run --filter '*' typecheck - test-core: - name: "Test: core" + test: + name: Test needs: changes if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest @@ -102,22 +102,7 @@ jobs: with: node-version: 22 - run: bun install --frozen-lockfile - - run: bun run --filter @hyperframes/core test -- --coverage - - test-engine: - name: "Test: engine" - needs: changes - if: needs.changes.outputs.code == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - run: bun install --frozen-lockfile - - run: bun run --filter @hyperframes/engine test + - run: bun run --filter '!@hyperframes/producer' test test-runtime-contract: name: "Test: runtime contract" diff --git a/bun.lock b/bun.lock index 2ea128e62..f7b88c711 100644 --- a/bun.lock +++ b/bun.lock @@ -7,8 +7,10 @@ "devDependencies": { "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", + "@hyperframes/player": "^0.3.0", "@types/node": "^25.0.10", "concurrently": "^8.2.0", + "happy-dom": "^20.9.0", "knip": "^6.0.3", "lefthook": "^2.1.4", "oxfmt": "^0.41.0", @@ -19,7 +21,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.2.4", + "version": "0.3.0", "bin": { "hyperframes": "./dist/cli.js", }, @@ -59,7 +61,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.2.4", + "version": "0.3.0", "dependencies": { "@chenglou/pretext": "^0.0.5", }, @@ -85,7 +87,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.2.4", + "version": "0.3.0", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -102,7 +104,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.2.4", + "version": "0.3.0", "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.0.0", @@ -111,7 +113,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.2.4", + "version": "0.3.0", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -149,7 +151,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.2.4", + "version": "0.3.0", "dependencies": { "html2canvas": "^1.4.1", }, @@ -161,7 +163,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.2.4", + "version": "0.3.0", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -731,6 +733,10 @@ "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "22.19.15" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "7.29.0", "@babel/plugin-transform-react-jsx-self": "7.27.1", "@babel/plugin-transform-react-jsx-source": "7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "7.20.5", "react-refresh": "0.17.0" }, "peerDependencies": { "vite": "5.4.21" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], @@ -1015,6 +1021,8 @@ "global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="], + "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -1453,7 +1461,7 @@ "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], - "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "1.15.0", "tr46": "6.0.0", "webidl-conversions": "8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], @@ -1535,6 +1543,8 @@ "chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -1551,6 +1561,8 @@ "jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "6.0.1" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], + "jsdom/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], diff --git a/package.json b/package.json index 9eca651ba..21c9beabe 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "lint:skills": "tsx scripts/lint-skills.ts", "lint:fix": "oxlint --fix .", "format": "oxfmt .", + "test": "bun run --filter '*' test", "format:check": "oxfmt --check .", "knip": "knip", "generate:previews": "tsx scripts/generate-template-previews.ts", @@ -30,8 +31,10 @@ "devDependencies": { "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", + "@hyperframes/player": "workspace:*", "@types/node": "^25.0.10", "concurrently": "^8.2.0", + "happy-dom": "^20.9.0", "knip": "^6.0.3", "lefthook": "^2.1.4", "oxfmt": "^0.41.0", diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index 4abd42d51..c3e0b6acd 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -13,7 +13,8 @@ function tmpProject(name: string): string { function validHtml(compId = "main"): string { return `
- + + `; } diff --git a/packages/cli/src/whisper/normalize.test.ts b/packages/cli/src/whisper/normalize.test.ts index 64ce635e9..0e4ea6862 100644 --- a/packages/cli/src/whisper/normalize.test.ts +++ b/packages/cli/src/whisper/normalize.test.ts @@ -153,8 +153,8 @@ How are you const { words, format } = loadTranscript(path); expect(format).toBe("srt"); expect(words).toEqual([ - { text: "Hello world", start: 1.0, end: 3.5 }, - { text: "How are you", start: 4.0, end: 6.0 }, + { text: "Hello world", start: 1.0, end: 3.5, id: "w0" }, + { text: "How are you", start: 4.0, end: 6.0, id: "w1" }, ]); }); @@ -171,8 +171,8 @@ How are you const { words, format } = loadTranscript(path); expect(format).toBe("vtt"); expect(words).toEqual([ - { text: "Hello world", start: 1.0, end: 3.5 }, - { text: "How are you", start: 4.0, end: 6.0 }, + { text: "Hello world", start: 1.0, end: 3.5, id: "w0" }, + { text: "How are you", start: 4.0, end: 6.0, id: "w1" }, ]); }); @@ -206,7 +206,10 @@ Short format const path = tmpFile("normalized.json", JSON.stringify(input)); const { words, format } = loadTranscript(path); expect(format).toBe("words-json"); - expect(words).toEqual(input); + expect(words).toEqual([ + { text: "Hello", start: 0, end: 0.5, id: "" }, + { text: "world", start: 0.6, end: 1.2, id: "" }, + ]); }); }); diff --git a/packages/player/README.md b/packages/player/README.md index 0798d45e3..62e6c346a 100644 --- a/packages/player/README.md +++ b/packages/player/README.md @@ -46,17 +46,33 @@ Show a static image before playback starts: ## Attributes -| Attribute | Type | Default | Description | -| --------------- | ------- | ------- | ------------------------------------------- | -| `src` | string | — | URL to the composition HTML file | -| `width` | number | 1920 | Composition width in pixels (aspect ratio) | -| `height` | number | 1080 | Composition height in pixels (aspect ratio) | -| `controls` | boolean | false | Show play/pause, scrubber, and time display | -| `muted` | boolean | false | Mute audio playback | -| `poster` | string | — | Image URL shown before playback starts | -| `playback-rate` | number | 1 | Speed multiplier (0.5 = half, 2 = double) | -| `autoplay` | boolean | false | Start playing when ready | -| `loop` | boolean | false | Restart when the composition ends | +| Attribute | Type | Default | Description | +| --------------- | ------- | ------- | -------------------------------------------- | +| `src` | string | — | URL to the composition HTML file | +| `audio-src` | string | — | Audio URL for parent-frame playback (mobile) | +| `width` | number | 1920 | Composition width in pixels (aspect ratio) | +| `height` | number | 1080 | Composition height in pixels (aspect ratio) | +| `controls` | boolean | false | Show play/pause, scrubber, and time display | +| `muted` | boolean | false | Mute audio playback | +| `poster` | string | — | Image URL shown before playback starts | +| `playback-rate` | number | 1 | Speed multiplier (0.5 = half, 2 = double) | +| `autoplay` | boolean | false | Start playing when ready | +| `loop` | boolean | false | Restart when the composition ends | + +### Mobile audio + +Mobile browsers block `audio.play()` inside iframes when the user gesture happened in the parent frame (the [User Activation spec](https://html.spec.whatwg.org/multipage/interaction.html#tracking-user-activation) does not propagate activation across frame boundaries via `postMessage`). + +The player handles this automatically for same-origin iframes (the default — `sandbox` includes `allow-same-origin`): + +1. When the composition is ready, the player extracts all timed media (`audio[data-start]`, `video[data-start]`) from the iframe DOM and creates parent-frame copies. +2. The iframe originals are disabled (`src` and `data-start` removed) so the runtime doesn't try to play them. +3. When `play()` is called (from a user gesture), parent media `.play()` runs synchronously in the gesture call stack, satisfying mobile autoplay policy. +4. Both parent media and the GSAP timeline start simultaneously and free-run — no active sync needed since both are real-time systems. + +No changes are required by consumers — this works out of the box. + +The optional `audio-src` attribute can be used to start preloading a primary audio track before the iframe loads (useful on slow connections), but is not required for mobile playback. ## JavaScript API diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 3841bc422..fdc72d6b8 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { formatTime, formatSpeed, SPEED_PRESETS } from "./controls.js"; +// ── Controls unit tests ── + describe("SPEED_PRESETS", () => { it("contains logarithmic speed steps", () => { expect(SPEED_PRESETS).toEqual([0.25, 0.5, 1, 1.5, 2, 4]); @@ -54,3 +56,142 @@ describe("formatTime", () => { expect(formatTime(-5)).toBe("0:00"); }); }); + +// ── Parent-frame media for mobile playback ── +// +// Mobile browsers block media.play() inside iframes when the user gesture +// happened in the parent. The player works around this by extracting media +// from the iframe and playing it in the parent frame. + +describe("HyperframesPlayer parent-frame media", () => { + type PlayerElement = HTMLElement & { + play: () => void; + pause: () => void; + seek: (t: number) => void; + }; + + let player: PlayerElement; + let mockAudio: { + src: string; + preload: string; + muted: boolean; + playbackRate: number; + currentTime: number; + paused: boolean; + play: ReturnType