fix(studio): server-side DOM patching, render CSS scoping, and resilience

Root-cause fix for edits being wiped after refresh: the studio's
inspector edits were patched client-side via regex matching in
sourcePatcher.ts, which silently failed for many compositions ("Unable
to patch" toast). Replaced with a server-side patch-element API endpoint
using linkedom for proper DOM parsing via querySelector.

Also fixes the WYSIWYG render bug where sub-composition CSS was not
applied. The CSS scoping generated descendant selectors when both
attributes coexist on the same host element. Fixed to use compound
selectors for the authored root.

Edit persistence:
- New POST /file-mutations/patch-element endpoint using linkedom
- persistDomEditOperations calls server instead of client regex
- 15 tests covering all patch operation types

Render CSS scoping:
- Compound selector for authored root on host element
- Regression test: wysiwyg-subcomp-css (baseline pending Docker)
- 3 unit tests + 1 integration test

GSAP CDN fallback:
- Preview: error-handler catches gsap 404 and loads from CDN
- Producer: rewrites missing local gsap paths to CDN before compile

Studio resilience:
- Error boundary with recoverable UI
- Lazy mediabunny import prevents crash cascade
- Hash routing listens for hashchange events
- Sub-composition duration reads data-hf-authored-duration fallback
- Save debounce 600ms to requestAnimationFrame

Observability:
- PostHog telemetry for crashes, save failures, tab switches, playback,
  toolbar actions, navigation, and render starts
This commit is contained in:
Miguel Ángel
2026-05-20 17:07:31 -04:00
parent ce95c9aea0
commit 45999226a3
29 changed files with 848 additions and 65 deletions
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
import { usePlayerStore, liveTime } from "../store/playerStore";
import { trackStudioEvent } from "../../utils/studioTelemetry";
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
const SEEK_EDGE_SNAP_PX = 8;
@@ -335,7 +336,10 @@ export const PlayerControls = memo(function PlayerControls({
<button
type="button"
aria-label={isPlaying ? "Pause" : "Play"}
onClick={onTogglePlay}
onClick={() => {
trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
onTogglePlay();
}}
disabled={controlsDisabled}
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
style={{ background: "rgba(255,255,255,0.06)" }}
@@ -461,7 +465,10 @@ export const PlayerControls = memo(function PlayerControls({
<button
type="button"
onClick={() => {
if (!audioAutoMuted) setAudioMuted(!audioMuted);
if (!audioAutoMuted) {
trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
setAudioMuted(!audioMuted);
}
}}
disabled={controlsDisabled || audioAutoMuted}
title={muteButtonLabel}
@@ -528,6 +535,7 @@ export const PlayerControls = memo(function PlayerControls({
<button
key={rate}
onClick={() => {
trackStudioEvent("playback", { action: "speed_change", rate });
setPlaybackRate(rate);
setShowSpeedMenu(false);
}}
@@ -553,7 +561,10 @@ export const PlayerControls = memo(function PlayerControls({
<button
type="button"
onClick={() => setLoopEnabled(!loopEnabled)}
onClick={() => {
trackStudioEvent("playback", { action: "loop_toggle", enabled: !loopEnabled });
setLoopEnabled(!loopEnabled);
}}
disabled={disabled}
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
loopEnabled
+20 -5
View File
@@ -1,5 +1,3 @@
import { Input, UrlSource, ALL_FORMATS } from "mediabunny";
export interface MediaProbeResult {
duration: number;
width?: number;
@@ -11,6 +9,20 @@ export interface MediaProbeResult {
const cache = new Map<string, MediaProbeResult>();
const inflight = new Map<string, Promise<MediaProbeResult | null>>();
let mediabunnyModule: typeof import("mediabunny") | null | false = null;
async function loadMediabunny() {
if (mediabunnyModule === false) return null;
if (mediabunnyModule) return mediabunnyModule;
try {
mediabunnyModule = await import("mediabunny");
return mediabunnyModule;
} catch {
mediabunnyModule = false;
return null;
}
}
function normalizeUrl(url: string): string {
try {
return new URL(url, window.location.href).href;
@@ -20,9 +32,12 @@ function normalizeUrl(url: string): string {
}
async function probeOne(url: string): Promise<MediaProbeResult | null> {
const input = new Input({
source: new UrlSource(url),
formats: ALL_FORMATS,
const mb = await loadMediabunny();
if (!mb) return null;
const input = new mb.Input({
source: new mb.UrlSource(url),
formats: mb.ALL_FORMATS,
});
try {
const duration = await input.getDurationFromMetadata();