mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
Merge remote-tracking branch 'origin/main' into feat/capture-improvements-v2
This commit is contained in:
@@ -1,2 +1,8 @@
|
||||
# Golden baseline videos for regression tests
|
||||
packages/producer/tests/*/output/output.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# GitHub Linguist overrides — HTML files are compositions (user content / templates),
|
||||
# not the framework source. Hide them from the repo language stats so TypeScript,
|
||||
# which is the actual implementation, surfaces as the dominant language.
|
||||
registry/**/*.html linguist-vendored
|
||||
*.html linguist-detectable=false
|
||||
|
||||
@@ -70,6 +70,8 @@ The browser preview and the rendered MP4 should match. Hyperframes achieves this
|
||||
- **Producer-canonical behavior** -- the [producer's](/packages/producer) seek semantics are the source of truth
|
||||
- **Readiness gates** -- `__playerReady` and `__renderReady` ensure the [composition](/concepts/compositions) is fully loaded before any frame is captured
|
||||
|
||||
Parity here means **visual fidelity** — every frame looks the same. It does *not* mean performance parity. Preview plays in real time in a browser, so frame-rate limits are bound by your hardware. Render is seek-driven and frame-at-a-time, so it never drops frames regardless of per-frame cost. A composition can stutter in preview and render perfectly. See [Performance](/guides/performance) for why.
|
||||
|
||||
<Note>
|
||||
Local rendering (without Docker) may show slight differences due to platform-specific font rendering and Chrome version. Use Docker mode when exact reproducibility matters.
|
||||
</Note>
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"guides/prompting",
|
||||
"guides/gsap-animation",
|
||||
"guides/rendering",
|
||||
"guides/performance",
|
||||
"guides/common-mistakes",
|
||||
"guides/troubleshooting"
|
||||
]
|
||||
|
||||
@@ -120,6 +120,75 @@ These are mistakes that cannot be caught by the linter. For automated checks, ru
|
||||
</Note>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Oversized source images">
|
||||
**Symptom:** Preview stutters during scenes with images on screen. Render is slower than expected.
|
||||
|
||||
**Cause:** Source images at much higher resolution than the canvas. Chrome decodes images to raw RGBA bitmaps before displaying them, and bitmap size is `width × height × 4` bytes — independent of file size on disk. A 7000×5000 JPEG is 140MB decoded, even if the file is only 2MB.
|
||||
|
||||
Displaying such an image in a 384×1080 region wastes memory and forces the compositor to resample a huge texture every frame.
|
||||
|
||||
**Before (bloated):**
|
||||
|
||||
```html index.html
|
||||
<!-- 7000x5000 source, ~140MB decoded -->
|
||||
<img class="clip" data-start="0" data-duration="3"
|
||||
src="./assets/hero-scene.jpg" />
|
||||
```
|
||||
|
||||
**After (sized to the canvas):**
|
||||
|
||||
```bash Terminal
|
||||
# Resize a batch of images to fit within 3840x3840, preserving aspect ratio
|
||||
mkdir -p assets/resized
|
||||
mogrify -path assets/resized -resize 3840x3840\> assets/*.jpg
|
||||
```
|
||||
|
||||
```html index.html
|
||||
<!-- ~3840x2560 source, ~40MB decoded -->
|
||||
<img class="clip" data-start="0" data-duration="3"
|
||||
src="./assets/resized/hero-scene.jpg" />
|
||||
```
|
||||
|
||||
**Rule of thumb:** source images at most 2x the canvas dimensions. For a 1920×1080 composition, 3840×2160 is already plenty. See [Performance: Image sizing](/guides/performance#image-sizing).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Heavy backdrop-filter stacks">
|
||||
**Symptom:** Specific scenes drop to 5-10fps in preview. The composition is fine elsewhere.
|
||||
|
||||
**Cause:** `backdrop-filter: blur()` on large elements, especially stacked at high radii. Each blur layer forces the compositor to sample pixels behind the element, run a blur kernel, and composite the result. Stacked layers multiply the cost.
|
||||
|
||||
**Before (expensive):**
|
||||
|
||||
```css
|
||||
/* 8 layers per side = 16 blur passes every frame */
|
||||
.pb-1 { backdrop-filter: blur(1px); }
|
||||
.pb-2 { backdrop-filter: blur(2px); }
|
||||
.pb-3 { backdrop-filter: blur(4px); }
|
||||
.pb-4 { backdrop-filter: blur(8px); }
|
||||
.pb-5 { backdrop-filter: blur(16px); }
|
||||
.pb-6 { backdrop-filter: blur(32px); }
|
||||
.pb-7 { backdrop-filter: blur(64px); }
|
||||
.pb-8 { backdrop-filter: blur(128px); }
|
||||
```
|
||||
|
||||
**After (3 tuned layers):**
|
||||
|
||||
```css
|
||||
/* Fewer passes with hand-picked radii — visually similar, much cheaper */
|
||||
.pb-1 { backdrop-filter: blur(4px); }
|
||||
.pb-2 { backdrop-filter: blur(16px); }
|
||||
.pb-3 { backdrop-filter: blur(48px); }
|
||||
```
|
||||
|
||||
**Guidelines:**
|
||||
|
||||
- Keep stacked `backdrop-filter` layers to 2-3 per region
|
||||
- Avoid radii above 64px over large areas — the biggest radii dominate the total cost
|
||||
- For a static blur effect, pre-render it into a PNG once and overlay with a regular `<img>`
|
||||
|
||||
See [Performance: backdrop-filter: blur()](/guides/performance#backdrop-filter-blur) for the full breakdown.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Timeline key doesn't match data-composition-id">
|
||||
**Symptom:** Animations don't play. The composition appears static.
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: Performance
|
||||
description: "How to keep preview playback smooth and diagnose expensive compositions."
|
||||
---
|
||||
|
||||
Preview plays your composition in real time, so any frame that takes longer than 33ms (at 30fps) shows up as stutter. This page covers the patterns that blow that budget and how to spot them.
|
||||
|
||||
## Preview vs. render
|
||||
|
||||
Render captures frames one at a time and stitches them into a video. Slow frames make the render take longer, but you never see the pauses — you watch the finished mp4.
|
||||
|
||||
Preview does the same work in real time. If a frame takes 200ms to paint, you see a 200ms freeze.
|
||||
|
||||
This is why "render looks fine, preview stutters" is expected for paint-heavy compositions. It doesn't mean preview is broken — it means individual frames are too expensive for real-time playback.
|
||||
|
||||
## Expensive CSS patterns
|
||||
|
||||
These are the patterns that most often cause preview to drop below 30fps.
|
||||
|
||||
### backdrop-filter: blur()
|
||||
|
||||
Each `backdrop-filter: blur(radius)` sampled over a large area forces the compositor to read pixels from behind the element, run a blur kernel across them, and composite the result. Cost scales with both the blurred area and the radius.
|
||||
|
||||
Stacked blur layers multiply the cost. Eight layers at progressively larger radii (1, 2, 4, 8, 16, 32, 64, 128px) will happily take 200ms per frame over a 1920x1080 region on mid-tier GPUs.
|
||||
|
||||
**What to do:**
|
||||
- Keep stacked layers to 2-3 maximum, with manually tuned radii
|
||||
- Avoid `blur(128px)` or `blur(64px)` over large areas — the biggest radii dominate the cost
|
||||
- For a static blur, render it once into a PNG and use a regular `<img>` overlay
|
||||
|
||||
### filter: blur() and filter: drop-shadow()
|
||||
|
||||
Same story as `backdrop-filter` but applied to the element itself rather than behind it. Fine on small elements, expensive on large ones.
|
||||
|
||||
### Shadows on many elements
|
||||
|
||||
`box-shadow` and `text-shadow` on a few elements are fine. On dozens of elements that also animate, the compositor re-rasterizes each shadowed layer on every frame.
|
||||
|
||||
### Large gradients with mask-image
|
||||
|
||||
Combined with `backdrop-filter`, `mask-image` can force additional compositor passes. If you have both on the same element, consider whether you need both.
|
||||
|
||||
## Image sizing
|
||||
|
||||
Image source resolution matters more than file size. Chrome decodes JPEGs and PNGs to raw RGBA bitmaps before displaying them — a decoded bitmap is:
|
||||
|
||||
```
|
||||
bitmap_bytes = width × height × 4
|
||||
```
|
||||
|
||||
A 7000×5000 source image is 140MB decoded, regardless of whether the JPEG on disk is 2MB or 5MB.
|
||||
|
||||
**Rule of thumb:** resize source images to at most 2x the canvas dimensions. For a 1920x1080 canvas, 3840x2160 source images are already overkill. Anything above that is paying for memory and texture-upload cost that never shows on screen.
|
||||
|
||||
```bash Terminal
|
||||
# ImageMagick one-liner to downsize a directory of images
|
||||
mogrify -path resized -resize 3840x3840\> *.jpg
|
||||
```
|
||||
|
||||
## Measuring a slow composition
|
||||
|
||||
Don't guess — measure. Chrome DevTools has everything you need.
|
||||
|
||||
<Steps>
|
||||
<Step title="Run preview">
|
||||
Start the preview server and open it in Chrome:
|
||||
|
||||
```bash Terminal
|
||||
npx hyperframes preview
|
||||
```
|
||||
</Step>
|
||||
<Step title="Open DevTools → Performance">
|
||||
`Cmd+Option+I` (macOS) or `Ctrl+Shift+I` (Linux/Windows), then switch to the **Performance** tab.
|
||||
</Step>
|
||||
<Step title="Record during playback">
|
||||
Hit the record button, click play in the preview, let it run 3-5 seconds through the jank-prone scene, then stop recording.
|
||||
</Step>
|
||||
<Step title="Read the main thread track">
|
||||
Look for long tasks (red-flagged in the timeline). Expand the tallest bars and check what Chrome labels them:
|
||||
|
||||
- **Composite Layers / Paint** with a large duration = compositor cost (backdrop-filter, shadows, large textures)
|
||||
- **Decode Image** = image decode on first paint (rare in Chrome 131+, images decode off-thread by default)
|
||||
- **Layout / Recalculate Style** = layout thrashing from script
|
||||
- **Script** = JS work (rare for compositions, check author scripts)
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Once you know which category dominates, you know what to change.
|
||||
|
||||
<Tip>
|
||||
A composition that runs at 60fps in isolation but stutters only during specific scenes is usually a composite-cost problem. Check which layers become visible during those scenes.
|
||||
</Tip>
|
||||
|
||||
## When preview is unavoidable slow
|
||||
|
||||
Some compositions are legitimately too expensive for real-time playback. If you've reduced what you can and preview still stutters, render-to-mp4 and watch the output is a fine workflow — render is still accurate.
|
||||
|
||||
```bash Terminal
|
||||
npx hyperframes render --quality draft --output preview.mp4
|
||||
```
|
||||
|
||||
Draft quality renders fast and is visually close to the final render for everything except encoder-level detail.
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Troubleshooting" icon="wrench" href="/guides/troubleshooting">
|
||||
Environment, tooling, and rendering issues
|
||||
</Card>
|
||||
<Card title="Common Mistakes" icon="triangle-exclamation" href="/guides/common-mistakes">
|
||||
Composition pitfalls that break rendering
|
||||
</Card>
|
||||
<Card title="Rendering" icon="film" href="/guides/rendering">
|
||||
Rendering modes, options, and flags
|
||||
</Card>
|
||||
<Card title="CLI Reference" icon="terminal" href="/packages/cli">
|
||||
Full list of CLI commands
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -71,6 +71,31 @@ If your issue is about a specific coding mistake (animations not working, video
|
||||
4. Clear the browser cache if CSS changes are not reflected
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Preview stutters or plays at a low frame rate">
|
||||
**Symptom:** Preview playback is jerky or skips frames, but the rendered mp4 looks fine.
|
||||
|
||||
**Cause:** Individual frames are taking longer than 16-33ms to paint. Render hides this (it captures frames one at a time), preview does not.
|
||||
|
||||
**Common culprits, most to least frequent:**
|
||||
|
||||
- Stacked `backdrop-filter: blur()` layers, especially at radii above 32px
|
||||
- Source images at very high resolution (above 4K) displayed in small regions
|
||||
- `filter: blur()` or `filter: drop-shadow()` on large elements
|
||||
- Many elements with `box-shadow` or `text-shadow` that also animate
|
||||
|
||||
**First thing to check:** does the stutter happen only during specific scenes, or throughout? Scene-specific stutter usually points at an element, often a blur overlay, that becomes visible in that scene.
|
||||
|
||||
**How to diagnose:** open Chrome DevTools, switch to the Performance tab, record a few seconds of playback, and look for long tasks labeled "Composite Layers" or "Paint". See [Performance: Measuring a slow composition](/guides/performance#measuring-a-slow-composition) for the full walkthrough.
|
||||
|
||||
**Temporary workaround:** render to mp4 and watch the output. Render is accurate regardless of per-frame cost.
|
||||
|
||||
```bash Terminal
|
||||
npx hyperframes render --quality draft --output preview.mp4
|
||||
```
|
||||
|
||||
See [Performance](/guides/performance) for the full guide on expensive CSS patterns and how to fix them.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Render looks different from preview">
|
||||
Use `--docker` mode for [deterministic output](/concepts/determinism). Local renders may differ due to:
|
||||
|
||||
|
||||
@@ -373,6 +373,10 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
|
||||
|
||||
Opens your composition in the Hyperframes Studio with live preview. Edits to `index.html` and any referenced sub-compositions are reflected automatically. The preview uses the same Hyperframes runtime as production rendering, so what you see is what you get.
|
||||
|
||||
<Note>
|
||||
Visual output matches render exactly. Playback *performance* does not: preview plays in real time in your browser, so paint-heavy compositions (large images, stacked `backdrop-filter` layers, many shadowed elements) may stutter depending on your hardware. The rendered mp4 is always accurate regardless — render captures frames one at a time, so per-frame cost shows up as longer render duration, not dropped frames. See [Performance](/guides/performance) for details.
|
||||
</Note>
|
||||
|
||||
The preview server runs in three modes, auto-detected:
|
||||
|
||||
1. **Embedded mode** (default for `npx`) — runs a standalone server with the studio bundled in the CLI. Zero extra dependencies.
|
||||
@@ -630,6 +634,26 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
|
||||
| `--cursor` | Install to Cursor (`.cursor/skills/` in current project) |
|
||||
|
||||
Skills are fetched from GitHub and include composition authoring, GSAP animation patterns, registry block/component wiring, and other domain-specific knowledge. The `init` command also offers to install skills automatically after scaffolding a project.
|
||||
|
||||
#### Troubleshooting: `fatal: active post-checkout hook found during git clone`
|
||||
|
||||
If you installed Git LFS globally (`git lfs install`), Git 2.45+ refuses to run the LFS post-checkout hook during any `git clone` — including the clone the upstream `skills` CLI performs under the hood. The error looks like:
|
||||
|
||||
```
|
||||
■ Failed to clone repository
|
||||
fatal: active `post-checkout` hook found during `git clone`
|
||||
└ Installation failed
|
||||
```
|
||||
|
||||
**Using `hyperframes skills` is already fine** — as of v0.4.5 the CLI sets `GIT_CLONE_PROTECTION_ACTIVE=0` on the child environment, which is the opt-in knob Git provides for exactly this case. You don't need to do anything.
|
||||
|
||||
**If you ran `npx skills add heygen-com/hyperframes` directly** (bypassing the HyperFrames CLI), set the env var yourself:
|
||||
|
||||
```bash
|
||||
GIT_CLONE_PROTECTION_ACTIVE=0 npx skills add heygen-com/hyperframes
|
||||
```
|
||||
|
||||
This is tracked in [GH #316](https://github.com/heygen-com/hyperframes/issues/316). An upstream fix in the `skills` CLI itself is the right long-term answer; until that lands, the env var is the correct workaround.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -252,6 +252,30 @@ bun run benchmark
|
||||
|
||||
The benchmark runs several compositions with different quality and FPS settings and reports timing for each combination.
|
||||
|
||||
## External assets (files outside `projectDir`)
|
||||
|
||||
A composition can reference absolute paths to assets outside the project
|
||||
directory — a local voiceover in `~/Downloads`, a shared-drive image, a
|
||||
generated fixture at an absolute path. The producer handles these by:
|
||||
|
||||
1. **Detection.** During compilation, the HTML compiler walks every
|
||||
`[src]` / `[href]` and every `url(...)` in `<style>`. A path that
|
||||
resolves to a file outside `projectDir` is collected into an
|
||||
`externalAssets` map.
|
||||
2. **Sanitised keys.** Each absolute path is converted into a safe,
|
||||
cross-platform relative key prefixed with `hf-ext/`. Windows
|
||||
drive-letter colons are stripped (`D:\foo\x.wav` → `hf-ext/D/foo/x.wav`)
|
||||
so that `path.join(compileDir, key)` stays inside the compile
|
||||
directory on every OS.
|
||||
3. **Copy + rewrite.** The orchestrator copies the file under
|
||||
`<compileDir>/hf-ext/...` and the HTML is rewritten to point at the
|
||||
sanitised key. The file server then serves both project-internal and
|
||||
external assets from the same root.
|
||||
|
||||
The containment check uses `path.relative()` rather than a hardcoded
|
||||
separator, so external assets work identically on macOS, Linux, and
|
||||
Windows. See `packages/producer/src/utils/paths.ts` for the helpers.
|
||||
|
||||
## Related Packages
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
@@ -185,6 +185,10 @@ The studio renders your composition in an iframe using the Hyperframes runtime.
|
||||
|
||||
Changes to your HTML are picked up automatically through hot reload, so you can edit `index.html` in your editor and see the result in the browser within milliseconds.
|
||||
|
||||
<Note>
|
||||
The *visual* output of preview matches render exactly. Real-time *playback smoothness* depends on your hardware, because preview actually plays the composition in your browser at 30/60fps. Render doesn't have that constraint — it captures each frame individually via a seek-driven pipeline, so expensive frames make the render slower but never drop. If you see stutter in preview but the rendered mp4 is clean, that's expected. See [Performance](/guides/performance) for the patterns that most often cause it.
|
||||
</Note>
|
||||
|
||||
### Timeline View
|
||||
|
||||
The timeline panel provides a visual representation of your composition's structure:
|
||||
|
||||
+3
-1
@@ -6,7 +6,9 @@ pre-commit:
|
||||
run: bunx oxlint {staged_files}
|
||||
format:
|
||||
glob: "*.{js,jsx,ts,tsx,json,md,yaml,yml}"
|
||||
run: bunx oxfmt --check {staged_files}
|
||||
# --no-error-on-unmatched-pattern: don't fail when staged files all
|
||||
# fall under .prettierignore (e.g. docs-only changes to docs/docs.json).
|
||||
run: bunx oxfmt --check --no-error-on-unmatched-pattern {staged_files}
|
||||
typecheck:
|
||||
glob: "*.{ts,tsx}"
|
||||
run: cd packages/core && bunx tsc --noEmit && cd ../studio && bunx tsc --noEmit
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/cli",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.5",
|
||||
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -6,7 +6,7 @@ export const examples: Example[] = [["Check system dependencies", "hyperframes d
|
||||
import { freemem, platform } from "node:os";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { findBrowser } from "../browser/manager.js";
|
||||
import { findFFmpeg } from "../browser/ffmpeg.js";
|
||||
import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { getUpdateMeta } from "../utils/updateCheck.js";
|
||||
import { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "../telemetry/system.js";
|
||||
@@ -36,19 +36,22 @@ function checkFFmpeg(): CheckResult {
|
||||
return {
|
||||
ok: false,
|
||||
detail: "Not found",
|
||||
hint: process.platform === "darwin" ? "brew install ffmpeg" : "sudo apt install ffmpeg",
|
||||
hint: getFFmpegInstallHint(),
|
||||
};
|
||||
}
|
||||
|
||||
function checkFFprobe(): CheckResult {
|
||||
// `ffprobe -version` works cross-platform if it's on PATH — no need for
|
||||
// `which`/`where` shell detection, which differs by OS.
|
||||
try {
|
||||
const result = execSync("which ffprobe", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||
return { ok: true, detail: result };
|
||||
const version =
|
||||
execSync("ffprobe -version", { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ?? "";
|
||||
return { ok: true, detail: version.trim() };
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
detail: "Not found",
|
||||
hint: "Installed with ffmpeg",
|
||||
hint: `Installed with ffmpeg — ${getFFmpegInstallHint()}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// ESM forbids `vi.spyOn` on live module exports, so we mock
|
||||
// `node:child_process` at the loader level and inspect the spawned
|
||||
// child's env.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
type SpawnCall = {
|
||||
command: string;
|
||||
args: ReadonlyArray<string>;
|
||||
env: NodeJS.ProcessEnv | undefined;
|
||||
};
|
||||
|
||||
const state: { calls: SpawnCall[] } = { calls: [] };
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFileSync: vi.fn(() => Buffer.from("11.0.0")),
|
||||
spawn: vi.fn(
|
||||
(command: string, args: ReadonlyArray<string>, opts?: { env?: NodeJS.ProcessEnv }) => {
|
||||
state.calls.push({ command, args, env: opts?.env });
|
||||
const fake = new EventEmitter();
|
||||
setImmediate(() => fake.emit("close", 0, null));
|
||||
return fake;
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
describe("hyperframes skills", () => {
|
||||
beforeEach(() => {
|
||||
state.calls = [];
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("sets GIT_CLONE_PROTECTION_ACTIVE=0 on the spawned skills CLI child (GH #316)", async () => {
|
||||
const { default: skillsCmd } = await import("./skills.js");
|
||||
await skillsCmd.run?.({ args: {}, rawArgs: [], cmd: skillsCmd } as never);
|
||||
|
||||
const first = state.calls[0];
|
||||
expect(first).toBeDefined();
|
||||
expect(first!.command).toBe("npx");
|
||||
expect(first!.args).toContain("skills");
|
||||
expect(first!.args).toContain("add");
|
||||
expect(first!.env?.GIT_CLONE_PROTECTION_ACTIVE).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,14 @@ function runSkillsAdd(repo: string): Promise<void> {
|
||||
const child = spawn("npx", ["skills", "add", repo, "--all"], {
|
||||
stdio: "inherit",
|
||||
timeout: 120_000,
|
||||
// GH #316 — the upstream `skills` CLI shells out to `git clone`.
|
||||
// When Git's clone-hook protection is active (shipped on by
|
||||
// default in 2.45.1, reverted in 2.45.2, still present on many
|
||||
// corporate and CI setups), any globally-registered
|
||||
// `git lfs install` post-checkout hook aborts the clone. The
|
||||
// `repo` reaching this function is hardcoded in SOURCES below
|
||||
// — no user input reaches the spawn — so opting out here is safe.
|
||||
env: { ...process.env, GIT_CLONE_PROTECTION_ACTIVE: "0" },
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createServer, type Server } from "node:net";
|
||||
import { PORT_PROBE_HOSTS, testPortOnAllHosts } from "./portUtils.js";
|
||||
|
||||
// High-ephemeral range with runway so parallel test shards don't collide.
|
||||
const BASE = 45_000;
|
||||
|
||||
const openServers: Server[] = [];
|
||||
|
||||
function allocFreePort(): number {
|
||||
return BASE + Math.floor(Math.random() * 1_000);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
openServers.splice(0).map(
|
||||
(s) =>
|
||||
new Promise<void>((resolve) => {
|
||||
s.close(() => resolve());
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("testPortOnAllHosts — real-socket behaviour (OS-dependent)", () => {
|
||||
// These exercise the real network stack. On Linux the buggy parallel
|
||||
// implementation reliably fails the first test (issue #309 repro); on
|
||||
// macOS the race is not deterministic so both old and new code pass
|
||||
// here. The sequential-contract test below is the platform-agnostic
|
||||
// regression gate.
|
||||
|
||||
it("returns true for a genuinely free port (regression: #309)", async () => {
|
||||
const port = allocFreePort();
|
||||
const result = await testPortOnAllHosts(port);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the port is occupied on 0.0.0.0", async () => {
|
||||
const port = allocFreePort();
|
||||
const blocker = createServer();
|
||||
openServers.push(blocker);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.once("error", reject);
|
||||
blocker.listen({ port, host: "0.0.0.0" }, () => resolve());
|
||||
});
|
||||
const result = await testPortOnAllHosts(port);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("testPortOnAllHosts — sequential contract (platform-agnostic)", () => {
|
||||
/**
|
||||
* Load-bearing regression test. Injects a recording fake probe that
|
||||
* holds each call open for a few ms and tracks how many are in flight.
|
||||
* The parallel (buggy) implementation would drive overlap to 4; the
|
||||
* sequential fix keeps it at 1. Deterministic on every OS.
|
||||
*/
|
||||
it("runs host probes sequentially — never more than one concurrent", async () => {
|
||||
let inFlight = 0;
|
||||
let peakConcurrency = 0;
|
||||
const hostsProbed: string[] = [];
|
||||
|
||||
const fakeProbe = async (_port: number, host: string): Promise<boolean> => {
|
||||
inFlight++;
|
||||
if (inFlight > peakConcurrency) peakConcurrency = inFlight;
|
||||
hostsProbed.push(host);
|
||||
// Hold so any parallel overlap from a regression would be visible
|
||||
// here regardless of OS scheduling.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
inFlight--;
|
||||
return true;
|
||||
};
|
||||
|
||||
const result = await testPortOnAllHosts(7777, fakeProbe);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(peakConcurrency).toBe(1);
|
||||
expect(hostsProbed).toEqual([...PORT_PROBE_HOSTS]);
|
||||
});
|
||||
|
||||
it("short-circuits on the first unavailable host", async () => {
|
||||
const hostsProbed: string[] = [];
|
||||
const fakeProbe = async (_port: number, host: string): Promise<boolean> => {
|
||||
hostsProbed.push(host);
|
||||
// Second host reports in-use; verify we never probe hosts three and four.
|
||||
return host === "127.0.0.1";
|
||||
};
|
||||
|
||||
const result = await testPortOnAllHosts(7777, fakeProbe);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(hostsProbed).toEqual(["127.0.0.1", "0.0.0.0"]);
|
||||
});
|
||||
});
|
||||
@@ -49,15 +49,37 @@ function isPortAvailableOnHost(port: number, host: string): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
export const PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const;
|
||||
|
||||
/**
|
||||
* Test a port across IPv4 and IPv6 interfaces in parallel. A port is only
|
||||
* unavailable if ANY host reports EADDRINUSE. This catches the devbox bug
|
||||
* where a port is free on localhost but occupied on 0.0.0.0 via SSH forwarding.
|
||||
* Test a port across IPv4 and IPv6 interfaces. A port is only available if
|
||||
* EVERY host binds and releases cleanly — that catches the devbox class of
|
||||
* bug where a port is free on `127.0.0.1` but held on `0.0.0.0` via SSH
|
||||
* forwarding.
|
||||
*
|
||||
* **Must be sequential, not Promise.all.** Binding `127.0.0.1` holds the
|
||||
* socket open until `server.close()` resolves on the next event-loop tick.
|
||||
* In parallel, the wildcard `0.0.0.0` / `::` tests race that still-open
|
||||
* socket and return spurious `EADDRINUSE` — which makes every port in the
|
||||
* scan range look occupied and the preview server refuse to start. Repro
|
||||
* on Linux (Crostini on ChromeOS in the reporting environment, issue #309)
|
||||
* is deterministic; on macOS/Windows the behaviour is less consistent but
|
||||
* the race is there all the same. Serializing each bind past its close
|
||||
* callback eliminates the window entirely.
|
||||
*
|
||||
* `probe` is injectable for deterministic testing of the sequential
|
||||
* contract — callers in production pass nothing and get the real socket
|
||||
* probe. Tests can pass a recording fake that tracks in-flight probes.
|
||||
*/
|
||||
export async function testPortOnAllHosts(port: number): Promise<boolean> {
|
||||
const hosts = ["127.0.0.1", "0.0.0.0", "::1", "::"];
|
||||
const results = await Promise.all(hosts.map((h) => isPortAvailableOnHost(port, h)));
|
||||
return results.every(Boolean);
|
||||
export async function testPortOnAllHosts(
|
||||
port: number,
|
||||
probe: (port: number, host: string) => Promise<boolean> = isPortAvailableOnHost,
|
||||
): Promise<boolean> {
|
||||
for (const host of PORT_PROBE_HOSTS) {
|
||||
const available = await probe(port, host);
|
||||
if (!available) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Existing instance detection ────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.5",
|
||||
"description": "",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1190,6 +1190,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
playing: state.isPlaying,
|
||||
playbackRate: state.playbackRate,
|
||||
outputMuted: state.mediaOutputMuted,
|
||||
userMuted: state.bridgeMuted,
|
||||
onAutoplayBlocked: () => {
|
||||
if (state.mediaAutoplayBlockedPosted) return;
|
||||
state.mediaAutoplayBlockedPosted = true;
|
||||
|
||||
@@ -458,4 +458,121 @@ describe("syncRuntimeMedia", () => {
|
||||
await Promise.resolve();
|
||||
expect(onAutoplayBlocked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asserts muted=true every tick while userMuted is set", () => {
|
||||
// Mirror of the `outputMuted` test — user preference must be sticky
|
||||
// too. A sub-composition that activates after the user mutes should
|
||||
// inherit the silence, not briefly play at author volume before the
|
||||
// next bridge message lands.
|
||||
const clip = createMockClip({ start: 0, end: 10, volume: 1 });
|
||||
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
|
||||
Object.defineProperty(clip.el, "muted", { value: false, writable: true });
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 5,
|
||||
playing: true,
|
||||
playbackRate: 1,
|
||||
userMuted: true,
|
||||
});
|
||||
expect(clip.el.muted).toBe(true);
|
||||
});
|
||||
|
||||
it("fires onAutoplayBlocked for every rejected play (caller owns the latch)", async () => {
|
||||
// media.ts is intentionally memoryless — each NotAllowedError rejection
|
||||
// invokes the callback. The init.ts caller wraps with
|
||||
// `mediaAutoplayBlockedPosted` so the outbound message is posted at most
|
||||
// once per session. This test pins down the contract (fires always) so
|
||||
// a future refactor can't quietly add deduplication here and break the
|
||||
// caller's latching logic.
|
||||
const clip = createMockClip({ start: 0, end: 10 });
|
||||
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
|
||||
const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" });
|
||||
clip.el.play = vi.fn(() => Promise.reject(rejection));
|
||||
const onAutoplayBlocked = vi.fn();
|
||||
|
||||
// Simulate two ticks — between them `playRequested` clears so play() runs
|
||||
// again and rejects again.
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 5,
|
||||
playing: true,
|
||||
playbackRate: 1,
|
||||
onAutoplayBlocked,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 5.05,
|
||||
playing: true,
|
||||
playbackRate: 1,
|
||||
onAutoplayBlocked,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// No latch inside media.ts — two rejections, two callback invocations.
|
||||
// The caller's latch is what prevents a second outbound message.
|
||||
expect(onAutoplayBlocked).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("caller-side latch pattern posts once across many rejections", async () => {
|
||||
// Mirrors what init.ts does: the onAutoplayBlocked wrapper checks and
|
||||
// sets a boolean flag so the outbound post fires exactly once even if
|
||||
// the raw callback fires many times. Regression guard for the latch
|
||||
// wiring in the init.ts handler.
|
||||
const clip = createMockClip({ start: 0, end: 10 });
|
||||
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
|
||||
const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" });
|
||||
clip.el.play = vi.fn(() => Promise.reject(rejection));
|
||||
|
||||
let posted = 0;
|
||||
const state = { latched: false };
|
||||
const wrapped = () => {
|
||||
if (state.latched) return;
|
||||
state.latched = true;
|
||||
posted += 1;
|
||||
};
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 5 + i * 0.05,
|
||||
playing: true,
|
||||
playbackRate: 1,
|
||||
onAutoplayBlocked: wrapped,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
expect(posted).toBe(1);
|
||||
});
|
||||
|
||||
it("mutes when either outputMuted OR userMuted is true (OR invariant)", () => {
|
||||
// Explicit validation of the combined-flag contract: setting one to
|
||||
// false while the other is true must keep the element muted.
|
||||
const clip = createMockClip({ start: 0, end: 10, volume: 1 });
|
||||
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
|
||||
Object.defineProperty(clip.el, "muted", { value: false, writable: true });
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 5,
|
||||
playing: true,
|
||||
playbackRate: 1,
|
||||
outputMuted: false,
|
||||
userMuted: true,
|
||||
});
|
||||
expect(clip.el.muted).toBe(true);
|
||||
Object.defineProperty(clip.el, "muted", { value: false, writable: true });
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 5,
|
||||
playing: true,
|
||||
playbackRate: 1,
|
||||
outputMuted: true,
|
||||
userMuted: false,
|
||||
});
|
||||
expect(clip.el.muted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,13 +93,18 @@ export function syncRuntimeMedia(params: {
|
||||
playing: boolean;
|
||||
playbackRate: number;
|
||||
/**
|
||||
* When `true`, assert `el.muted = true` on every active media element on
|
||||
* every tick. Sticky against newly-discovered media (sub-composition
|
||||
* activation, dynamic DOM) so the parent-frame audio-owner invariant holds.
|
||||
* `false` is a no-op — we don't un-mute, because other code paths
|
||||
* (`<audio muted>` author intent, `onSetMuted`) own the un-mute decision.
|
||||
* Parent-frame audio-owner has taken over audible playback. Assert
|
||||
* `el.muted = true` on every active media element per tick so that any
|
||||
* sub-composition media inserted mid-playback inherits the silence.
|
||||
*/
|
||||
outputMuted?: boolean;
|
||||
/**
|
||||
* User's explicit mute preference (set via `onSetMuted`). Symmetric to
|
||||
* `outputMuted` — also asserted per tick — so a sub-composition that
|
||||
* activates after the user mutes doesn't briefly play at author volume
|
||||
* before the next bridge message lands.
|
||||
*/
|
||||
userMuted?: boolean;
|
||||
/**
|
||||
* Invoked at most once when a media element's `play()` promise rejects with
|
||||
* `NotAllowedError`. The caller is expected to latch and post a single
|
||||
@@ -107,6 +112,9 @@ export function syncRuntimeMedia(params: {
|
||||
*/
|
||||
onAutoplayBlocked?: () => void;
|
||||
}): void {
|
||||
// Either flag silences output. Combined up front so the per-clip loop is
|
||||
// a single branch instead of two.
|
||||
const shouldMute = !!(params.outputMuted || params.userMuted);
|
||||
for (const clip of params.clips) {
|
||||
const { el } = clip;
|
||||
if (!el.isConnected) continue;
|
||||
@@ -122,7 +130,7 @@ export function syncRuntimeMedia(params: {
|
||||
}
|
||||
}
|
||||
if (clip.volume != null) el.volume = clip.volume;
|
||||
if (params.outputMuted) el.muted = true;
|
||||
if (shouldMute) el.muted = true;
|
||||
try {
|
||||
// Per-element rate × global transport rate
|
||||
el.playbackRate = clip.playbackRate * params.playbackRate;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/engine",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.5",
|
||||
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isFontResourceError } from "./frameCapture.js";
|
||||
|
||||
describe("isFontResourceError", () => {
|
||||
it("matches Google Fonts CSS load failures via location.url", () => {
|
||||
expect(
|
||||
isFontResourceError(
|
||||
"error",
|
||||
"Failed to load resource: net::ERR_FAILED",
|
||||
"https://fonts.googleapis.com/css2?family=Inter",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches gstatic font binaries via location.url", () => {
|
||||
expect(
|
||||
isFontResourceError(
|
||||
"error",
|
||||
"Failed to load resource: the server responded with a status of 404 (Not Found)",
|
||||
"https://fonts.gstatic.com/s/inter/v12/foo.woff2",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches self-hosted woff2 failures", () => {
|
||||
expect(
|
||||
isFontResourceError(
|
||||
"error",
|
||||
"Failed to load resource: net::ERR_CONNECTION_REFUSED",
|
||||
"http://localhost:9999/font.woff2",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches .ttf and .otf URLs", () => {
|
||||
expect(
|
||||
isFontResourceError("error", "Failed to load resource: 404", "http://example.com/a.ttf"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isFontResourceError("error", "Failed to load resource: 404", "http://example.com/b.otf"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match non-font resources (images, scripts, videos)", () => {
|
||||
expect(
|
||||
isFontResourceError("error", "Failed to load resource: 404", "https://example.com/img.png"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFontResourceError(
|
||||
"error",
|
||||
"Failed to load resource: 404",
|
||||
"https://cdn.example.com/bundle.js",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFontResourceError("error", "Failed to load resource: 404", "https://example.com/video.mp4"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT match when location.url is missing and text has no URL (safe default)", () => {
|
||||
expect(isFontResourceError("error", "Failed to load resource: 404", "")).toBe(false);
|
||||
});
|
||||
|
||||
it("still matches when URL appears in text (older Chrome formats)", () => {
|
||||
expect(
|
||||
isFontResourceError(
|
||||
"error",
|
||||
"Failed to load resource: https://fonts.googleapis.com/... 404",
|
||||
"",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match non-error console messages", () => {
|
||||
expect(
|
||||
isFontResourceError(
|
||||
"warn",
|
||||
"Failed to load resource: 404",
|
||||
"https://fonts.googleapis.com/css2",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFontResourceError(
|
||||
"info",
|
||||
"Failed to load resource: 404",
|
||||
"https://fonts.googleapis.com/css2",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT match unrelated error messages", () => {
|
||||
expect(isFontResourceError("error", "Uncaught ReferenceError: x is not defined", "")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isFontResourceError("error", "Some other error", "https://fonts.googleapis.com/css2"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-insensitive for URL matching", () => {
|
||||
expect(
|
||||
isFontResourceError(
|
||||
"error",
|
||||
"Failed to load resource: 404",
|
||||
"https://FONTS.GOOGLEAPIS.COM/css2",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isFontResourceError("error", "Failed to load resource: 404", "http://example.com/FONT.WOFF2"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,27 @@ export async function createCaptureSession(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a console "Failed to load resource" error as a font-load failure.
|
||||
*
|
||||
* These are expected when deterministic font injection replaces Google Fonts
|
||||
* @import URLs with embedded base64 — or when the render environment has no
|
||||
* network access to Google Fonts. Suppressing them reduces noise in render
|
||||
* output without hiding real asset failures (images, videos, scripts, etc.).
|
||||
*
|
||||
* Chrome's `msg.text()` for a failed resource is typically just
|
||||
* `"Failed to load resource: net::ERR_FAILED"` — the URL is only on
|
||||
* `msg.location().url`. We match against both so the filter works regardless
|
||||
* of which form Chrome emits.
|
||||
*/
|
||||
export function isFontResourceError(type: string, text: string, locationUrl: string): boolean {
|
||||
if (type !== "error") return false;
|
||||
if (!text.startsWith("Failed to load resource")) return false;
|
||||
return /fonts\.googleapis|fonts\.gstatic|\.(woff2?|ttf|otf)(\b|$)/i.test(
|
||||
`${locationUrl} ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function initializeSession(session: CaptureSession): Promise<void> {
|
||||
const { page, serverUrl } = session;
|
||||
|
||||
@@ -149,13 +170,8 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
||||
page.on("console", (msg: ConsoleMessage) => {
|
||||
const type = msg.type();
|
||||
const text = msg.text();
|
||||
|
||||
// Suppress font-loading 404s entirely. These are expected when deterministic
|
||||
// font injection replaces Google Fonts @import URLs with embedded base64.
|
||||
const isFontLoadError =
|
||||
type === "error" &&
|
||||
text.startsWith("Failed to load resource") &&
|
||||
/fonts\.googleapis|fonts\.gstatic|\.woff2?(\b|$)/i.test(text);
|
||||
const locationUrl = msg.location()?.url ?? "";
|
||||
const isFontLoadError = isFontResourceError(type, text, locationUrl);
|
||||
|
||||
// Other "Failed to load resource" 404s are typically non-blocking (e.g.
|
||||
// favicon, sourcemaps, optional assets). Prefix them so users know they
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.5",
|
||||
"description": "Embeddable web component for HyperFrames compositions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -211,6 +211,89 @@ describe("HyperframesPlayer parent-frame media", () => {
|
||||
expect(player._audioOwner).toBe("parent");
|
||||
});
|
||||
|
||||
it("dispatches audioownershipchange on promotion", () => {
|
||||
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
||||
document.body.appendChild(player);
|
||||
|
||||
const events: Array<{ owner: string; reason: string }> = [];
|
||||
player.addEventListener("audioownershipchange", (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ owner: string; reason: string }>).detail;
|
||||
events.push(detail);
|
||||
});
|
||||
|
||||
player._promoteToParentProxy?.();
|
||||
expect(events).toEqual([{ owner: "parent", reason: "autoplay-blocked" }]);
|
||||
|
||||
// Second promote is idempotent — no duplicate event.
|
||||
player._promoteToParentProxy?.();
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("promotion mid-playback plays parent proxy immediately", () => {
|
||||
// Previously-missing coverage: if the user is already playing when
|
||||
// the runtime reports autoplay-blocked, the proxy must start audible
|
||||
// right away — not wait for the user to hit pause/play again.
|
||||
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
||||
document.body.appendChild(player);
|
||||
|
||||
player.play(); // `_paused = false`, owner still `runtime` → no parent play yet
|
||||
expect(mockAudio.play).not.toHaveBeenCalled();
|
||||
|
||||
player._promoteToParentProxy?.();
|
||||
expect(mockAudio.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces playbackerror when parent proxy play() rejects", async () => {
|
||||
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
||||
document.body.appendChild(player);
|
||||
|
||||
const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" });
|
||||
mockAudio.play = vi.fn().mockRejectedValueOnce(rejection);
|
||||
|
||||
const errors: unknown[] = [];
|
||||
player.addEventListener("playbackerror", (e: Event) => {
|
||||
errors.push((e as CustomEvent).detail);
|
||||
});
|
||||
|
||||
player._promoteToParentProxy?.();
|
||||
player.play();
|
||||
// Promise rejection delivered on a microtask — flush.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect((errors[0] as { source: string }).source).toBe("parent-proxy");
|
||||
});
|
||||
|
||||
it("playbackerror dedup: fires at most once per parent-ownership session", async () => {
|
||||
// Under parent ownership with parent-also-blocked, every iframe
|
||||
// paused→playing transition in the state loop re-invokes `_playParentMedia`.
|
||||
// Without a latch, each rejection would re-fire `playbackerror`, spamming
|
||||
// subscribers. Mirrors the runtime's `mediaAutoplayBlockedPosted` latch.
|
||||
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
||||
document.body.appendChild(player);
|
||||
|
||||
const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" });
|
||||
mockAudio.play = vi.fn().mockRejectedValue(rejection);
|
||||
|
||||
const errors: unknown[] = [];
|
||||
player.addEventListener("playbackerror", (e: Event) => {
|
||||
errors.push((e as CustomEvent).detail);
|
||||
});
|
||||
|
||||
player._promoteToParentProxy?.();
|
||||
player.play();
|
||||
player.pause();
|
||||
player.play();
|
||||
player.pause();
|
||||
player.play();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("cleans up parent media on disconnect", () => {
|
||||
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
||||
document.body.appendChild(player);
|
||||
|
||||
@@ -62,6 +62,24 @@ class HyperframesPlayer extends HTMLElement {
|
||||
*/
|
||||
private _audioOwner: "runtime" | "parent" = "runtime";
|
||||
|
||||
/**
|
||||
* Watches the iframe document for sub-composition media added after
|
||||
* initial setup. Disconnected on iframe reload (fresh iframe = fresh
|
||||
* observer against the new document).
|
||||
*/
|
||||
private _mediaObserver?: MutationObserver;
|
||||
|
||||
/**
|
||||
* One-shot latch for `playbackerror`. Without it, under parent ownership
|
||||
* where the parent frame itself lacks activation, every paused→playing
|
||||
* transition in the iframe state loop would re-fire `play()` (and its
|
||||
* rejection) on each proxy — spamming host subscribers through a whole
|
||||
* playback session. Mirrors the `mediaAutoplayBlockedPosted` latch on the
|
||||
* runtime side. Cleared on `_onIframeLoad` alongside the owner reset, so
|
||||
* a fresh composition gets a fresh shot at surfacing the error.
|
||||
*/
|
||||
private _playbackErrorPosted = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
@@ -114,6 +132,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
window.removeEventListener("message", this._onMessage);
|
||||
this.iframe.removeEventListener("load", this._onIframeLoad);
|
||||
if (this._probeInterval) clearInterval(this._probeInterval);
|
||||
this._teardownMediaObserver();
|
||||
this.controlsApi?.destroy();
|
||||
for (const m of this._parentMedia) {
|
||||
m.el.pause();
|
||||
@@ -366,6 +385,28 @@ class HyperframesPlayer extends HTMLElement {
|
||||
private _onIframeLoad() {
|
||||
let attempts = 0;
|
||||
this._runtimeInjected = false;
|
||||
// A fresh iframe means a fresh runtime — `mediaOutputMuted` and the
|
||||
// autoplay-blocked latch are both reset inside it. The web component's
|
||||
// `_audioOwner` must reset to match, otherwise a composition switch on
|
||||
// a previously-promoted player would leave the parent thinking it owns
|
||||
// audio against a runtime that's happily playing the iframe copy again
|
||||
// — briefly reintroducing the double-voice bug for one probe window.
|
||||
// The next `NotAllowedError` (if any) will re-promote.
|
||||
const wasPromoted = this._audioOwner === "parent";
|
||||
this._audioOwner = "runtime";
|
||||
this._playbackErrorPosted = false;
|
||||
this._pauseParentMedia();
|
||||
// The old iframe document is about to go away. Disconnect the
|
||||
// MutationObserver now so we don't hold a reference to it; a fresh
|
||||
// one will attach once the new document settles in `_setupParentMedia`.
|
||||
this._teardownMediaObserver();
|
||||
if (wasPromoted) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("audioownershipchange", {
|
||||
detail: { owner: "runtime", reason: "iframe-reload" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (this._probeInterval) clearInterval(this._probeInterval);
|
||||
|
||||
this._probeInterval = setInterval(() => {
|
||||
@@ -527,28 +568,46 @@ class HyperframesPlayer extends HTMLElement {
|
||||
private _playParentMedia() {
|
||||
for (const m of this._parentMedia) {
|
||||
if (!m.el.src) continue;
|
||||
// Best-effort: if the parent itself has no user activation, this will
|
||||
// also reject — the caller has already decided parent ownership is
|
||||
// warranted, and there's nothing better to fall back to from here.
|
||||
m.el.play().catch(() => {});
|
||||
// Under parent ownership the proxy is the only audible pipeline. If
|
||||
// its `play()` rejects (rare — parent also lacks activation in some
|
||||
// programmatic embed flows), swallowing silently leaves the viewer
|
||||
// staring at motion with no audio and no signal. Surface it as a
|
||||
// `playbackerror` event — but only once per parent-ownership session;
|
||||
// see `_playbackErrorPosted` for why.
|
||||
m.el.play().catch((err: unknown) => this._reportPlaybackError(err));
|
||||
}
|
||||
}
|
||||
|
||||
private _reportPlaybackError(err: unknown) {
|
||||
if (this._playbackErrorPosted) return;
|
||||
this._playbackErrorPosted = true;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("playbackerror", { detail: { source: "parent-proxy", error: err } }),
|
||||
);
|
||||
}
|
||||
|
||||
private _pauseParentMedia() {
|
||||
for (const m of this._parentMedia) m.el.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag parent-proxy `currentTime` onto the iframe's timeline. Called on
|
||||
* every runtime state message under parent ownership. Only re-seeks when
|
||||
* drift exceeds 150 ms so we don't trigger a re-buffer on every tick —
|
||||
* native HTMLMediaElement playback rate drift stays well inside that.
|
||||
* every runtime state message under parent ownership. Threshold is 50 ms
|
||||
* — ITU-R BT.1359 puts A/V offset perceptibility at roughly ±45 ms, so
|
||||
* anything looser risks audible lip-sync drift on talking-head content
|
||||
* (a core use case). The re-seek cost at this tightness is a handful of
|
||||
* extra `currentTime` writes per second; the media element's own buffer
|
||||
* smooths them out without visible rebuffer on the mirror path.
|
||||
*/
|
||||
private static readonly MIRROR_DRIFT_THRESHOLD_SECONDS = 0.05;
|
||||
|
||||
private _mirrorParentMediaTime(timelineSeconds: number) {
|
||||
for (const m of this._parentMedia) {
|
||||
const relTime = timelineSeconds - m.start;
|
||||
if (relTime < 0 || relTime >= m.duration) continue;
|
||||
if (Math.abs(m.el.currentTime - relTime) > 0.15) m.el.currentTime = relTime;
|
||||
if (Math.abs(m.el.currentTime - relTime) > HyperframesPlayer.MIRROR_DRIFT_THRESHOLD_SECONDS) {
|
||||
m.el.currentTime = relTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,15 +630,37 @@ class HyperframesPlayer extends HTMLElement {
|
||||
private _promoteToParentProxy() {
|
||||
if (this._audioOwner === "parent") return;
|
||||
this._audioOwner = "parent";
|
||||
// `_sendControl` is async — the iframe won't see the mute for ~one
|
||||
// message-loop tick. In that narrow window the runtime's next
|
||||
// `syncRuntimeMedia` pass may still try `el.play()` on the iframe
|
||||
// copy; we rely on the autoplay gate (which got us here in the first
|
||||
// place) to keep rejecting until our mute lands. This is defensible
|
||||
// precisely because the scenario that triggered promotion is
|
||||
// "autoplay blocked" — the iframe can't make noise on its own.
|
||||
this._sendControl("set-media-output-muted", { muted: true });
|
||||
this._mirrorParentMediaTime(this._currentTime);
|
||||
if (!this._paused) this._playParentMedia();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("audioownershipchange", {
|
||||
detail: { owner: "parent", reason: "autoplay-blocked" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a parent-frame media element, configure it, and start preloading. */
|
||||
private _createParentMedia(src: string, tag: "audio" | "video", start: number, duration: number) {
|
||||
/**
|
||||
* Create a parent-frame media element, configure it, and start preloading.
|
||||
* Returns the newly-created proxy entry, or `null` if one already exists for
|
||||
* this src (dedup) — callers that need to act on the new element should
|
||||
* branch on the return value rather than inferring via `_parentMedia.length`.
|
||||
*/
|
||||
private _createParentMedia(
|
||||
src: string,
|
||||
tag: "audio" | "video",
|
||||
start: number,
|
||||
duration: number,
|
||||
): { el: HTMLMediaElement; start: number; duration: number } | null {
|
||||
// Deduplicate — browsers normalize URLs so we compare on the element after assignment
|
||||
if (this._parentMedia.some((m) => m.el.src === src)) return;
|
||||
if (this._parentMedia.some((m) => m.el.src === src)) return null;
|
||||
|
||||
const el = tag === "video" ? document.createElement("video") : new Audio();
|
||||
el.preload = "auto";
|
||||
@@ -588,7 +669,9 @@ class HyperframesPlayer extends HTMLElement {
|
||||
el.muted = this.muted;
|
||||
if (this.playbackRate !== 1) el.playbackRate = this.playbackRate;
|
||||
|
||||
this._parentMedia.push({ el, start, duration });
|
||||
const entry = { el, start, duration };
|
||||
this._parentMedia.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -609,6 +692,13 @@ class HyperframesPlayer extends HTMLElement {
|
||||
* Under runtime ownership (the default) these proxies stay paused and
|
||||
* inert; the iframe is the audible source. Ownership flips only in
|
||||
* response to a real `media-autoplay-blocked` message from the runtime.
|
||||
*
|
||||
* Also installs a MutationObserver so that media added to the iframe
|
||||
* *after* the initial scan (sub-composition activation is the common
|
||||
* case) gets a proxy on the fly. Without this, under parent ownership
|
||||
* late-added `<audio data-start>` would be silenced by the runtime
|
||||
* (`outputMuted` sticks per-tick) but have no parent-frame counterpart
|
||||
* to play — a silent hole in the audio track.
|
||||
*/
|
||||
private _setupParentMedia() {
|
||||
try {
|
||||
@@ -619,33 +709,124 @@ class HyperframesPlayer extends HTMLElement {
|
||||
const mediaEls = doc.querySelectorAll<HTMLMediaElement>(
|
||||
"audio[data-start], video[data-start]",
|
||||
);
|
||||
for (const iframeEl of mediaEls) this._adoptIframeMedia(iframeEl);
|
||||
|
||||
for (const iframeEl of mediaEls) {
|
||||
const rawSrc =
|
||||
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
|
||||
if (!rawSrc) continue;
|
||||
|
||||
// Resolve against the iframe's baseURI. The parent-frame <audio>/<video>
|
||||
// we create next lives in the host document, whose base URL differs from
|
||||
// the iframe's — without this, a relative src like "assets/narration.wav"
|
||||
// would resolve against the studio root and 404.
|
||||
const src = new URL(rawSrc, iframeEl.ownerDocument.baseURI).href;
|
||||
|
||||
const start = parseFloat(iframeEl.getAttribute("data-start") || "0");
|
||||
const duration = parseFloat(iframeEl.getAttribute("data-duration") || "Infinity");
|
||||
const tag = iframeEl.tagName === "VIDEO" ? ("video" as const) : ("audio" as const);
|
||||
|
||||
this._createParentMedia(src, tag, start, duration);
|
||||
// Iframe originals stay untouched — the runtime's `syncRuntimeMedia`
|
||||
// queries `audio[data-start]` for state and needs them addressable.
|
||||
// Their audible output is gated later by `set-media-output-muted`
|
||||
// when (and only when) parent ownership is promoted.
|
||||
}
|
||||
this._observeDynamicMedia(doc);
|
||||
} catch {
|
||||
// Cross-origin iframe — can't access DOM, fall back to iframe media
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a parent-frame proxy mirroring a single iframe media element.
|
||||
* Extracted so both the initial scan and the MutationObserver path use
|
||||
* identical URL-resolution and attribute parsing.
|
||||
*/
|
||||
private _adoptIframeMedia(iframeEl: HTMLMediaElement): void {
|
||||
const rawSrc =
|
||||
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
|
||||
if (!rawSrc) return;
|
||||
|
||||
// Resolve against the iframe's baseURI. The parent-frame <audio>/<video>
|
||||
// we create next lives in the host document, whose base URL differs from
|
||||
// the iframe's — without this, a relative src like "assets/narration.wav"
|
||||
// would resolve against the studio root and 404.
|
||||
const src = new URL(rawSrc, iframeEl.ownerDocument.baseURI).href;
|
||||
|
||||
const start = parseFloat(iframeEl.getAttribute("data-start") || "0");
|
||||
const duration = parseFloat(iframeEl.getAttribute("data-duration") || "Infinity");
|
||||
const tag = iframeEl.tagName === "VIDEO" ? ("video" as const) : ("audio" as const);
|
||||
|
||||
const created = this._createParentMedia(src, tag, start, duration);
|
||||
// Iframe originals stay untouched — the runtime's `syncRuntimeMedia`
|
||||
// queries `audio[data-start]` for state and needs them addressable.
|
||||
// Their audible output is gated later by `set-media-output-muted` when
|
||||
// (and only when) parent ownership is promoted.
|
||||
|
||||
// If we're already under parent ownership and the player is playing,
|
||||
// the new proxy needs to pick up where the timeline currently is and
|
||||
// start producing audio right away — otherwise it sits silent through
|
||||
// the next several hundred ms until the next runtime state message.
|
||||
if (created && this._audioOwner === "parent") {
|
||||
this._mirrorParentMediaTime(this._currentTime);
|
||||
if (!this._paused && created.el.src) {
|
||||
created.el.play().catch((err: unknown) => this._reportPlaybackError(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the iframe document for subtree additions of timed media so
|
||||
* sub-composition activation (late-attached `<audio data-start>`) grows
|
||||
* the parent-proxy set automatically. Disconnected on iframe reload via
|
||||
* `_teardownMediaObserver`.
|
||||
*/
|
||||
private _observeDynamicMedia(doc: Document): void {
|
||||
this._teardownMediaObserver();
|
||||
if (typeof MutationObserver === "undefined" || !doc.body) return;
|
||||
const obs = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
for (const added of m.addedNodes) {
|
||||
if (!(added instanceof Element)) continue;
|
||||
// Handle both the node itself and any timed media nested inside
|
||||
// (sub-compositions typically inject a fragment whose root is a
|
||||
// `<div data-composition-id=...>` with `<audio>` children).
|
||||
const candidates: HTMLMediaElement[] = [];
|
||||
if (added.matches?.("audio[data-start], video[data-start]")) {
|
||||
candidates.push(added as HTMLMediaElement);
|
||||
}
|
||||
const inside = added.querySelectorAll?.<HTMLMediaElement>(
|
||||
"audio[data-start], video[data-start]",
|
||||
);
|
||||
if (inside) for (const el of inside) candidates.push(el);
|
||||
for (const el of candidates) this._adoptIframeMedia(el);
|
||||
}
|
||||
for (const removed of m.removedNodes) {
|
||||
if (!(removed instanceof Element)) continue;
|
||||
// Symmetric detach: when a sub-composition unmounts, the iframe
|
||||
// media it owned is gone but our parent proxies would otherwise
|
||||
// linger — accumulating host-document <audio> elements and, under
|
||||
// parent ownership, still being played by `_playParentMedia` as
|
||||
// orphans. Match by resolved URL (same resolution as adoption).
|
||||
const dropped: HTMLMediaElement[] = [];
|
||||
if (removed.matches?.("audio[data-start], video[data-start]")) {
|
||||
dropped.push(removed as HTMLMediaElement);
|
||||
}
|
||||
const inside = removed.querySelectorAll?.<HTMLMediaElement>(
|
||||
"audio[data-start], video[data-start]",
|
||||
);
|
||||
if (inside) for (const el of inside) dropped.push(el);
|
||||
for (const el of dropped) this._detachIframeMedia(el);
|
||||
}
|
||||
}
|
||||
});
|
||||
obs.observe(doc.body, { childList: true, subtree: true });
|
||||
this._mediaObserver = obs;
|
||||
}
|
||||
|
||||
private _teardownMediaObserver(): void {
|
||||
this._mediaObserver?.disconnect();
|
||||
this._mediaObserver = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `_adoptIframeMedia`: drop the parent proxy mirroring a removed
|
||||
* iframe media element. Resolves the src identically so matching is exact,
|
||||
* then pauses, clears the src (frees the decoder), and splices it out.
|
||||
*/
|
||||
private _detachIframeMedia(iframeEl: HTMLMediaElement): void {
|
||||
const rawSrc =
|
||||
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
|
||||
if (!rawSrc) return;
|
||||
const src = new URL(rawSrc, iframeEl.ownerDocument.baseURI).href;
|
||||
const idx = this._parentMedia.findIndex((m) => m.el.src === src);
|
||||
if (idx === -1) return;
|
||||
const entry = this._parentMedia[idx];
|
||||
entry.el.pause();
|
||||
entry.el.src = "";
|
||||
this._parentMedia.splice(idx, 1);
|
||||
}
|
||||
|
||||
private _hidePoster() {
|
||||
this.posterEl?.remove();
|
||||
this.posterEl = null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.5",
|
||||
"description": "HTML-to-video rendering engine using Chrome's BeginFrame API",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
rewriteCssAssetUrls,
|
||||
} from "@hyperframes/core";
|
||||
import { extractVideoMetadata, extractAudioMetadata } from "../utils/ffprobe.js";
|
||||
import { isPathInside, toExternalAssetKey } from "../utils/paths.js";
|
||||
import {
|
||||
parseVideoElements,
|
||||
type VideoElement,
|
||||
@@ -804,12 +805,14 @@ export function collectExternalAssets(
|
||||
return null;
|
||||
}
|
||||
const absPath = resolve(absProjectDir, trimmed);
|
||||
if (absPath.startsWith(absProjectDir + "/") || absPath === absProjectDir) {
|
||||
if (isPathInside(absPath, absProjectDir)) {
|
||||
return null; // inside projectDir, file server handles this
|
||||
}
|
||||
if (!existsSync(absPath)) return null;
|
||||
// resolve() already canonicalizes the path (no .. components remain)
|
||||
const safeKey = "hf-ext/" + absPath.replace(/^\//, "");
|
||||
// resolve() already canonicalises the path (no .. components remain);
|
||||
// toExternalAssetKey() produces a cross-platform relative key that
|
||||
// `path.join(compileDir, key)` cannot escape on any OS.
|
||||
const safeKey = toExternalAssetKey(absPath);
|
||||
externalAssets.set(safeKey, absPath);
|
||||
return safeKey;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractStandaloneEntryFromIndex } from "./renderOrchestrator.js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { extractStandaloneEntryFromIndex, writeCompiledArtifacts } from "./renderOrchestrator.js";
|
||||
import { toExternalAssetKey } from "../utils/paths.js";
|
||||
|
||||
describe("extractStandaloneEntryFromIndex", () => {
|
||||
it("reuses the index wrapper and keeps only the requested composition host", () => {
|
||||
@@ -59,3 +64,100 @@ describe("extractStandaloneEntryFromIndex", () => {
|
||||
expect(extracted).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeCompiledArtifacts — external assets on Windows drive-letter paths (GH #321)", () => {
|
||||
// End-to-end seam test: covers both `toExternalAssetKey` and
|
||||
// `renderOrchestrator`'s copy step by simulating a Windows absolute
|
||||
// path flowing through the full external-asset pipeline. The helpers
|
||||
// are logically cross-platform, but this is the integration that
|
||||
// guarantees they compose — catches any regression at the boundary.
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const d = tempDirs.pop();
|
||||
if (d) {
|
||||
try {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function makeWorkDir(): string {
|
||||
const d = mkdtempSync(join(tmpdir(), "hf-orch-"));
|
||||
tempDirs.push(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
it("copies an external asset with a Windows-style drive-letter key into compileDir", () => {
|
||||
const workDir = makeWorkDir();
|
||||
// Simulate a real external asset: write a dummy file to an absolute
|
||||
// path, then build the sanitised key the way `collectExternalAssets`
|
||||
// would on Windows.
|
||||
const sourceDir = mkdtempSync(join(tmpdir(), "hf-src-"));
|
||||
tempDirs.push(sourceDir);
|
||||
const srcFile = join(sourceDir, "segment.wav");
|
||||
writeFileSync(srcFile, "fake wav bytes");
|
||||
|
||||
// The simulated Windows input is a path with backslashes and a drive
|
||||
// letter — even though the test runs on Unix, the helper is expressed
|
||||
// with regex on the string so we can exercise the Windows code path
|
||||
// deterministically.
|
||||
const windowsStyleInput = "D:\\coder\\assets\\segment.wav";
|
||||
const key = toExternalAssetKey(windowsStyleInput);
|
||||
expect(key).toBe("hf-ext/D/coder/assets/segment.wav");
|
||||
|
||||
const externalAssets = new Map<string, string>([[key, srcFile]]);
|
||||
const compiled = {
|
||||
html: "<!doctype html><html><body></body></html>",
|
||||
subCompositions: new Map<string, string>(),
|
||||
videos: [],
|
||||
audios: [],
|
||||
unresolvedCompositions: [],
|
||||
externalAssets,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
staticDuration: 10,
|
||||
};
|
||||
|
||||
writeCompiledArtifacts(compiled, workDir, /* includeSummary */ false);
|
||||
|
||||
const landed = join(workDir, "compiled", key);
|
||||
expect(existsSync(landed)).toBe(true);
|
||||
expect(readFileSync(landed, "utf-8")).toBe("fake wav bytes");
|
||||
});
|
||||
|
||||
it("rejects a maliciously crafted key that tries to escape compileDir", () => {
|
||||
// Defense-in-depth: if a buggy upstream produced a key with `..`
|
||||
// components, `isPathInside` at copy time must catch it and skip.
|
||||
const workDir = makeWorkDir();
|
||||
const sourceDir = mkdtempSync(join(tmpdir(), "hf-src-"));
|
||||
tempDirs.push(sourceDir);
|
||||
const srcFile = join(sourceDir, "evil.wav");
|
||||
writeFileSync(srcFile, "should never be copied");
|
||||
|
||||
const externalAssets = new Map<string, string>([["hf-ext/../../etc/passwd", srcFile]]);
|
||||
const compiled = {
|
||||
html: "<!doctype html>",
|
||||
subCompositions: new Map<string, string>(),
|
||||
videos: [],
|
||||
audios: [],
|
||||
unresolvedCompositions: [],
|
||||
externalAssets,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
staticDuration: 10,
|
||||
};
|
||||
|
||||
writeCompiledArtifacts(compiled, workDir, false);
|
||||
|
||||
// Assert that the file was NOT written outside compileDir (the
|
||||
// attacker's target). We check the escape destination didn't
|
||||
// materialise next to workDir.
|
||||
const escapeTarget = join(workDir, "..", "..", "etc", "passwd");
|
||||
expect(existsSync(escapeTarget)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
type CompiledComposition,
|
||||
} from "./htmlCompiler.js";
|
||||
import { defaultLogger, type ProducerLogger } from "../logger.js";
|
||||
import { isPathInside } from "../utils/paths.js";
|
||||
|
||||
/**
|
||||
* Wrap a cleanup operation so it never throws, but logs any failure.
|
||||
@@ -246,7 +247,9 @@ function installDebugLogger(logPath: string, log: ProducerLogger = defaultLogger
|
||||
/**
|
||||
* Write compiled HTML and sub-compositions to the work directory.
|
||||
*/
|
||||
function writeCompiledArtifacts(
|
||||
// Exported for integration tests. Not part of the stable public API —
|
||||
// callers outside this package should use `executeRenderJob` instead.
|
||||
export function writeCompiledArtifacts(
|
||||
compiled: CompiledComposition,
|
||||
workDir: string,
|
||||
includeSummary: boolean,
|
||||
@@ -263,10 +266,13 @@ function writeCompiledArtifacts(
|
||||
}
|
||||
|
||||
// Copy external assets (files outside projectDir) into the compiled directory
|
||||
// so the file server can serve them.
|
||||
// so the file server can serve them. The safe-path check uses
|
||||
// `isPathInside()` rather than a hardcoded separator — on Windows,
|
||||
// `compileDir + "/"` never matches because paths use `\\`, which caused
|
||||
// every external asset to be wrongly rejected as "unsafe" (see GH #321).
|
||||
for (const [relativePath, absolutePath] of compiled.externalAssets) {
|
||||
const outPath = resolve(join(compileDir, relativePath));
|
||||
if (!outPath.startsWith(compileDir + "/")) {
|
||||
if (!isPathInside(outPath, compileDir)) {
|
||||
console.warn(`[Render] Skipping external asset with unsafe path: ${relativePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Cross-platform containment + external-asset-key tests.
|
||||
*
|
||||
* Regression coverage for GH #321 — on Windows, every external asset was
|
||||
* wrongly rejected as "unsafe path" because the containment check used
|
||||
* `startsWith(parent + "/")` and the safe key carried a drive-letter
|
||||
* colon that made the downstream `path.join` absolute.
|
||||
*
|
||||
* We exercise both OS layouts by posing the hypothetical paths the
|
||||
* respective platforms would generate — the logic itself is expressed
|
||||
* using `path.relative()` so it works regardless of the runtime OS.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { isPathInside, toExternalAssetKey } from "./paths.js";
|
||||
|
||||
describe("isPathInside", () => {
|
||||
it("returns true when child is directly inside parent", () => {
|
||||
expect(isPathInside(resolve("/foo/bar/baz.wav"), resolve("/foo/bar"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when child is deeply nested inside parent", () => {
|
||||
expect(isPathInside(resolve("/foo/bar/a/b/c/d.wav"), resolve("/foo/bar"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when child equals parent (a dir contains itself)", () => {
|
||||
expect(isPathInside(resolve("/foo/bar"), resolve("/foo/bar"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when child is a sibling whose name starts with parent", () => {
|
||||
// Regression: the old `startsWith(parent + "/")` accidentally worked for
|
||||
// this case, but a naive rewrite without the trailing separator would
|
||||
// admit `/foo/bar-sibling` as a child of `/foo/bar`. Verify we don't.
|
||||
expect(isPathInside(resolve("/foo/bar-sibling/x"), resolve("/foo/bar"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when child is outside parent", () => {
|
||||
expect(isPathInside(resolve("/tmp/evil/file.wav"), resolve("/foo/bar"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when child resolves above parent via ..", () => {
|
||||
expect(isPathInside(resolve("/foo/bar/../../etc/passwd"), resolve("/foo/bar"))).toBe(false);
|
||||
});
|
||||
|
||||
it("normalises trailing slashes on parent", () => {
|
||||
expect(isPathInside(resolve("/foo/bar/baz"), resolve("/foo/bar/"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toExternalAssetKey", () => {
|
||||
it("prefixes with hf-ext/ and keeps a Unix absolute path", () => {
|
||||
expect(toExternalAssetKey("/Users/miguel/assets/segment.wav")).toBe(
|
||||
"hf-ext/Users/miguel/assets/segment.wav",
|
||||
);
|
||||
});
|
||||
|
||||
it("converts Windows drive-letter paths to a colonless, slash-delimited key", () => {
|
||||
// GH #321: `D:\coder\reactGin\hyperframes\reading\assets\segment_001.wav`
|
||||
// used to become `hf-ext/D:\coder\...`, which makes the downstream
|
||||
// `path.join(compileDir, key)` absolute on Windows (drive letter wins).
|
||||
expect(
|
||||
toExternalAssetKey("D:\\coder\\reactGin\\hyperframes\\reading\\assets\\segment_001.wav"),
|
||||
).toBe("hf-ext/D/coder/reactGin/hyperframes/reading/assets/segment_001.wav");
|
||||
});
|
||||
|
||||
it("handles Windows paths with forward slashes (mixed separators)", () => {
|
||||
expect(toExternalAssetKey("C:/Users/Alice/Downloads/clip.mp4")).toBe(
|
||||
"hf-ext/C/Users/Alice/Downloads/clip.mp4",
|
||||
);
|
||||
});
|
||||
|
||||
it("lowercases / uppercases drive letters faithfully (we don't munge)", () => {
|
||||
expect(toExternalAssetKey("e:\\data\\a.wav")).toBe("hf-ext/e/data/a.wav");
|
||||
expect(toExternalAssetKey("Z:\\data\\a.wav")).toBe("hf-ext/Z/data/a.wav");
|
||||
});
|
||||
|
||||
it("is truly idempotent — double-wrap short-circuits on the hf-ext/ prefix", () => {
|
||||
// Earlier revision of this test claimed "idempotent" but actually
|
||||
// produced `hf-ext/hf-ext/...` — a silent doubling. The short-circuit
|
||||
// on the hf-ext/ prefix makes the helper exactly idempotent now, so
|
||||
// the invariant test matches the label.
|
||||
const once = toExternalAssetKey("/foo/bar.mp3");
|
||||
const twice = toExternalAssetKey(once);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
|
||||
it("strips the Windows extended-length prefix (\\\\?\\)", () => {
|
||||
expect(toExternalAssetKey("\\\\?\\D:\\very\\long\\path\\clip.mp4")).toBe(
|
||||
"hf-ext/D/very/long/path/clip.mp4",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses UNC paths to unc/<server>/<share>/... so cross-server names can't collide", () => {
|
||||
expect(toExternalAssetKey("\\\\server\\share\\file.wav")).toBe(
|
||||
"hf-ext/unc/server/share/file.wav",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles UNC extended-length form (\\\\?\\UNC\\server\\...)", () => {
|
||||
expect(toExternalAssetKey("\\\\?\\UNC\\server\\share\\file.wav")).toBe(
|
||||
"hf-ext/unc/server/share/file.wav",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats leading double-slash as UNC (the Windows-correct reading)", () => {
|
||||
// A leading `//host/share/...` is the Windows UNC form — NOT a Unix
|
||||
// absolute path with an extra slash. The sanitiser now preserves the
|
||||
// host/share boundary instead of collapsing it, matching the actual
|
||||
// meaning of the input on the platform that produces these paths.
|
||||
expect(toExternalAssetKey("//foo/bar.mp3")).toBe("hf-ext/unc/foo/bar.mp3");
|
||||
});
|
||||
|
||||
it("produces a key that path.join(compileDir, key) keeps inside compileDir", () => {
|
||||
// The real failure mode from #321: on Windows, join(compileDir, key) with
|
||||
// a key containing a drive letter silently escaped compileDir. Our key
|
||||
// must be a pure relative path — no `:`, no leading separator — so
|
||||
// `isPathInside(join(compileDir, key), compileDir)` is always true.
|
||||
const key = toExternalAssetKey("D:\\evil\\x.wav");
|
||||
// Key cannot start with a separator or drive letter.
|
||||
expect(key.startsWith("/")).toBe(false);
|
||||
expect(/^[A-Za-z]:/.test(key)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
* Path resolution utilities for the render pipeline.
|
||||
*/
|
||||
|
||||
import { resolve, basename, join } from "node:path";
|
||||
import { resolve, basename, join, relative, isAbsolute } from "node:path";
|
||||
|
||||
export interface RenderPaths {
|
||||
absoluteProjectDir: string;
|
||||
@@ -13,6 +13,79 @@ const DEFAULT_RENDERS_DIR =
|
||||
process.env.PRODUCER_RENDERS_DIR ??
|
||||
resolve(new URL(import.meta.url).pathname, "../../..", "renders");
|
||||
|
||||
/**
|
||||
* Cross-platform containment check.
|
||||
*
|
||||
* `child.startsWith(parent + "/")` breaks on Windows because the path
|
||||
* separator is `\`, not `/`. This helper uses `path.relative()` which
|
||||
* normalises separators per-platform and returns `..`-prefixed output
|
||||
* for out-of-tree paths — the canonical way to ask "is `child` inside
|
||||
* `parent`?" on every supported OS.
|
||||
*
|
||||
* Both inputs are normalised via `resolve()` so callers don't need to.
|
||||
* Equality counts as "inside" (a directory contains itself).
|
||||
*/
|
||||
export function isPathInside(childPath: string, parentPath: string): boolean {
|
||||
const absChild = resolve(childPath);
|
||||
const absParent = resolve(parentPath);
|
||||
if (absChild === absParent) return true;
|
||||
const rel = relative(absParent, absChild);
|
||||
// `relative()` returns "" when paths are equal, ".." or "..\\foo" when child
|
||||
// is above the parent, and an absolute path when they live on different
|
||||
// drives/volumes (Windows) — none of which count as "inside".
|
||||
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a safe, cross-platform relative key for an absolute asset path
|
||||
* that lives outside the project directory.
|
||||
*
|
||||
* Windows absolute paths (`D:\coder\assets\segment.wav`) break two
|
||||
* downstream assumptions when passed as-is to `path.join(compileDir, key)`:
|
||||
* 1. The drive letter makes the path absolute, so `join()` silently
|
||||
* discards `compileDir`.
|
||||
* 2. The backslashes and colon are invalid inside some OS sandboxes
|
||||
* and HTTP URL encodings.
|
||||
*
|
||||
* We sanitise into `hf-ext/...` form using forward slashes, stripping
|
||||
* the colon after drive letters, the Windows extended-length prefix
|
||||
* (`\\?\`), and the UNC prefix (`\\server\share\`). The result is a
|
||||
* pure relative path that joins cleanly on every platform.
|
||||
*
|
||||
* Caller contract: `absPath` is expected to be canonical — typically
|
||||
* produced by `path.resolve()` upstream. This helper does NOT strip
|
||||
* `..` components on its own. `isPathInside` at copy time is the
|
||||
* defensive backstop.
|
||||
*/
|
||||
export function toExternalAssetKey(absPath: string): string {
|
||||
// Short-circuit if already a sanitised key — prevents double-wrap
|
||||
// producing `hf-ext/hf-ext/...`.
|
||||
if (absPath.startsWith("hf-ext/")) return absPath;
|
||||
|
||||
// Normalise to forward slashes first so every subsequent pattern is
|
||||
// separator-agnostic.
|
||||
let normalised = absPath.replace(/\\/g, "/");
|
||||
|
||||
// Windows extended-length prefix: `//?/` (was `\\?\`). Strip entirely —
|
||||
// the actual path follows. `//?/UNC/server/share/...` is the UNC
|
||||
// extended-length form; normalise to match the UNC branch below.
|
||||
normalised = normalised.replace(/^\/\/\?\/UNC\//i, "//");
|
||||
normalised = normalised.replace(/^\/\/\?\//, "");
|
||||
|
||||
// UNC paths (`\\server\share\file`). Collapse to
|
||||
// `unc/server/share/file` so two different servers can't collide
|
||||
// under the same relative key.
|
||||
normalised = normalised.replace(/^\/\/([^/]+)\//, "unc/$1/");
|
||||
|
||||
// Strip remaining leading forward slashes (Unix absolute).
|
||||
normalised = normalised.replace(/^\/+/, "");
|
||||
|
||||
// Strip a leading drive-letter colon (Windows: "D:/coder" → "D/coder").
|
||||
normalised = normalised.replace(/^([A-Za-z]):\/?/, "$1/");
|
||||
|
||||
return "hf-ext/" + normalised;
|
||||
}
|
||||
|
||||
export function resolveRenderPaths(
|
||||
projectDir: string,
|
||||
outputPath: string | null | undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/shader-transitions",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.5",
|
||||
"description": "WebGL shader transitions for HyperFrames compositions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>HyperFrames Studio</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.5",
|
||||
"description": "",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -611,7 +611,7 @@ export function StudioApp() {
|
||||
|
||||
if (resolving || !projectId) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-neutral-950 flex items-center justify-center">
|
||||
<div className="h-full w-full bg-neutral-950 flex items-center justify-center">
|
||||
<div className="w-4 h-4 rounded-full bg-studio-accent animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
@@ -621,7 +621,7 @@ export function StudioApp() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-screen w-screen bg-neutral-950 relative"
|
||||
className="flex flex-col h-full w-full bg-neutral-950 relative"
|
||||
onDragOver={(e) => {
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
|
||||
@@ -98,23 +98,76 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
[duration, onSeek],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
// Ignore secondary mouse buttons — only primary (left click / touch /
|
||||
// pen contact) should start a drag.
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
// preventDefault() on pointerdown also suppresses the implicit focus
|
||||
// transfer that click normally grants a `tabIndex=0` element — which
|
||||
// matches native `<input type="range">` behavior, but it also means a
|
||||
// click-then-arrow-key workflow wouldn't work. Restore focus explicitly
|
||||
// so seeking by click and nudging by arrow keys compose naturally.
|
||||
e.currentTarget.focus();
|
||||
isDraggingRef.current = true;
|
||||
|
||||
// `setPointerCapture` routes every subsequent pointermove/up to the
|
||||
// slider element even when the pointer leaves its bounding box. Without
|
||||
// it, fast drags on touch would lose events the moment the finger
|
||||
// slips outside the 6 px-tall hit zone.
|
||||
const target = e.currentTarget;
|
||||
const pointerId = e.pointerId;
|
||||
try {
|
||||
target.setPointerCapture(pointerId);
|
||||
} catch {
|
||||
/* non-supporting browsers fall back to window listeners below */
|
||||
}
|
||||
|
||||
seekFromClientX(e.clientX);
|
||||
|
||||
const onMouseMove = (me: MouseEvent) => {
|
||||
if (isDraggingRef.current) seekFromClientX(me.clientX);
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== pointerId) return;
|
||||
if (isDraggingRef.current) seekFromClientX(ev.clientX);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
const cleanup = () => {
|
||||
isDraggingRef.current = false;
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
try {
|
||||
target.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
/* Already released after the first cleanup — second invocation
|
||||
via the window-fallback or visibility path is a no-op throw. */
|
||||
}
|
||||
target.removeEventListener("pointermove", onMove);
|
||||
target.removeEventListener("pointerup", onUp);
|
||||
target.removeEventListener("pointercancel", onUp);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onUp);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("blur", cleanup);
|
||||
};
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== pointerId) return;
|
||||
cleanup();
|
||||
};
|
||||
// iOS Safari does not reliably fire `pointercancel` when the page is
|
||||
// backgrounded mid-drag (alt-tab, incoming call, switch apps). Without
|
||||
// a release path the ref stays `true` until the next pointerdown — a
|
||||
// stuck-scrubber class bug waiting to happen if anyone later gates
|
||||
// rendering on `isDragging`. Synthesize the release on hide / blur.
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "hidden") cleanup();
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
target.addEventListener("pointermove", onMove);
|
||||
target.addEventListener("pointerup", onUp);
|
||||
target.addEventListener("pointercancel", onUp);
|
||||
// Window-level fallback in case capture fails and the pointer release
|
||||
// lands outside the element (rare, but defensive).
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onUp);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("blur", cleanup);
|
||||
},
|
||||
[seekFromClientX],
|
||||
);
|
||||
@@ -137,7 +190,13 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
return (
|
||||
<div
|
||||
className="px-4 py-2 flex items-center gap-3"
|
||||
style={{ borderTop: "1px solid rgba(255,255,255,0.04)" }}
|
||||
style={{
|
||||
borderTop: "1px solid rgba(255,255,255,0.04)",
|
||||
// Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
|
||||
// the Play button + timecode on iPhone. `env(safe-area-inset-bottom)`
|
||||
// is 0 everywhere else, so this is a no-op on desktop.
|
||||
paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
|
||||
}}
|
||||
>
|
||||
{/* Play/Pause button */}
|
||||
<button
|
||||
@@ -183,8 +242,12 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
aria-valuemax={Math.round(duration)}
|
||||
aria-valuenow={0}
|
||||
className="flex-1 h-6 flex items-center cursor-pointer group"
|
||||
style={{ touchAction: "manipulation" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
// `touch-action: none` tells the browser we're handling every
|
||||
// pointer gesture on this element ourselves. Without it, iOS
|
||||
// Safari consumes horizontal swipes for its own swipe-back-to-
|
||||
// previous-page navigation and the scrubber can't drag left.
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -13,7 +13,18 @@ body {
|
||||
|
||||
#root {
|
||||
width: 100vw;
|
||||
/*
|
||||
* 100vh on iOS Safari measures the LARGEST viewport (toolbars hidden) and
|
||||
* stays fixed at that value, so when the toolbar is visible the bottom of
|
||||
* the layout sits *under* it and anything at flex-end — the player
|
||||
* controls row, notably — becomes untappable. `100dvh` follows the
|
||||
* dynamic viewport, shrinking when the toolbar is shown so the bottom of
|
||||
* #root lines up with the bottom of the visible area. Fallback to 100vh
|
||||
* keeps older browsers (pre-Safari 15.4 / Firefox 101 / Chrome 108) on
|
||||
* the existing behaviour.
|
||||
*/
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
/* CodeMirror overrides */
|
||||
|
||||
Reference in New Issue
Block a user