Commit Graph
1442 Commits
Author SHA1 Message Date
Miguel Ángel 72a938e704 fix: include pip sub-composition source file 2026-05-26 14:25:39 -04:00
Miguel Ángel 48b85d6cff test(producer): add pip-video-late-host regression test
Renders a 6s composition with a pip video inside a sub-composition
host starting at t=3. Verifies the video is visible during the host
window and not double-offset to t=6. Baseline generated in Docker.
2026-05-26 14:25:39 -04:00
Miguel Ángel 1d0b18587d fix(core): extend media start fix to all consumers, guard auto-start
Narrow the raw data-start read to media elements without
data-hf-auto-start (explicitly authored global coordinates). Elements
with auto-injected data-start="0" remain composition-local via the
resolver. Apply consistently across all three consumers:
- visibility loop (init.ts)
- refreshRuntimeMediaCache start/duration (init.ts)
- resolveMediaWindowEndSeconds (timeline.ts)

Add regression test for auto-injected data-start="0" inside a
late-starting host to prove it doesn't regress.
2026-05-26 14:25:39 -04:00
James XiaoandClaude Sonnet 4.6 1be2a584b4 fix(core): use raw data-start for media elements in preview visibility loop
For video and audio elements, data-start is authored in global (composition-root)
time — the same contract used by the render pipeline's discoverMediaFromBrowser,
which reads the raw attribute directly. Previously, the visibility loop called
resolveStartForElement which adds the nearest ancestor composition's global start
on top, causing a double-offset that kept pip-wired media permanently hidden when
the host composition did not start at t=0.

Example: a pip video with data-start="45.40" inside a host composition that also
starts at data-start="45.40" resolved to 90.80, so the video was always hidden
during its actual [45.40, 52.46] window.

Non-media elements (divs, sections, etc.) continue to use the accumulating
resolver because their data-start values are local to their composition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 14:25:39 -04:00
Miguel Ángel 0e052e42d2 fix(engine): support AMD AMF GPU encoding 2026-05-26 13:35:55 -04:00
Lirian Su 9a7b3efa94 fix(cli): align snapshot's local InjectFn return type with engine
`injectVideoFramesBatch` now returns `Promise<string[]>` so the caller can
filter cache entries to videos the page actually painted. The cli-side
snapshot command does not use the return value, but its local `InjectFn`
declared `Promise<void>` which made the `as { injectVideoFramesBatch:
InjectFn }` cast on the dynamic engine import fail typecheck under TS's
"sufficiently overlapping types" rule. Match the engine's actual export
shape.
2026-05-26 00:37:13 -04:00
Lirian Su f89c17fd81 fix(engine): harden ancestor-hidden video skip against mask + caller cache
Two follow-ups to the ancestor-visibility skip in `injectVideoFramesBatch`
and `syncVideoFrameVisibility`.

1. **Mask defence.** Both ancestor-hidden branches previously wrote a plain
   `img.style.visibility = "hidden"`. `applyDomLayerMask` writes the
   stylesheet rule `#${showId} *{visibility:visible !important}`, and CSS
   cascade puts important stylesheet author above non-important inline
   author — so a sub-comp host landing in the active layer's `show` set
   would revive a stale `__render_frame__` and let it bleed onto the
   layer composite. Write the hide via
   `style.setProperty("visibility", "hidden", "important")` instead;
   important inline beats important stylesheet.

2. **Caller cache hygiene.** `createVideoFrameInjector` unconditionally
   wrote `lastInjectedFrameByVideo.set(id, frameIndex)` after calling
   `injectVideoFramesBatch`, even for videos the page silently skipped due
   to a hidden visual ancestor. On the next call at the same frameIndex —
   common with source-fps < output-fps, paused source frames, or
   non-frame-aligned host starts — the cache short-circuited the second
   inject and the host's first visible frame painted blank because the
   replacement `<img>` was never created.

   Make `injectVideoFramesBatch` return `string[]` (the subset of ids it
   actually painted) and have the caller cache only those. The cli-side
   `snapshot.ts` consumer is unaffected: its local `InjectFn` types the
   return as `Promise<void>`, which is structurally compatible with
   `Promise<string[]>` under TS void-return assignment rules.

Tests: linkedom doesn't preserve `!important` in cssText, so the two new
mask-defence cases spy on the live `<img>`'s `style.setProperty` and assert
the 3-arg call shape. The cache-hygiene case stubs the page-side primitives
via `vi.mock`, drives the hook twice at the same frameIndex with a stubbed
"injected nothing" first response, and verifies the second call still
issues an inject. A counter-test pins the happy-path cache hit so a future
refactor can't trade the skip bug for a never-cache regression.
2026-05-26 00:37:13 -04:00
Lirian Su f3bb6dc125 fix(engine): narrow visibility:hidden ancestor skip to sub-comp hosts
`isVisualAncestorHidden` was treating any `visibility: hidden` ancestor as a
signal to skip injecting the replacement frame. That's too broad — for plain
`[data-start]` containers, the replacement `<img>`'s explicit
`visibility: visible` correctly overrides the ancestor per CSS spec, and
consumers rely on that to hold the final GSAP-driven frame when an authored
`data-duration` outlives the composition's GSAP timeline (e.g.
`style-9-prod`, where the runtime truncates the host to `visibility: hidden`
after the timeline ends and the replacement frame must paint through).

Restrict the `visibility: hidden` skip to ancestors that carry
`data-composition-src` or `data-composition-file` — the actual sub-composition
hosts this guard was added for. `display: none` keeps the broad behavior:
it takes the whole subtree out of layout and a child override cannot escape.

Update the existing regression suite to mark the host as a sub-composition,
and add two new cases pinning the plain-`[data-start]` behavior: both
`injectVideoFramesBatch` and `syncVideoFrameVisibility` must still produce a
visible replacement `<img>` when the host is `visibility: hidden` but does
not carry a sub-composition attribute.
2026-05-26 00:37:13 -04:00
Lirian SuandClaude Opus 4.7 68ade6609f chore(engine): drop stale fork-branch reference from test comment
The screenshotService.test.ts regression-suite comment pointed at the
author's fork branch as backstory. Strip the line so upstream code
doesn't carry a fork-relative reference; the surrounding paragraph
already explains the bug end-to-end without it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:37:13 -04:00
Lirian SuandClaude Opus 4.7 3700cc2a16 fix(engine): skip video frame injection when a visual ancestor is hidden
`injectVideoFramesBatch` and `syncVideoFrameVisibility` iterate every
`video[data-start]` whose raw time window covers the current seek.
Inner `<video>` elements inside `[data-composition-src]`
sub-compositions get `data-start="0"` auto-injected by
`compileTimingAttrs` and probed-duration cover the entire timeline,
so they look "active" even when their host has not yet started.

When the runtime then hides the host with `visibility: hidden` (its
out-of-window lifecycle), the inner video inherits hidden via the CSS
cascade — but our injector responded by painting a replacement
`<img class="__render_frame__" style="visibility: visible">` next to
the video. `visibility: visible` on the descendant defeats the parent
`visibility: hidden` cascade, and because the host has not been
morphed by GSAP yet the video's bounding box is its CSS default
(usually full-bleed). The result is one full-bleed frame per inactive
sub-comp painted over whichever moment is *actually* visible — the
overlay symptom the upstream agentic-finecut project saw.

Walk ancestors in both functions; if any has `display: none` or
`visibility: hidden`, skip the inject and hide any stale
`__render_frame__` sibling. The render is now correctly empty for
hidden hosts, which is what the surrounding CSS cascade already
intends.

Tests:
- `screenshotService.test.ts`: cover the new guard for both
  visibility:hidden and display:none hosts, both for the fresh-img and
  the stale-img paths, plus `syncVideoFrameVisibility` for the case
  where the time window calls a video "active" but a hidden ancestor
  still requires its frame to stay hidden. Each test fails against
  pre-fix `screenshotService.ts`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:37:13 -04:00
Miguel Ángel 60cb9552e4 chore: release v0.6.46 v0.6.46 2026-05-25 23:49:33 +00:00
Miguel Ángel 66e90b7ad8 fix(studio): remove rootRect subtraction from overlay position formula
elementRect.left/top from getBoundingClientRect() already reflects GSAP
transforms in viewport coordinates. Subtracting rootRect.left/top
cancels the transform, pinning overlays to the un-animated layout
position. Use elementRect directly so overlays track elements during
scroll (y: -500) and entrance (scale: 0.95) animations.
2026-05-25 19:47:53 -04:00
Miguel Ángel 825c0aa194 fix(studio): use declared dimensions for overlay scale during GSAP playback
When GSAP applies transforms (scale, translate) to the root composition
element during playback, rootRect.width/height from getBoundingClientRect()
changes to reflect the transformed size. The overlay scale calculation
(rootScaleX/Y = iframeRect / rootRect) then produces wrong values,
causing overlays to appear at incorrect positions during animated
playback — especially visible during scroll animations (y transform)
and entrance animations (scale transform).

Fix: use the composition's declared data-width/data-height attributes
for scale calculation. These are the canonical dimensions that don't
change with GSAP transforms. Falls back to rootRect dimensions when
the attributes aren't present (non-composition elements).
2026-05-25 19:47:53 -04:00
Miguel Ángel 07d14553c9 fix(studio): clamp loopEnd to duration so RAF boundary stays reachable
When outPoint exceeds composition duration, rawLoopEnd > dur makes the
time >= loopEnd branch unreachable after the playhead clamp — the player
ticks forever. Clamp rawLoopEnd to dur in both forward and backward RAF
loops, matching the seek() clamping. Add test for the boundary behavior.
Trim blank lines to satisfy 600-line filesize gate.
2026-05-25 19:18:46 -04:00
Miguel Ángel 1aaf89ce70 fix(studio): clamp playhead to composition duration in RAF loop
The studio player's RAF loop in useTimelinePlayer notified the playhead
position via liveTime.notify(time) before checking the duration limit.
When adapter.getTime() returned a value past the composition's
data-duration (due to timing drift or delayed duration calculation),
the playhead would visually overshoot — showing e.g. 0:19 on a 0:10
composition.

The web player component already had this clamping (playback-state.ts
line 42, direct-timeline-clock.ts line 56), but the studio player's
forward loop was missing it.

Fix: clamp time to dur before notifying, matching the pattern already
used in the web player: Math.min(rawTime, dur) when dur > 0.
2026-05-25 19:18:46 -04:00
Miguel Ángel 9a4a00582c chore: release v0.6.45 v0.6.45 2026-05-25 19:45:52 +00:00
Miguel Ángel 0de17a1f66 fix(core): restore IIFE with </script> escaping for composition scripts
The Function constructor (3bb0d1ef) was a security hardening to prevent
</script> injection, but it broke sub-composition DOM proxy scoping.
This restores the inline IIFE (preserving closure scope) while adding
</script> → <\/script> escaping to maintain the injection prevention.

Updates tests to match the new IIFE output shape.

Closes #1074
2026-05-25 15:44:35 -04:00
Miguel Ángel ccee8df4e1 fix(core): revert Function constructor back to inline IIFE for composition scripts
The Function constructor (3bb0d1ef) breaks sub-composition scripts that
call document.getElementById() — the constructor creates functions with
global scope, losing access to the composition-scoped DOM proxy. Native
method calls on proxy-returned elements throw "Illegal invocation".

Reverts to the inline IIFE that preserves the closure over __hfScoped*
variables. The original motivation (handling </script> in source) is
already handled by the compiler's script bundling path.

Closes #1074
2026-05-25 15:44:35 -04:00
AnoKno 0ea8aa4ffa fix(cli): address PR #983 review feedback
- play.ts: move --remote-debugging-port parse+deps validation before any
  server setup so an invalid value exits cleanly instead of leaking a
  listening socket (the original bug — server printed 'Player running'
  and 'Press Ctrl+C to stop' before failing).
- Extract validateRemoteDebuggingPortDeps() in openBrowser.ts to keep
  preview.ts and play.ts in sync instead of copy-pasting the dep
  checks.
- Narrow parseRemoteDebuggingPort param to string | undefined; drop the
  dead null branch and the redundant String() / Number.isInteger() now
  that the regex already constrains the input.
- buildBrowserArgs: omit --remote-debugging-port when userDataDir is
  missing so a CDP endpoint cannot leak into the user's main profile
  even if a caller bypasses the CLI validation layer.
- Replace the duplicated buildBrowserArgs case with one that proves
  this defense-in-depth behaviour; add unit tests for
  validateRemoteDebuggingPortDeps.
- Drop the heavy JSDoc on parseRemoteDebuggingPort to match the file's
  surrounding style.
- Both commands: align --remote-debugging-port description (it now
  matches the actual 'requires --browser-path and --user-data-dir'
  contract) and add a CDP example to the --help output.
2026-05-25 15:38:41 -04:00
AnoKno 3902a9a82b feat(cli): add remote debugging port option
Adds a Chromium remote debugging port flag for preview and play.

The flag is only passed when launching an explicit browser/profile.

HyperFrames still does not own CDP automation.
2026-05-25 15:38:41 -04:00
Miguel Ángel 28f948aa7d fix(engine): use static import for execSync, unify VRAM cap at 16GB
- Replace require("child_process") with static import (same ESM fix
  as config.ts — require is undefined in native ESM)
- Unify cap: both VRAM probe and heuristic paths now cap at 16GB
- Add comment noting the one-time blocking execSync is cached
2026-05-25 15:37:13 -04:00
Miguel Ángel 486c204609 fix(engine): probe nvidia-smi for actual VRAM before falling back to heuristic
On NVIDIA systems, spawns nvidia-smi once (cached) to read actual GPU
memory. Uses real VRAM for the Chrome GPU budget instead of guessing
from total system RAM. Falls back to total/2 on non-NVIDIA systems or
when nvidia-smi is unavailable.

No other headless Chrome renderer probes GPU memory — Remotion, Puppeteer,
and Playwright all ignore --force-gpu-mem-available-mb entirely.
2026-05-25 15:37:13 -04:00
Miguel Ángel 335a105ef4 fix(engine): remove 4096MB GPU budget ceiling for high-RAM systems
Scale GPU budget to half of total RAM (capped at 16GB) instead of
hardcoding 4096MB. A 32GB machine now gets 16GB GPU budget; a 64GB
machine gets 16GB (Chrome's practical limit). Low-memory tiers unchanged.
2026-05-25 15:37:13 -04:00
Miguel Ángel 0620fa908d fix(engine): use static import for os.totalmem in ESM config
Replace dynamic require("os") with static import — require is undefined
in native ESM, causing the try/catch to silently return the 16GB
fallback on every machine. The cache scaling was dead code.
2026-05-25 15:37:13 -04:00
Miguel Ángel c8ed90fa21 fix(engine): use totalmem() instead of freemem() for memory scaling
Addresses review feedback: freemem() is misleading on macOS where
aggressive file caching reports low free memory even on high-spec
machines. Switched to totalmem()-based thresholds consistent with
calculateOptimalWorkers in parallelCoordinator.ts.

Thresholds now based on total RAM:
- <4GB total: GPU=512MB, V8=256MB, cache=32/128MB
- <8GB total: GPU=1024MB, V8=512MB, cache=64/256MB
- >=8GB total: unchanged (4096MB GPU, no V8 cap, 256/1500MB cache)
2026-05-25 15:37:13 -04:00
Miguel Ángel 2015d93d2a fix(engine): scale Chrome memory budget and frame cache to available RAM
On low-memory systems (<4GB free), Chrome's --force-gpu-mem-available-mb=4096
causes the renderer to allocate more GPU texture memory than the system can
provide, leading to OOM crashes during frame capture ("Target closed").

Changes:
- Scale --force-gpu-mem-available-mb to match available system RAM
  (512MB when <2GB free, 1024MB when <4GB, 4096MB otherwise)
- Add --js-flags=--max-old-space-size=N on low-memory systems to cap
  Chrome's V8 heap (256MB when <2GB free, 512MB when <4GB)
- Scale frame data URI cache defaults: 32 entries/128MB when <2GB free,
  64 entries/256MB when <4GB, unchanged otherwise

Closes #1072
2026-05-25 15:37:13 -04:00
Miguel Ángel c46adb52e2 chore: release v0.6.44 v0.6.44 2026-05-25 16:56:50 +00:00
Miguel Ángel 5fe62fc924 fix(studio): tighten media decode filter with error_name + sampled counter
Address review feedback:
- AND with error_name === "EncodingError" for tighter filtering
- Add sampled composition_asset_error_filtered tracking event (fires on
  1st occurrence, then every 100th) so filtered errors aren't completely
  invisible in telemetry
2026-05-25 12:55:41 -04:00
Miguel Ángel 87cffb6904 Merge pull request #1071 from heygen-com/fix/studio-cross-origin-iframe-guards 2026-05-25 12:53:06 -04:00
Miguel Ángel c1b26fcb32 fix(studio): guard cross-origin iframe access to prevent SecurityError crashes
Wrap all contentWindow/contentDocument access and addEventListener/removeEventListener
calls in try/catch across usePlaybackKeyboard, useAppHotkeys, and CompositionsTab.
Prevents SecurityError from propagating to the React error boundary (white screen).
Affects 1,885 crashes / 648 unique users in the last 7 days.
2026-05-25 16:49:40 +00:00
Miguel Ángel 179241e932 fix(studio): filter media decode errors from crash telemetry 2026-05-25 16:49:40 +00:00
Miguel ÁngelandClaude Sonnet 4.6 e4e2234303 chore: release v0.6.43
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
v0.6.43
2026-05-25 13:35:55 +00:00
Miguel Ángel 8b776cb3c8 Merge pull request #1069 from func25/fix/preview-reuse-and-waapi-seek-baselines 2026-05-25 09:33:54 -04:00
Miguel Ángel 375e298093 Merge pull request #1068 from func25/fix/subcomp-local-script-bundling 2026-05-25 09:32:24 -04:00
func25 a0169309ae test(cli): normalize project paths in preview reuse probe tests 2026-05-25 18:08:51 +07:00
func25 e10ec358f8 fix(core): correct WAAPI rediscovery seek baselines and preview reuse invalidation 2026-05-25 17:51:47 +07:00
func25 3bb0d1efd9 fix(core): wrap bundled composition scripts as string literals 2026-05-25 15:41:13 +07:00
func25 e101b47be4 fix(core): preserve sub-composition script order in preview bundles 2026-05-25 15:27:52 +07:00
func25 bbb7a4d965 fix(core): inline local sub-composition scripts in preview bundles 2026-05-25 15:12:11 +07:00
Miguel Ángel 68fce93a49 Merge pull request #1041 from kiyeonjeon21/docs/rendering-comparison-license
docs: update rendering comparison and license wording
2026-05-24 23:51:47 -04:00
Miguel Ángel d2b915be9e chore: release v0.6.42 v0.6.42 2026-05-24 17:36:30 -04:00
Miguel Ángel 047c1e1d33 fix: support animated media volume (#1066)
## Problem

Audio-capable media could only use a static `data-volume` value during preview/render. Issue #1064 reports this for `<audio>`: GSAP/JS attempts to fade an element from `volume: 0` to a non-zero value still rendered as silent because HyperFrames only respected the initial `data-volume` value.

The same root cause applies to `<video data-has-audio="true">`: preview/runtime can animate the media element, but the offline audio mix previously only had one static volume number for the extracted audio track.

Closes #1064.

## What this fixes

- Preserves authored `HTMLMediaElement.volume` changes made by GSAP/JS between runtime media sync ticks instead of clobbering them back to `data-volume`.
- Updates the WebAudio transport gain for active media sources when the element volume changes.
- Probes scripted media timelines in the producer browser pass and records sampled volume keyframes for both `<audio>` elements and video-derived audio tracks.
- Maps extracted video audio IDs such as `bg-video-audio` back to their source `<video id="bg-video">` so video volume automation is sampled from the actual element.
- Converts sampled keyframes into a frame-evaluated FFmpeg `volume` expression during audio mixing, so rendered output includes fades and other timeline-driven media volume changes.

## Root cause

There were two static-volume paths:

1. Runtime media sync treated `clip.volume` parsed from `data-volume` as authoritative on every tick and rewrote `el.volume = clip.volume * userVolume`, undoing GSAP/JS updates after timeline seeks.
2. The producer audio stage mixed audio from compile/probe metadata, where `volume` was a single number. The FFmpeg filter used `volume=<initial value>`, so a clip starting at `data-volume="0"` stayed silent in the muxed output even if the browser timeline had changed `audio.volume` or `video.volume`.

The fix makes the browser/runtime media volume the source of truth when authors animate it, then carries that time-varying signal into the offline mix.

## Verification

### Local checks

- `bun install` in the clean worktree
- `bun run build:hyperframes-runtime`
- `bun run --filter @hyperframes/core test -- src/runtime/media.test.ts`
- `bun run --filter @hyperframes/engine test -- src/services/audioMixer.test.ts`
- `bun run --filter @hyperframes/core typecheck && bun run --filter @hyperframes/engine typecheck && bun run --filter @hyperframes/producer typecheck`
- `bunx oxfmt --check <10 touched files>`
- `bunx oxlint <10 touched files>`
- `bun run --filter @hyperframes/producer build`

### Audio repro and render proof

Created `/tmp/hf-1064` with `<audio id="bg-audio" ... data-volume="0">` and `gsap.to(audio, { volume: 1 })`.

Before fix:
- `RMS level dB: -inf`

After fix:
- `[Probe] Runtime audio volume automation: bg-audio {"keyframeCount":12}`
- `RMS level dB: -24.133461`

### Video repro and render proof

Created `/tmp/hf-1064-video` with `<video id="bg-video" data-has-audio="true" data-volume="0">` and `gsap.to(video, { volume: 1 })`.

After widening the probe mapping:
- `[Probe] Runtime audio volume automation: bg-video-audio {"keyframeCount":12}`
- `RMS level dB: -24.203664`

### Preview verification

Used `hyperframes preview` from the PR branch and `agent-browser` against Studio:

- Audio preview direct seek through `window.__player.seek(...)` showed `#bg-audio.volume`: `0 -> 0.5 -> 1`.
- Audio preview playback advanced to ~0.92s with `#bg-audio.volume` ~0.917 and the audio element unpaused.
- Video preview direct seek showed `#bg-video.volume`: `0 -> 0.5 -> 1`.
- Video preview playback sampled during the fade at ~0.68s showed `#bg-video.volume` around `0.664`.

### Browser verification

Used `agent-browser` against the rendered MP4 from the PR branch:

- Opened `file:///tmp/hf-1064/out-pr.mp4`
- Verified the browser loaded a playable `<video>`: `{"hasVideo":true,"duration":3.020996,"readyState":4,"paused":false}`
- Screenshot artifact: `/Users/miguel07code/dev/hyperframes-oss/qa-artifacts/issue-1064/pr-rendered-video.png`
- Recording artifact: `/Users/miguel07code/dev/hyperframes-oss/qa-artifacts/issue-1064/pr-rendered-video.webm`

## Notes

- The commit was prepared in a clean worktree based on `origin/main` to avoid mixing in the existing `fix/nonlatin-media-src-resolution` checkout changes.
- Pre-commit lint, format, and typecheck passed. The hook's Fallow audit still exits non-zero on inherited duplication/complexity in the touched large runtime/probe files; the dead-code issue from the new type export was fixed before committing.
- Volume automation is sampled from the browser timeline and approximated as piecewise-linear FFmpeg volume expressions. This is intended for timeline-driven GSAP/JS fades, not audio-rate DSP.
2026-05-24 17:33:31 -04:00
Miguel Ángel 5a3b76a218 fix: parse scripted volume probes with html parser 2026-05-24 17:09:57 -04:00
Miguel Ángel 50c972fd50 fix: address audio volume review feedback 2026-05-24 17:02:35 -04:00
Miguel Ángel 947bf6cc78 Merge remote-tracking branch 'origin/main' into fix/audio-volume-automation
# Conflicts:
#	packages/engine/src/services/audioMixer.test.ts
2026-05-24 16:46:52 -04:00
Miguel Ángel 720bb9d851 Merge pull request #1065 from heygen-com/fix/nonlatin-media-src-resolution
fix(engine): resolve encoded non-Latin media paths
2026-05-24 16:41:16 -04:00
Miguel Ángel 167a222318 fix: support animated audio volume 2026-05-24 16:39:04 -04:00
Miguel Ángel a68b4b6590 fix(cli): decode linked stylesheet paths 2026-05-24 16:38:54 -04:00
Miguel Ángel 526709cad2 fix(cli): handle encoded lint asset paths 2026-05-24 16:31:45 -04:00
Miguel Ángel 7ad10a2dff fix(engine): resolve encoded media src paths 2026-05-24 15:58:42 -04:00