`present` and `play` render compositions in the standalone browser player,
resolving the player/slideshow IIFE bundles via resolvePlayerPath /
resolveSlideshowPath. Those resolvers look for the bundles alongside the built
CLI (dist/hyperframes-player.global.js, dist/hyperframes-slideshow.global.js),
but build-copy.mjs never staged them into dist/. The remaining candidate paths
are monorepo-dev only, so an npm install has nothing to resolve.
Result: `npx hyperframes present` always failed with
"@hyperframes/player not found", forcing users to run the presenter from a
monorepo checkout.
Copy both player globals from packages/player/dist into the CLI dist during
build:copy (existsSync-guarded + warn, matching the surrounding pattern). The
runtime bundle is already handled by build:runtime. Verified: the globals now
appear in `npm pack`, and `node dist/cli.js present` starts without the
player-not-found error.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add formatSrt/formatVtt/wordsToCues to normalize.ts (the inverse of the
existing parseSrt/parseVtt) and a 'hyperframes transcribe <transcript> --to
srt|vtt' export mode. Word-level whisper transcripts group into cues on
sentence boundaries with maxChars/maxGap guards; imported phrase-level cues
pass through unchanged. Default transcribe behavior is unchanged and no new
dependencies are added.
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix: handle caption skin workflow
* docs(skills): simplify the finalize step across video workflows
- Drop --strict-layout; all skills use plain `hyperframes inspect`
- Add the caption text_box_overflow false-positive note to faceless-explainer
- On a failed check, the orchestrator makes the cheapest safe edit itself
(no worker re-dispatch / Step 3 backtrack language)
- Snapshot: glance at the stitched contact-sheet.jpg and move on
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(auth): onboarding-first `auth status` + shared TTS/BGM preflight
When no HeyGen credential is configured, `hyperframes auth status` now
prints registration-first guidance instead of a terse error:
- Interactive / agent-driven sessions get sign-in guidance led by
`hyperframes auth login` (the OAuth step that also creates an account
and is shared with heygen-cli), and never steer users to a per-repo
`.env`. CI / non-interactive runs get a terse note. Exit 1 is kept so
the "am I logged in?" `$?` contract still holds.
- It probes which local engine voice/music will fall back to (Kokoro /
MusicGen, mirroring the skill resolution order) and whether their
Python deps are installed, with a pip hint when missing. `--json`
exposes `recommended_action` + `offline_engines` for skills to branch.
- `doctor` gains matching "TTS (Kokoro)" / "BGM (MusicGen)" checks via
the same shared probe (findPython/hasPythonModules extracted to
tts/python.ts; provider resolution in audio/providers.ts).
Every TTS/BGM workflow now relays this at Step 0 (setup) instead of
improvising its own "missing key" prompt: pr-to-video, product-launch-
video, faceless-explainer, website-to-video, music-to-video. The
canonical behavior + key-priority table live once in hyperframes-media.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(pr-to-video): scale recommended video length to PR change size
Step 0 led with a fixed ~60-90s length default. Now the recommended
length is derived from the PR's diff stat (lines added+deleted, nudged
by file count) on a tier scale (trivial ~20-40s → large ~110-180s, hard
cap ~3 min), reusing the same PR peek already done to infer the angle.
The agent states the basis when proposing it, and a huge PR with one
headline change still stays tight. User can always override.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(captions): embed brand fonts whose files use separators
brandFontFaces() matched font files by stripping only whitespace, so an
underscore/hyphen-named file (TT_Norms_Pro_Bold.woff2) never matched the
family key "ttnormspro" — captions shipped with no @font-face, the
font_family_without_font_face bug. Now both family and filename normalize
away all non-alphanumerics; families match longest-key-first so a parent
family can't swallow a more specific one's files (TT Norms Pro vs Mono);
each file is claimed once; "demibold" ranks before "bold"; and when
nothing matches it warns loudly at build time instead of returning "".
Also: parseFonts() falls back to h1/h2/title/hero display roles, and the
frame-worker + caption authoring docs spell out that only shipped font
files render — no system CJK/Devanagari families on the headless renderer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(hyperframes-media): enforce sign-in preflight on standalone BGM/TTS
A one-off "generate me a BGM" request went straight to local MusicGen
without recommending sign-in: bgm.md/tts.md framed the no-credential path
as an automatic fallback, so the generation path bypassed the Preflight
stop, and the preflight used a bare `hyperframes auth status` that isn't
on PATH in a fresh `npx skills` project.
- Preflight now applies to one-off generation as well as workflows, uses
`npx hyperframes auth status`, and says: if the CLI can't run, still
recommend signing in and STOP — never treat "no credential" as a silent
green light for local generation.
- bgm.md and tts.md point at the Preflight before generating, reframing
local generation as the fallback the user opts into, not a default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(auth): add Authentication & API keys guide
Document signing in, the keys each capability (voice, music, capture)
uses, their resolution priority, and the fully local fallback. Add the
guide to the nav and cross-link it from the cloud deploy note and the
CLI env-var reference.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(lint): strip HTML comments in a fixpoint loop (CodeQL)
Single-pass <!-- --> removal can re-form a complete comment from
adjacent markers (e.g. `<<!-- -->!-- ... -->`), letting a decoy
<template> survive and hijack the template-boundary match. Loop to a
fixpoint, mirroring the captions.mjs precedent; add a regression test
that fails on single-pass (2 root findings) and passes on the loop.
Also wrap the build-frame.mjs node:fs imports to satisfy oxfmt — the
new copyFileSync import pushed the line past the width limit, which
was the sole cause of the Format / Preflight CI failures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(lint): strip HTML comments with a linear scan (CodeQL ReDoS)
The fixpoint loop still ran a /<!--[\s\S]*?-->/ regex per pass, which
backtracks O(n^2) on inputs with many unterminated "<!--" — CodeQL
js/polynomial-redos (high). Looping the same regex (the prescribed
fix) never addressed this; only the regex itself does.
Replace it with an indexOf-based linear strip in utils.ts
(stripHtmlComments), kept in a fixpoint loop so markers that re-form
when a comment is removed are still stripped. 200k unterminated
"<!--" now strips in ~3ms instead of quadratic time; behavior is
otherwise unchanged — unterminated comments are kept verbatim, as the
old regex left them. The re-forming regression test still guards it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): make TTS/BGM sign-in guidance accurate and runnable
From team review of the not-signed-in onboarding:
- OAuth is a `hyperframes auth login` feature only. The separate `heygen`
CLI is API-key-only — `heygen auth login` stores a pasted key, it is not
OAuth and does not create an account. Stop presenting the two CLIs as the
same OAuth/sign-up step.
- Use `npx hyperframes` in every imperative and runtime hint. Bare
`hyperframes` is not on PATH on a fresh machine (command not found); only
`npx hyperframes` is guaranteed. Also updates the JSON recommended_action.
- Drop `heygen auth login` from the terminal/skill onboarding: it needs its
own install and there is no `npx heygen`, so it was a command-not-found
trap. The shared-credential fact stays in the reference docs.
Covers the `auth status` guidance + tests, the Authentication docs, the
shared hyperframes-media preflight (SKILL, requirements, tts, error hints),
and the `npx hyperframes auth status` preflight in every TTS/BGM workflow
(pr-to-video, product-launch-video, faceless-explainer, website-to-video,
music-to-video).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(telemetry): attribute renders to the authoring workflow skill
Add an optional `--skill` flag to `hyperframes render` and tag the
`render_complete` / `render_error` events with `authoring_skill`, so render
usage can be broken down per authoring workflow. The value is slug-gated (a
malformed value is ignored) and the existing anonymous / opt-out telemetry
pipeline is otherwise unchanged.
Each end-user workflow that renders now passes `--skill=<name>` on its render
command: embedded-captions, faceless-explainer, graphic-overlays,
motion-graphics, music-to-video, pr-to-video, product-launch-video,
remotion-to-hyperframes, website-to-video.
Not instrumented, by design: general-video renders freeform with no canonical
render command to attach to, and slideshow produces an interactive deck rather
than a rendered video. Both can follow up if per-skill numbers are wanted.
* fix(telemetry): address review — shared slug util, equals-form flag, invalid-value warning
- Extract the SKILL_SLUG regex + a normalizeSkillSlug() helper into
telemetry/skill.ts, shared by the `events` and `render` commands (the regex
was duplicated). `render` adopts normalizeSkillSlug (so it now trims the value,
matching `events`); `events` references the shared SKILL_SLUG. + unit test.
- `render` warns on a non-empty but invalid --skill value (e.g. a camelCase
typo) so attribution isn't silently lost — stderr only, never fails the render.
- embedded-captions render script: `--skill embedded-captions` -> `--skill=embedded-captions`.
On an older CLI that does not declare --skill, the space form leaks the value
as a positional and clobbers the project dir (resolveProject fails); the equals
form is parsed as a self-delimiting flag and safely ignored. Verified via Node
parseArgs(strict:false).
Addresses review feedback on the PR (shared util + .trim drift, version-skew
safety, invalid-value visibility).
---------
Co-authored-by: kiritowoo <295860553+kiritowoo@users.noreply.github.com>
- Platform-specific install instructions for Linux (apt) and Windows
(releases page / cmake) instead of a generic "see GitHub" fallback
- Export getInstallInstructions so doctor can reuse it
- Add whisper-cpp check to `hyperframes doctor` after the Environment
check — reports path when found, shows install hint when missing
`hyperframes init` only installed AI coding skills on the interactive path
(behind a clack confirm). When an agent drives it non-interactively (no TTY),
it just printed `npx skills add ...` and returned — so skills were never
installed and the agent later hit `Unknown skill: <workflow>`.
- init: both interactive and non-interactive branches now run
`npx skills add` with `cwd` set to the new project dir so skills land
there, not in the caller's working directory. Non-interactive additionally
passes `--yes`; when Claude Code is driving (CLAUDECODE env var), adds
`--agent claude-code` so skills target `.claude/skills/`.
- skills: `runSkillsAdd` accepts `cwd` and `extraArgs` so callers can
control where and how skills are installed.
- templates: CLAUDE.md / AGENTS.md now tell agents to run
`npx skills add heygen-com/hyperframes` to install or update skills.
Co-authored-by: Wenbo Zhu <295860553+kiritowoo@users.noreply.github.com>
gsap_css_transform_conflict existed but missed the most common real-world
shape (a label centered with CSS translateX(-50%) plus a GSAP xPercent that
stacks to -100% in the capture path), for three independent reasons:
- selector matching was exact-string, so a scoped/grouped GSAP selector
("#root .label, #root .sub") never matched a CSS class rule (.label)
- the acorn parser only captures timeline-rooted calls (tl.to/tl.set), so a
standalone gsap.set("#root .label", { xPercent: -50 }) was invisible to it
- lintProject read compositions/ non-recursively, so per-frame compositions
in compositions/frames/*.html were never linted at all
Fix: token-decompose grouped/descendant/compound selectors and match by
id/class against CSS transform rules; additionally scan standalone gsap.*
transform calls; and recurse into compositions/ subdirectories so frame
sub-compositions are linted.
Adds unit tests (grouped gsap.set repro, descendant tl.to, negative case) and
an end-to-end lintProject test that writes compositions/frames/04-*.html and
asserts the conflict is reported there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core): escape digit-leading id selectors in standalone sub-composition preview
A CSS identifier cannot start with a digit, so an authored rule like
`#01-wall-pushes-back { ... }` is an invalid selector and the browser drops
the whole rule — taking the root's size/background with it. A full
composition masks this (the host stretches/paints the frame), but a
standalone preview has no host, so the root collapses to height:0 +
transparent and renders blank.
extractFullDocumentParts now rewrites `#<digit-leading-id>` selectors to
their escaped valid form (`#\30 1-...`, still matching the element id),
scoped to ids actually present and matched only as `#id` not followed by an
ident char so hex colors are never touched. Also harden the <template>
inner-HTML extraction to use the DOM instead of a greedy regex. Tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit e43b377fc0)
* feat(cli): surface captured video clips in asset descriptions
generateAssetDescriptions now reads extracted/video-manifest.json and emits
each downloaded clip first, tagged [video], with its DOM heading/caption and
dimensions — motion clips are usually the strongest hero material and
downstream planners key off the [video] marker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(studio): import commitGsapPositionFromDrag from its actual module
The function was split out into gsapDragPositionCommit.ts in #1605, but
the test kept importing it from ./gsapDragCommit, which no longer exports
it — yielding `is not a function` at runtime. Import from the correct
module to match the production import in gsapRuntimeBridge.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(core): address review nits on standalone sub-composition preview
Review follow-ups (#1631), all non-blocking polish:
- contentExtractor: use path.basename() instead of localPath.split('/').pop()
so video filenames resolve correctly on Windows-style paths too.
- subComposition: document that only the leading digit needs CSS escaping
(CSS Syntax L3 §4.3.11) on escapeLeadingDigitIdent.
- tests: pin three previously-uncovered paths — multiple digit-leading ids in
one composition, a digit-leading id inside compound/combinator selectors, and
the promoteTemplateCompositionId no-op when the <template> has no id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
transcribe hard-failed with cli_error whenever whisper-cpp was absent. On
Linux/Docker/CI (no Homebrew, no compiler toolchain) that is unavoidable, so it
drove ~30k cli_error/day that are really "install the prerequisite" rather than
bugs — and buried genuine transcription failures in the command-error budget.
ensureWhisper now throws a typed WhisperUnavailableError when no binary exists
and none can be built. The transcribe command reports that on a dedicated
transcribe_unavailable metric instead of cli_error, and a new --optional flag
lets pipelines skip captions and exit 0. Real transcription crashes still fail
as cli_error. init and the skill pipelines already continue without captions.
Also removes a stale doc reference to a `transcribe --provider groq` flag that
does not exist.
* fix(slideshow): harden media controls in present decks
* refactor(slideshow): clear Fallow audit findings
Decompose flagged high-CRAP functions and extract production-code
duplications so the audit gate clears.
- core/runtime/bridge.ts handler — replace the 14-branch if-chain with a
CONTROL_HANDLERS dispatch table; flash-elements payload handling moves
to its own helper. Behavior preserved (all existing bridge.test.ts
cases hit the same dispatchers via the public installRuntimeControlBridge
API).
- player/slideshow/SlideshowController syncTo — split into
isValidSyncTarget / isCrossSlide / rerootStackTo helpers. The
stopSlideMedia decision and the stack re-rooting are now individually
named; the public method is a 4-line orchestrator.
- cli/commands/validate.ts run — extract emitJsonReport / emitTextReport
so the orchestrator no longer carries the dual JSON/text branches.
Cuts the cyclomatic complexity flagged by fallow after the
shouldIgnoreRequestFailure signature expansion shifted the fingerprint.
- player/hyperframes-player.ts — _setIframeMediaMuted and _stopIframeMedia
shared a `try { iframeDoc = contentDocument } catch { return }` preamble
(clone group 15). Extract _getSameOriginIframeDocument(): Document | null
and have both call sites consume it.
- studio/panels/SlideshowPanel.tsx — the notes controller's debounce-tail
and explicit flush() shared the pending-drain pattern (clone group 16).
Extract a drainPending() closure both call.
- player/hyperframes-player.test.ts — collapse the new stopMedia / muted
tests' repeated Object.defineProperty(iframe, "contentDocument", { get })
shape behind a stubIframeContentDocument helper.
No behavior changes — refactor only. Existing tests cover the affected
paths unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(validate): split run further; ignore test dup parity
Second Fallow pass surfaced two minor follow-ups after the first cut:
- packages/cli/src/commands/validate.ts run + emitTextReport still
carried minor CRAP findings (43.1 / 37.1, threshold 30). Extract
printValidationResult / formatConsoleEntry / formatTotals /
emitFailureReport so run becomes a try/catch + delegation, well
below the threshold; emitTextReport drops the inline format loops.
- .fallowrc.jsonc duplicates.ignore: add hyperframes-player.test.ts
alongside the existing SlideshowPanel.test.ts entry. Same reasoning
documented there — parallel arrange/act/assert test cases are
intentionally self-contained for readability; collapsing them under
shared fixtures would couple unrelated scenarios (same-origin vs
realm media, audio-locked permutations, seek bridge variants).
No behavior changes — refactor + config-policy parity only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(slideshow): address split-PR review findings on #1585
Genuinely-open findings from the #1580/#1590/#1591/#1592 reviews (the rest were
already fixed on this branch: CSP handlers, manifest version, UUID ids, float
keys, presenter 1s-timer):
core (#1580):
- isManifest rejects a non-object/array manifest (e.g. [42,null]) explicitly
- resolveSlideshow flags duplicate slideSequence ids instead of silent overwrite
player (#1590):
- present() window.open uses noopener,noreferrer (audience syncs via channel)
- BroadcastChannel name is per-deck (keyed on pathname) to avoid same-origin
cross-talk between decks
- add observedAttributes + attributeChangedCallback so runtime sound/mode toggles
re-render
studio (#1591/#1592):
- persistSlideshowManifest no-op gate (skip write when HTML is unchanged)
- surface persist failures (console.error) instead of silent .catch(()=>{})
- confirm before deleting a branch sequence (data-loss + dangling hotspots)
+ tests for the collision + non-object-manifest rejection. 20 core / 106 player /
53 studio pass; tsc/lint/fmt/fallow clean; deck still renders.
* fix(slideshow): finish remaining split-PR review findings
The larger items from the #1580/#1590/#1591/#1592 reviews (the rest landed in
#1585):
core (#1580):
- dedup isSceneLikeCompositionId — shared slideshow/sceneId.ts, used by both the
lint rule and the runtime scene-window computation (no more mirror-and-drift)
player (#1590 / #1592):
- onKey: when multiple decks share a page, drop the unfocused-convenience so a
key drives only the focused deck
- slow-iframe recovery: if the scene timeline posts after the wait times out
(empty scenes), re-init once so sceneId slides resolve instead of being dropped
studio (#1591):
- persistSlideshowManifest validates the built island round-trips before writing
- reorderBranchSlide helper + BranchTree up/down controls (parallel to main-line
reorder), with a branch-position indicator
+ tests for reorderBranchSlide. core 228 / player 106 / studio (panel) 46 pass;
tsc/lint/fmt/fallow clean.
* fix(player,cli): use fileURLToPath for path resolution (Windows CI)
new URL(...).pathname yields a leading-slash drive path ("/D:/...") on Windows,
which broke:
- packages/player/vitest.config.ts — the @hyperframes/core/slideshow alias
resolved to a nonexistent path, failing the player slideshow tests on the
Windows render-verification CI (passed on macOS/Linux where pathname is clean)
- packages/cli/src/utils/compositionServer.ts helperDir — same bug in the
play/present bundle-path resolution
fileURLToPath converts file:// URLs to correct OS paths on all platforms.
Player slideshow tests pass; present serves + resolves bundles.
* fix(producer): fileURLToPath for the renders dir (Windows)
DEFAULT_RENDERS_DIR used new URL(import.meta.url).pathname, which is "/D:/..."
on Windows and resolves to a bogus path — affects the Windows render pipeline.
Last of the .pathname -> fileURLToPath fixes (repo-wide src sweep now clean).
* fix(slideshow): address code-review findings #1580-1584
- player: bundle @hyperframes/core into the IIFE/global build (noExternal)
- player: resolve audience mode from ?mode=audience URL query, not just attr
- player: event-driven waitForScenes + loud failure when no slides resolve
- player: scope window keydown so Space/Backspace don't hijack the host page
- player: audience mirrors full position (branch + fragment) via syncTo
- player: next() reveals remaining fragments even at slide end; enterBranch ignores empty sequences
- core: harden extractScenes against null/non-object scene entries
- core: strict manifest validation; error on inverted ranges & empty hotspot targets; dedup fragments
- core/lint: accept data-end/timeline-derived scene durations (match runtime)
- core+studio: share ISLAND_TYPE + island regex from @hyperframes/core/slideshow
- studio: SlideList reflects manifest slide order; branch-slide authoring (notes/fragments/hotspots)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(player): slideshow fullscreen + presenter-view rework
- fullscreen toggle in the nav chrome (button + 'F' key); standard Fullscreen
API on the <hyperframes-slideshow> element, icon reflects state
- presenter console: live slide on top, speaker-notes panel below, with the nav
controls shown in-view; Present button hides once presenting (harness)
- audience (viewer) window: chrome reduced to a fullscreen-only control, no nav
- fix: audience / back() / backToMain() mirror stayed frozen on the first frame —
a bare paused seek does not repaint some compositions. resumeSlide now plays a
brief render-nudge (RENDER_NUDGE) past the target so the composition paints,
then onTime pauses at the hold
- refactor: extract reusable buildNavCluster() + wireChromeButtons(); rework
buildPresenterLayout into the bottom notes panel
- example: airbnb-deck presenter-test.html harness (Present button + 'F')
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(player): slideshow no auto-progress + presenter slide fits/pins
- navigation jumps to a static frame instead of auto-playing the timeline:
playTo() seeks to the hold (+ a brief RENDER_NUDGE to repaint) rather than
sustaining playback, so slides hold until the user advances
- presenter view: pin the live slide to the top and confine the player to the
region above the notes panel, so the player CONTAINS the composition — the
full slide stays visible (letterboxed) at any width and re-fits on resize;
its bottom is no longer cut off by the notes panel
- tests: seek targets updated for the render-nudge offset
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): presenter nav flash, slide-1 boundary, branch buttons
Three presenter-mode fixes from testing the airbnb deck: (1) navigation flash — seek to the exact target then play forward to repaint, instead of seeking backward (t-0.2) which painted the previous scene at boundaries; split hold into holdTarget (logical) and holdAt (target+nudge, clamped to slide.end). (2) slide-1 boundary — no-fragment slides rest at the slide midpoint, not slide.end. (3) presenter branch buttons — surface hotspots as buttons in the presenter console (the on-slide pill is lost in the letterboxed view). Also extract paintChrome() to dedupe the three chrome-render sites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): stop presenter nav buttons flickering / dropping clicks
The presenter elapsed clock called render() every second, which rebuilt the
entire chrome (innerHTML) including the nav buttons — they flickered and any
click landing mid-rebuild was lost. The 1s tick now updates only the elapsed
text node; the nav buttons are rebuilt only on actual navigation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): CSP-safe nav hover, UUID editor ids, manifest version
Addresses review feedback on the split stack:
- CSP: replace the 8 inline onmouseover/onmouseout handlers on the nav
buttons with a [data-hf-nav-cluster] button:hover CSS rule (injected once
per document). No inline event handlers → works under strict CSP.
- IDs: studio sequence/hotspot id generation used Date.now() (sub-ms
collision on rapid clicks) — now crypto.randomUUID().
- Versioning: stamp version on the persisted manifest island (preserving an
existing one); add the optional version field + SLIDESHOW_MANIFEST_VERSION
to the core schema so future schema changes can migrate older islands.
These live on the review-fixes tip (consistent with the stack's fixup-on-tip
model); the touched code belongs to ss-player-b (#1590), ss-studio-a/b
(#1591/#1592), and ss-core (#1580).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(ci): fix format + fallow gates for slideshow stack
- .prettierignore: exclude generated demo compositions (registry/examples/**/*.html)
from oxfmt — large video-pipeline output (GSAP/Three/WebGL), not hand-authored
source. Was failing 'Format' repo-wide (pre-existing on main via #1584).
- .fallowrc: exempt SlideshowPanel.tsx (health/complexity — section fan-out) and
the slideshowPanelHelpers.ts / SlideshowPanel.test.ts parallel-structure clones
(duplicates.ignore). File-level config, not inline comments — inline shifts line
numbers and breaks fallow's inherited-finding fingerprint (per existing rc note).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): address PR review + CodeQL findings
- CodeQL #638 (parseSlideshow): complete the regex metachar escape in
slideshowIslandRegex (was missing backslash); add JSDoc on the factory +
lastIndex caveat (reviewer 5a/16).
- CodeQL #639/#640 + review items 13/17: remove registry/examples/airbnb-deck/
presenter-test.html — a generated test harness (postMessage w/o origin check,
proto-pollution) that was scope-creep into a fix PR and a 3rd duplicate island.
Regenerate locally via the scratchpad script when testing.
- Review item 15 (docs drift in skills/slideshow/SKILL.md): lint resolves scenes
by data-composition-id only (not .clip[id]); fragments are valid INCLUSIVE of
[start,end], not 'strictly inside'.
IIFE bundles core confirmed (0 external @hyperframes/core refs in the slideshow
global build). format/lint/fallow green.
* feat(cli): add 'present' command — serve a deck in presenter mode
hyperframes present [dir] starts a lightweight HTTP server, wraps the
composition in <hyperframes-slideshow> with its island inlined, and opens
the browser. A real HTTP origin is required for presenter mode: present()
opens the audience window via window.open(?mode=audience) and the two sync
over BroadcastChannel — neither works from file://.
- New utils/compositionServer.ts factors the server scaffolding shared with
'play' (resolve runtime/player/slideshow bundles, inject runtime, asset
content-types, bind to a free port); play.ts now uses it too.
- Errors clearly if the deck has no slideshow island.
- .fallowrc: exempt the play/present command entrypoints (validation + server
wiring) and the per-command startup/logging block from the complexity /
duplication gates.
Verified end-to-end against registry/examples/airbnb-deck: server serves the
wrapper + assets, the component binds and renders (counter 1 / 11).
* fix(cli): present renders the deck (player sizing + self-driving serve)
Two bugs caused a black slide area:
- The <hyperframes-player> had no positioning, so its iframe collapsed to
zero size — the (absolutely-positioned) chrome showed but the composition
didn't. Add position:absolute; inset:0 (matches demo.html).
- The composition was served with the engine runtime injected, which leaves
its timelines engine-paused (blank). Slideshow decks self-drive their own
timelines (like demo.html / the standalone harness), so serve them raw.
Verified end-to-end on registry/examples/airbnb-deck: cover renders, Next
advances 1/11 -> 2/11 and slide 2 paints.
* fix(cli): present plays slideshow sound effects
The composition (in the player's sandboxed iframe) posts
{ type: 'hf-sfx', name } to the parent on nav, but the iframe is
autoplay-blocked — audio must play in the parent that owns the user gesture.
Add the parent-side hf-sfx handler (the 4 standard clips advance/fragment/
branch-enter/back, served from the deck's sfx/ under /composition/sfx/),
gesture-unlocked and mute-aware, in both presenter and audience windows.
Verified: sfx serve 200 (audio/mpeg) and Next delivers [advance, fragment]
to the parent handler.
* feat(examples): softer mellow slideshow sfx for airbnb-deck
Replace the aggressive percussive pops with gentle sine-tone cues (warm
pitches C5/G4/E5/F4, 12ms attack + exponential decay, lowpassed) — advance/
fragment/branch-enter/back. Much lighter; fragment is the most subtle.
* feat(examples): whoosh + sparkle slideshow sfx for airbnb-deck
Replace the sine-tone cues with airy, designed sounds:
- advance: a soft whoosh (band-limited pink noise, bell-shaped swell)
- back: that whoosh reversed and darkened
- fragment: a light sparkle (staggered high chime blips)
- branch-enter: whoosh + a trailing sparkle (magical entry)
* feat(examples): directional whoosh + richer branch-enter cue (airbnb-deck)
- Going backward a slide now plays the reverse whoosh (back), not advance —
the sfx logic detects nav direction by scene order instead of firing advance
for every scene change.
- branch-enter is now a more interesting magical cue: a faint whoosh + an
ascending C5-E5-G5-C6 chime arpeggio + a trailing sparkle.
Verified: next then prev fires [advance, fragment, back]; no page errors.
* fix(cli): harden present sfx handler + mute-hover affordance (R2 review)
Addresses Rames R2 items 19-21:
- 20: the present audio handler reintroduced the CodeQL classes removed with
presenter-test.html — add an origin check (same-origin composition iframe)
and an own-property guard so a 'name' like __proto__ can't resolve to and
mutate Object.prototype.
- 21: assetContentType used a bare index lookup (ext='__proto__' -> prototype);
guard with Object.hasOwn.
- 19: the CSP hover rule erased the speaker button's muted color; add a
higher-specificity [data-hf-muted] [data-hf-mute]:hover override.
Verified: hf-sfx origin matches location.origin (guard passes), advance/fragment
still fire, deck renders + advances. Items 14/18/22 deferred (minor, pre-existing).
* fix(slideshow): address remaining R2 items (14/18/22) + re-remove harness
- 14: resumeSlide now mirrors enterSlide — a no-fragment slide resumes at its
midpoint (visible-at-rest), not frame-0; fragmented slides still resume to the
saved fragment or slide.start. Added a dedicated test naming the heuristic.
- 18: fullscreenchange swaps only the fullscreen glyph + aria (hoisted SVGs to
module consts) instead of re-rendering the whole chrome.
- 22: .prettierignore lists the specific generated demo compositions instead of
blanket registry/examples/**/*.html, so hand-authored example HTML still formats.
- presenter-test.html: a stray 54a4460 git add -A had re-added the deleted
harness (reviving CodeQL #639/#640); remove it again.
106 slideshow tests pass; tsc/lint/fallow/format clean; deck still renders.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(engine): static-frame dedup for screenshot capture (opt-in)
Skip re-seeking + re-screenshotting frames byte-identical to their predecessor. A
frame is dedupable iff no GSAP tween or clip cut is active in it or its predecessor
(predicted from window.__timelines + clip schedule) AND an empirical anchor-compare
confirms it. Opt-in HF_STATIC_DEDUP=true, default off.
Correctness (designed for the multi-worker / distributed render paths):
- Reuse is keyed by the ABSOLUTE composition frame (derived from the frame's time),
NOT the captureFrameCore frameIndex arg — chunked/parallel callers pass a chunk-
relative index. Validated lossless (PSNR=inf) on both single- and multi-worker
renders of a static-hold comp.
- verifyStaticFramesSafe checks EVERY run (no longest-first budget truncation that
left runs armed-but-unverified), and samples each run's FIRST reused frame, its END,
and interior points at a stride; a hard cap disables dedup rather than trust an
unverified set.
- Conservative arming: skipped when capture mode != screenshot (BeginFrame tick
semantics + the verifier's screenshot path wouldn't transfer), when a before-capture
hook is set (per-frame video injection), when page-side compositing is active (shader
/ drawElement composite the plain verification screenshot can't reproduce), and when
any data-start is a non-numeric reference expression the clip-boundary parser can't
protect, or duration is unknown/zero.
- Session reuse (prepareCaptureSessionForReuse) resets lastFrameBuffer + dedup counter
so a probe/prior-render buffer can't bleed into the first static frame; the armed set
is kept (same-composition reuse). Cost calibration bypasses dedup for its sparse,
non-contiguous sample sweep, then restores the armed set.
- HF_STATIC_DEDUP_SAMPLES is NaN-guarded.
Disqualifies on signals the GSAP predictor can't see: video, canvas/webgl, zero
tweens, running CSS/WAAPI animation. Pays on static-hold content (title cards,
slideshow/kiosk loops, data-viz pauses); no-op on continuously-animated comps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(engine): static-frame dedup default-on + render telemetry
Flip dedup from opt-in (HF_STATIC_DEDUP=true) to default-on (opt-out
HF_STATIC_DEDUP=false). Verification (verifyStaticFramesSafe) is the
safety net that keeps reuse sound at scale.
Add end-to-end dedup observability. The capture session records
enabled / armed / skipReason / predicted; these surface via
CapturePerfSummary -> a dedupPerfs accumulator (disk sequential +
parallel AND streaming sequential + parallel) -> aggregated into
RenderPerfSummary.staticDedup (OR armed, SUM frames across workers) ->
render_complete props static_dedup_{enabled,armed,skip_reason,
predicted_frames,reused_frames}. skip_reason is a low-cardinality code:
capture_mode | video_injection | page_composite | ineligible |
verification_failed.
Distributed chunks run on Linux/beginframe where dedup never arms, so
they pass a throwaway dedupPerfs sink (no per-chunk reporting).
Tests: aggregation logic (OR/SUM/skip-reason) + opt-out passthrough.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(engine): address review on dedup default-on + telemetry
Review feedback (miga-heygen) + self-review fixes:
- Retry double-count: executeDiskCaptureWithAdaptiveRetry pushed worker
dedup perf inside the retry loop, so an adaptive retry counted frames
twice (reused/predicted could exceed totalFrames). Reset dedupPerfs at
the start of each attempt — retry now REPLACES rather than accumulates;
common no-retry path is unchanged.
- Opt-out parsing: HF_STATIC_DEDUP now disables on {false,0,off}
case/space-insensitive (was strict !== "false", so `False`/`0` silently
kept dedup on — the kill-switch could no-op).
- Verification budget vs drift: verifyStaticFramesSafe returns
{badFrame, budgetExhausted}; armStaticDedup reports a distinct
`verification_budget` skip reason so a telemetry spike means "raise
HF_STATIC_DEDUP_SAMPLES", not "compositions are non-static".
- Index idiom: captureFrameCore now uses Math.floor(time*fps + 1e-9)
(matches quantizeTimeToFrame) so the dedup lookup agrees with the frame
the seek lands on even for non-exact times.
- Stale "opt-in HF_STATIC_DEDUP=true" comments -> "opt-out
HF_STATIC_DEDUP=false" across frameCapture.ts + types.ts.
- Extract pushWorkerDedupPerfs helper (perfSummary.ts), used by the disk
and streaming parallel paths — removes the duplicated push loop and
drops captureStreamingStage back under the complexity threshold.
- dedupPerfs is now required (not optional) on
executeDiskCaptureWithAdaptiveRetry — a missing arg silently dropped
telemetry.
- Test: captureStreamingStage createInput() now provides the required
dedupPerfs field.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(engine): address deferred dedup-review items
- Derivable state: drop session.staticDedupArmed/staticDedupPredicted;
derive both from session.staticFrames in getCapturePerfSummary
(armed ⟺ non-empty set, predicted === size) so they can't desync.
- Config altitude: HF_STATIC_DEDUP now resolves into
EngineConfig.staticFrameDedup (resolveConfig, opt-out on {false,0,off}),
alongside forceScreenshot/browserGpuMode — armStaticDedup reads config
instead of process.env. Default-on preserved (missing config → enabled).
- Lossy aggregation: aggregateDedup now reports DISTINCT skip reasons
(sorted, `|`-joined) across diverging unarmed workers instead of just
the first.
- discardWarmupCapture: also snapshot/restore staticDedupCount and
lastFrameBuffer so a warmup capture can't leak a phantom reuse or a
stale buffer anchor into the real summary.
- Convention: perfSummary-dedup.test builds its job via createRenderJob
instead of `as unknown as RenderJob`.
- Docs: verification_budget added to skip-reason lists.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The render pre-flight check shells out to `which ffmpeg`, which only
searches the server process's PATH. When Studio is launched from a
GUI/Dock/launchd context that PATH lacks /opt/homebrew/bin, so `which`
fails even when ffmpeg is installed — and POST /render returns 503
"FFmpeg not found".
Fall back to probing well-known install dirs (Homebrew on Apple Silicon
and Intel, plus system/Linux locations) when the PATH lookup fails.
Also drop the [kf:static]/[kf:runtime] keyframe diagnostics that were
spamming the Studio console in prod, and fix two unrelated CI breakages
the branch inherited: a Windows-sensitive ffmpeg test (pin platform) and
a stale player test mock missing onRuntimeReady.
`hyperframes capture <url>` (no -o) used to dump into `./captures/<hostname>/`,
which buries the project two levels deep and silently merges re-runs into the
previous dir — file-by-file, so leftover screenshots / assets from the prior
run stay mixed in and any later `glob` sees both.
Switch the default to `./capture/`. When it already exists, auto-suffix to
`./capture-2/`, `./capture-3/`, … (up to -99). Each capture is its own clean
directory — no crud, no friction, no clobber. The CLI prints a one-line note
when the suffix kicks in so the user sees which dir actually got written.
Explicit `-o <name>` is unaffected (still overwrite-tolerant).
* fix(lint): promote rules to errors with registry exemptions and false-positive fixes
- Export isRegistrySourceFile/isRegistryInstalledFile from composition.ts
- Add registry exemptions to google_fonts_import and font_family_without_font_face
- Add registry exemption to requestanimationframe_in_composition
- Fix timed_element_missing_clip_class: data-track-index alone no longer triggers
- Fix caption_transcript_parse_error: balanced-bracket scanner replaces non-greedy regex
- Fix missing_timeline_registry: skips sub-compositions and template-wrapped files
- Fix scene_layer_missing_visibility_kill: strip JS comments before pattern matching
- Fix gsap_css_transform_conflict: exempt from() alongside fromTo()
- Fix gsap_from_opacity_noop: only fires when opacity value is actually 0
- Add regression test for data-track-index-only elements
* test(lint): add regression tests for false-positive fixes
Covers the 7 missing negative-case assertions flagged in PR review:
- registry marker suppresses google_fonts_import + font_family_without_font_face
- registry marker suppresses requestanimationframe_in_composition
- isSubComposition suppresses missing_timeline_registry
- scene_layer_missing_visibility_kill: fires, commented-kill fires, real kill suppresses
- gsap_css_transform_conflict: from() exempt alongside fromTo()
- gsap_from_opacity_noop: non-zero opacity (e.g. 0.5) is a valid reveal, not a noop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(examples): fix warm-grain template to pass promoted lint rules
- index.html: remove undeclared "Lexend" from font-family stack
- intro.html: replace Google Fonts @import with bundled Inter font
- captions.html: quote TRANSCRIPT keys for valid JSON + use Inter font
Fixes CLI smoke CI failure after google_fonts_import, font_family_without_font_face,
and caption_transcript_parse_error were promoted from warning to error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): resolve warm-grain from repo registry in dev mode + bundle at build
getStaticTemplateDir now falls back to registry/examples/<id> in dev mode
so CI smoke tests use the PR-branch copy instead of fetching from main.
build-copy.mjs copies warm-grain to dist/templates/warm-grain at build time
so packed CLIs can scaffold it offline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(examples): remove trailing comma from warm-grain TRANSCRIPT array
JSON.parse rejects trailing commas (valid JS, invalid JSON).
caption_transcript_parse_error was still firing because of the comma
on the last entry after quoting all keys.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>