Merge pull request #986 from heygen-com/fix/studio-edit-persistence-and-render-css

fix(studio): server-side DOM patching, render CSS scoping, and resilience
This commit is contained in:
Miguel Ángel
2026-05-21 05:53:32 +02:00
committed by GitHub
32 changed files with 1120 additions and 67 deletions
+65
View File
@@ -228,6 +228,71 @@ jobs:
- run: bun install --frozen-lockfile - run: bun install --frozen-lockfile
- run: bun run --filter @hyperframes/core test:hyperframe-runtime-ci - run: bun run --filter @hyperframes/core test:hyperframe-runtime-ci
studio-load-smoke:
name: "Studio: load smoke"
needs: [changes]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
lfs: true
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- run: bun install --frozen-lockfile
- run: bun run --cwd packages/core build:hyperframes-runtime
- name: Start studio and check for runtime errors
run: |
# Start the studio dev server in the background
bun run --filter '@hyperframes/studio' dev -- --port 5199 &
SERVER_PID=$!
# Wait for the server to be ready (up to 20s)
for i in $(seq 1 40); do
if curl -sf http://localhost:5199/ >/dev/null 2>&1; then break; fi
sleep 0.5
done
if ! curl -sf http://localhost:5199/ >/dev/null 2>&1; then
echo "FAIL: studio dev server did not start"
kill $SERVER_PID 2>/dev/null || true
exit 1
fi
# Load the studio in headless Chrome and capture console errors
# puppeteer is a dependency of @hyperframes/producer; resolve from there
cd packages/producer
node --input-type=module <<'SMOKE_EOF'
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
const errors = [];
page.on("pageerror", (err) => errors.push(err.message));
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
await page.goto("http://localhost:5199/", { waitUntil: "networkidle0", timeout: 30000 });
await new Promise((r) => setTimeout(r, 3000));
await browser.close();
const fatal = errors.filter(
(e) => !e.includes("favicon") && !e.includes("ERR_CONNECTION_REFUSED"),
);
if (fatal.length > 0) {
console.error("FAIL: studio had runtime errors on load:");
for (const e of fatal) console.error(" •", e);
process.exit(1);
}
console.log("PASS: studio loaded without runtime errors");
SMOKE_EOF
kill $SERVER_PID 2>/dev/null || true
smoke-global-install: smoke-global-install:
name: "Smoke: global install" name: "Smoke: global install"
needs: [changes, build] needs: [changes, build]
+1 -1
View File
@@ -4,5 +4,5 @@
"correctness": "error" "correctness": "error"
}, },
"plugins": ["react", "typescript"], "plugins": ["react", "typescript"],
"ignorePatterns": ["dist/", "coverage/", "node_modules/"] "ignorePatterns": ["dist/", "coverage/", "node_modules/", "playground/"]
} }
@@ -497,6 +497,55 @@ window.__afterTimeline = window.__timelines.scene;
expect(errorSpy).not.toHaveBeenCalled(); expect(errorSpy).not.toHaveBeenCalled();
}); });
it("uses compound selector when authored root is the scoped element itself", () => {
const scoped = scopeCssToComposition(
"#chrome-overlay-root { --primary: #FFDC8B; }",
"chrome-overlay",
undefined,
"chrome-overlay-root",
{ compoundAuthoredRoot: true },
);
// Both attributes are on the same element after inlining, so the selector
// must be compound (no space) to match.
expect(scoped).toContain(
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"]',
);
expect(scoped).not.toContain(
'[data-composition-id="chrome-overlay"] [data-hf-authored-id="chrome-overlay-root"]',
);
});
it("uses compound selector for authored root with descendant combinators", () => {
const scoped = scopeCssToComposition(
"#chrome-overlay-root .chrome { display: flex; }",
"chrome-overlay",
undefined,
"chrome-overlay-root",
{ compoundAuthoredRoot: true },
);
// The authored root part is compound with scope, .chrome is a descendant
expect(scoped).toContain(
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"] .chrome',
);
expect(scoped).not.toMatch(
/\[data-composition-id="chrome-overlay"\]\s+\[data-hf-authored-id="chrome-overlay-root"\]\s+\.chrome/,
);
});
it("still uses descendant selector for non-root selectors with authoredRootId", () => {
const scoped = scopeCssToComposition(
".child-element { color: red; }",
"chrome-overlay",
undefined,
"chrome-overlay-root",
);
// Regular child selectors still get a descendant combinator (space)
expect(scoped).toContain('[data-composition-id="chrome-overlay"] .child-element');
});
it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => { it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
const scoped = scopeCssToComposition( const scoped = scopeCssToComposition(
`#intro { background: #111; } `#intro { background: #111; }
@@ -101,6 +101,7 @@ function scopeSelector(
scope: string, scope: string,
compositionId: string, compositionId: string,
authoredRootId?: string | null, authoredRootId?: string | null,
compoundAuthoredRoot?: boolean,
): string { ): string {
const selectorWithoutAuthoredRootId = normalizeAuthoredRootIdSelector(selector, authoredRootId); const selectorWithoutAuthoredRootId = normalizeAuthoredRootIdSelector(selector, authoredRootId);
const selectorWithoutRootTiming = normalizeCompositionRootSelector( const selectorWithoutRootTiming = normalizeCompositionRootSelector(
@@ -120,6 +121,15 @@ function scopeSelector(
} }
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? ""; const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? ""; const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? "";
if (compoundAuthoredRoot) {
const authoredRootAttr = authoredRootId
? `[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(authoredRootId)}"]`
: null;
if (authoredRootAttr && trimmed.startsWith(authoredRootAttr)) {
const rest = trimmed.slice(authoredRootAttr.length);
return `${leading}${scope}${authoredRootAttr}${rest}${trailing}`;
}
}
return `${leading}${scope} ${trimmed}${trailing}`; return `${leading}${scope} ${trimmed}${trailing}`;
} }
@@ -158,6 +168,7 @@ export function scopeCssToComposition(
compositionId: string, compositionId: string,
scopeSelectorOverride?: string, scopeSelectorOverride?: string,
authoredRootId?: string | null, authoredRootId?: string | null,
options?: { compoundAuthoredRoot?: boolean },
): string { ): string {
const trimmedCompositionId = compositionId.trim(); const trimmedCompositionId = compositionId.trim();
if (!css || !trimmedCompositionId) return css; if (!css || !trimmedCompositionId) return css;
@@ -169,7 +180,13 @@ export function scopeCssToComposition(
root.walkRules((rule) => { root.walkRules((rule) => {
if (isInsideGlobalAtRule(rule)) return; if (isInsideGlobalAtRule(rule)) return;
rule.selectors = rule.selectors.map((selector) => rule.selectors = rule.selectors.map((selector) =>
scopeSelector(selector, scope, trimmedCompositionId, authoredRootId), scopeSelector(
selector,
scope,
trimmedCompositionId,
authoredRootId,
options?.compoundAuthoredRoot,
),
); );
}); });
@@ -236,4 +236,35 @@ describe("inlineSubCompositions #ID selector scoping divergence", () => {
expect(host.getAttribute("data-composition-id")).toBe("intro"); expect(host.getAttribute("data-composition-id")).toBe("intro");
}); });
it("producer path: scoped CSS matches host element when both attributes coexist", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
compoundAuthoredRoot: true,
});
// After inlining, the host has both data-composition-id and data-hf-authored-id.
// CSS selectors targeting the root must be compound (no space) so they match
// when both attributes are on the same element.
expect(host.getAttribute("data-composition-id")).toBe("intro");
expect(host.getAttribute("data-hf-authored-id")).toBe("intro");
const scopedCss = result.styles.join("\n");
// Root-only selector: must be compound
expect(scopedCss).toMatch(/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]/);
// Must NOT have a descendant combinator between the two attribute selectors
expect(scopedCss).not.toMatch(
/\[data-composition-id="intro"\]\s+\[data-hf-authored-id="intro"\]\s*\{/,
);
// Descendant selector: compound root + space + child
expect(scopedCss).toMatch(
/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]\s+\.title/,
);
});
}); });
@@ -60,6 +60,15 @@ export interface InlineSubCompositionsOptions {
*/ */
flattenInnerRoot?: (innerRoot: Element) => Element; flattenInnerRoot?: (innerRoot: Element) => Element;
/**
* When true, CSS selectors targeting the authored root use a compound
* selector (`[scope][root]`) instead of a descendant (`[scope] [root]`).
* Enable this in the producer path where the inner root merges onto
* the host element via innerHTML — both attributes end up on the same
* element and a descendant selector won't match.
*/
compoundAuthoredRoot?: boolean;
/** /**
* Read declared variable defaults from a sub-composition's `<html>` element. * Read declared variable defaults from a sub-composition's `<html>` element.
* The bundler passes `readDeclaredDefaults`; the producer can omit this. * The bundler passes `readDeclaredDefaults`; the producer can omit this.
@@ -140,6 +149,7 @@ export function inlineSubCompositions(
hostIdentityMap, hostIdentityMap,
rewriteInlineStyles = false, rewriteInlineStyles = false,
flattenInnerRoot, flattenInnerRoot,
compoundAuthoredRoot,
readVariableDefaults, readVariableDefaults,
parseHostVariables, parseHostVariables,
buildScopeSelector = defaultBuildScopeSelector, buildScopeSelector = defaultBuildScopeSelector,
@@ -214,7 +224,9 @@ export function inlineSubCompositions(
const css = rewriteCssAssetUrls(s.textContent || "", src); const css = rewriteCssAssetUrls(s.textContent || "", src);
styles.push( styles.push(
scopeCompId scopeCompId
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId) ? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
compoundAuthoredRoot: compoundAuthoredRoot === true,
})
: css, : css,
); );
} }
@@ -244,7 +256,9 @@ export function inlineSubCompositions(
const css = rewriteCssAssetUrls(s.textContent || "", src); const css = rewriteCssAssetUrls(s.textContent || "", src);
styles.push( styles.push(
scopeCompId scopeCompId
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId) ? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
compoundAuthoredRoot: compoundAuthoredRoot === true,
})
: css, : css,
); );
s.remove(); s.remove();
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { removeElementFromHtml } from "./sourceMutation.js"; import { removeElementFromHtml, patchElementInHtml } from "./sourceMutation.js";
describe("removeElementFromHtml", () => { describe("removeElementFromHtml", () => {
it("removes a self-closing element by id", () => { it("removes a self-closing element by id", () => {
@@ -28,3 +28,223 @@ describe("removeElementFromHtml", () => {
expect(removeElementFromHtml(html, { id: "photo" })).toBe(`<div id="rest"></div>`); expect(removeElementFromHtml(html, { id: "photo" })).toBe(`<div id="rest"></div>`);
}); });
}); });
describe("patchElementInHtml", () => {
const FIXTURE = `<!doctype html><html><head></head><body>
<div id="root" data-composition-id="main">
<div class="layer" data-composition-id="overlay" data-composition-src="compositions/overlay.html">
<div class="chrome">
<span class="brand">HyperFrames</span>
</div>
</div>
<div id="hero" class="hero-heading" style="font-size: 48px">Hello World</div>
</div>
</body></html>`;
it("patches inline style by id", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "color", value: "red" },
]);
expect(result).toMatch(/color:\s*red/);
expect(result).toContain('id="hero"');
});
it("patches inline style by class selector", () => {
const result = patchElementInHtml(FIXTURE, { selector: ".hero-heading" }, [
{ type: "inline-style", property: "font-size", value: "72px" },
]);
expect(result).toMatch(/font-size:\s*72px/);
});
it("patches data attribute", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
]);
expect(result).toContain('data-hf-studio-path-offset="true"');
});
it("patches html attribute", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "title", value: "greeting" },
]);
expect(result).toContain('title="greeting"');
});
it("patches text content", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "text-content", property: "", value: "New Title" },
]);
expect(result).toContain("New Title");
expect(result).not.toContain("Hello World");
});
it("applies multiple operations in one call", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "color", value: "blue" },
{ type: "inline-style", property: "font-size", value: "96px" },
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
]);
expect(result).toMatch(/color:\s*blue/);
expect(result).toMatch(/font-size:\s*96px/);
expect(result).toContain('data-hf-studio-path-offset="true"');
});
it("finds element by composition-id selector", () => {
const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
{ type: "inline-style", property: "opacity", value: "0.5" },
]);
expect(result).toMatch(/opacity:\s*0\.5/);
});
it("finds element by class with selectorIndex", () => {
const html = `<div class="item">A</div><div class="item">B</div>`;
const result = patchElementInHtml(html, { selector: ".item", selectorIndex: 1 }, [
{ type: "text-content", property: "", value: "Changed" },
]);
expect(result).toContain("A");
expect(result).toContain("Changed");
expect(result).not.toContain(">B<");
});
it("returns unchanged html when target not found", () => {
const result = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [
{ type: "inline-style", property: "color", value: "red" },
]);
expect(result).toBe(FIXTURE);
});
it("removes inline style when value is null", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "font-size", value: null },
]);
expect(result).not.toContain("font-size");
});
it("removes attribute when value is null", () => {
const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
{ type: "html-attribute", property: "data-composition-src", value: null },
]);
expect(result).not.toContain("data-composition-src");
});
it("patches fragment html without doctype", () => {
const fragment = `<div id="card" style="padding: 8px"><span>Title</span></div>`;
const result = patchElementInHtml(fragment, { id: "card" }, [
{ type: "inline-style", property: "padding", value: "16px" },
]);
expect(result).toMatch(/padding:\s*16px/);
});
it("rejects event handler attributes", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "onload", value: "fetch('/evil')" },
]);
expect(result).not.toContain("onload");
expect(result).not.toContain("fetch");
});
it("rejects javascript: URLs in src", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "src", value: "javascript:alert(1)" },
]);
expect(result).not.toContain("javascript:");
});
it("allows aria-* and data-* attributes", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "aria-label", value: "greeting" },
{ type: "html-attribute", property: "data-custom", value: "test" },
]);
expect(result).toContain('aria-label="greeting"');
expect(result).toContain('data-custom="test"');
});
it("rejects srcdoc and formaction attributes", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "srcdoc", value: "<script>alert(1)</script>" },
{ type: "html-attribute", property: "formaction", value: "javascript:void(0)" },
]);
expect(result).not.toContain("srcdoc");
expect(result).not.toContain("formaction");
});
it("rejects on* event handlers regardless of casing", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "onClick", value: "alert(1)" },
{ type: "html-attribute", property: "ONERROR", value: "alert(2)" },
{ type: "html-attribute", property: "onmouseover", value: "alert(3)" },
]);
expect(result).not.toContain("alert");
});
it("rejects data:text/html URIs in src", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{
type: "html-attribute",
property: "src",
value: "data:text/html,<script>alert(1)</script>",
},
]);
expect(result).not.toContain("data:text/html");
});
it("allows safe href values", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "href", value: "https://example.com" },
]);
expect(result).toContain('href="https://example.com"');
});
it("rejects javascript: in href", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "href", value: "javascript:alert(1)" },
]);
expect(result).not.toContain("javascript:");
});
it("allows legitimate form and media attributes", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "placeholder", value: "Enter text" },
{ type: "html-attribute", property: "target", value: "_blank" },
{ type: "html-attribute", property: "rel", value: "noopener" },
{ type: "html-attribute", property: "srcset", value: "img-2x.png 2x" },
]);
expect(result).toContain('placeholder="Enter text"');
expect(result).toContain('target="_blank"');
expect(result).toContain('rel="noopener"');
expect(result).toContain("srcset");
});
it("rejects unknown/dangerous attributes", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "xmlns", value: "http://evil.com" },
{ type: "html-attribute", property: "background", value: "http://evil.com/bg.js" },
{ type: "html-attribute", property: "dynsrc", value: "http://evil.com/vid.avi" },
]);
expect(result).not.toContain("xmlns");
expect(result).not.toContain("background=");
expect(result).not.toContain("dynsrc");
});
});
@@ -54,3 +54,148 @@ export function removeElementFromHtml(source: string, target: SourceMutationTarg
element.remove(); element.remove();
return wrappedFragment ? document.body.innerHTML || "" : document.toString(); return wrappedFragment ? document.body.innerHTML || "" : document.toString();
} }
function isHTMLElement(el: Element): boolean {
const HTMLEl = el.ownerDocument.defaultView?.HTMLElement;
return HTMLEl ? el instanceof HTMLEl : "style" in el;
}
export interface PatchOperation {
type: "inline-style" | "attribute" | "html-attribute" | "text-content";
property: string;
value: string | null;
}
const ALLOWED_HTML_ATTRS = new Set([
// Identity & structure
"id",
"class",
"style",
"title",
"name",
"for",
"type",
// Internationalization
"lang",
"dir",
"translate",
// Interaction
"hidden",
"tabindex",
"draggable",
"contenteditable",
// Accessibility
"role",
"slot",
// Links & navigation
"href",
"target",
"rel",
// Media
"src",
"srcset",
"sizes",
"alt",
"poster",
"loading",
"decoding",
"crossorigin",
"preload",
"autoplay",
"loop",
"muted",
"controls",
"playsinline",
// Layout
"width",
"height",
"colspan",
"rowspan",
"scope",
// Form
"placeholder",
"value",
"min",
"max",
"step",
"pattern",
"required",
"disabled",
"readonly",
"checked",
"selected",
"multiple",
"accept",
"maxlength",
"minlength",
"rows",
"cols",
"wrap",
]);
const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;
function isAllowedHtmlAttribute(name: string): boolean {
const lower = name.toLowerCase();
if (lower.startsWith("on")) return false;
if (ALLOWED_HTML_ATTRS.has(lower)) return true;
if (lower.startsWith("data-")) return true;
if (lower.startsWith("aria-")) return true;
return false;
}
const URI_ATTRS = new Set(["src", "href", "action", "formaction", "poster", "srcset"]);
function isSafeAttributeValue(name: string, value: string): boolean {
if (URI_ATTRS.has(name.toLowerCase())) {
const trimmed = value.trim();
if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false;
if (DANGEROUS_DATA_URI.test(trimmed)) return false;
}
return true;
}
export function patchElementInHtml(
source: string,
target: SourceMutationTarget,
operations: PatchOperation[],
): string {
const { document, wrappedFragment } = parseSourceDocument(source);
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return source;
const htmlEl = el as unknown as HTMLElement;
for (const op of operations) {
switch (op.type) {
case "inline-style":
if (op.value != null) {
htmlEl.style.setProperty(op.property, op.value);
} else {
htmlEl.style.removeProperty(op.property);
}
break;
case "attribute":
if (op.value != null) {
htmlEl.setAttribute(`data-${op.property}`, op.value);
} else {
htmlEl.removeAttribute(`data-${op.property}`);
}
break;
case "html-attribute":
if (!isAllowedHtmlAttribute(op.property)) break;
if (op.value != null) {
if (!isSafeAttributeValue(op.property, op.value)) break;
htmlEl.setAttribute(op.property, op.value);
} else {
htmlEl.removeAttribute(op.property);
}
break;
case "text-content":
if (op.value != null) htmlEl.textContent = op.value;
break;
}
}
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
}
+44 -1
View File
@@ -17,7 +17,11 @@ import { isAudioFile } from "../helpers/mime.js";
import { generateWaveformCache } from "../helpers/waveform.js"; import { generateWaveformCache } from "../helpers/waveform.js";
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js"; import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
import { isSafePath } from "../helpers/safePath.js"; import { isSafePath } from "../helpers/safePath.js";
import { removeElementFromHtml } from "../helpers/sourceMutation.js"; import {
removeElementFromHtml,
patchElementInHtml,
type PatchOperation,
} from "../helpers/sourceMutation.js";
// ── Shared helpers ────────────────────────────────────────────────────────── // ── Shared helpers ──────────────────────────────────────────────────────────
@@ -236,6 +240,45 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
return c.json({ ok: true, changed: true, content: patchedContent }); return c.json({ ok: true, changed: true, content: patchedContent });
}); });
api.post("/projects/:id/file-mutations/patch-element/*", async (c) => {
const id = c.req.param("id");
const project = await adapter.resolveProject(id);
if (!project) return c.json({ error: "not found" }, 404);
const filePath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/file-mutations/patch-element/`, ""),
);
if (filePath.includes("\0")) {
return c.json({ error: "forbidden" }, 403);
}
const absPath = resolve(project.dir, filePath);
if (!isSafePath(project.dir, absPath)) {
return c.json({ error: "forbidden" }, 403);
}
const body = (await c.req.json().catch(() => null)) as {
target?: { id?: string | null; selector?: string; selectorIndex?: number };
operations?: PatchOperation[];
} | null;
if (!body?.target || !Array.isArray(body.operations) || body.operations.length === 0) {
return c.json({ error: "target and operations required" }, 400);
}
let originalContent: string;
try {
originalContent = readFileSync(absPath, "utf-8");
} catch {
return c.json({ error: "not found" }, 404);
}
const patchedContent = patchElementInHtml(originalContent, body.target, body.operations);
if (patchedContent === originalContent) {
return c.json({ ok: true, changed: false, content: originalContent });
}
writeFileSync(absPath, patchedContent, "utf-8");
return c.json({ ok: true, changed: true, content: patchedContent });
});
// ── Rename / Move ── // ── Rename / Move ──
api.patch("/projects/:id/files/*", async (c) => { api.patch("/projects/:id/files/*", async (c) => {
+30 -1
View File
@@ -111,6 +111,33 @@ function injectStudioMotionScript(
); );
} }
const GSAP_CDN_FALLBACK_SCRIPT = `<script data-hf-gsap-fallback>
(function(){
var cdnBase="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/";
var loaded={};
function loadFallback(file){
if(loaded[file])return loaded[file];
return loaded[file]=new Promise(function(ok,fail){
var s=document.createElement("script");
s.src=cdnBase+file;s.onload=ok;s.onerror=fail;
document.head.appendChild(s);
});
}
document.addEventListener("error",function(e){
var t=e.target;
if(!t||t.tagName!=="SCRIPT"||!t.src)return;
var m=t.src.match(/gsap[^/]*\\/dist\\/(.+\\.js)/);
if(m)loadFallback(m[1]);
},true);
})();
</script>`;
function injectGsapCdnFallback(html: string): string {
if (html.includes("data-hf-gsap-fallback")) return html;
if (html.includes("<head>")) return html.replace("<head>", "<head>" + GSAP_CDN_FALLBACK_SCRIPT);
return GSAP_CDN_FALLBACK_SCRIPT + html;
}
function injectStudioPreviewAugmentations( function injectStudioPreviewAugmentations(
html: string, html: string,
adapter: StudioApiAdapter, adapter: StudioApiAdapter,
@@ -118,7 +145,9 @@ function injectStudioPreviewAugmentations(
activeCompositionPath: string, activeCompositionPath: string,
): string { ): string {
return injectStudioMotionScript( return injectStudioMotionScript(
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)), injectGsapCdnFallback(
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
),
projectDir, projectDir,
activeCompositionPath, activeCompositionPath,
); );
+19 -1
View File
@@ -575,6 +575,7 @@ function inlineSubCompositions(
}, },
parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document, parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document,
scriptErrorLabel: "[Compiler] Composition script failed", scriptErrorLabel: "[Compiler] Composition script failed",
compoundAuthoredRoot: true,
}, },
); );
@@ -871,6 +872,23 @@ export interface CompileForRenderOptions {
failClosedFontFetch?: boolean; failClosedFontFetch?: boolean;
} }
const GSAP_CDN_BASE = "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/";
function rewriteUnresolvableGsapToCdn(html: string, projectDir: string): string {
return html.replace(
/(<script\b[^>]*\bsrc=["'])([^"']*gsap[^"']*\/dist\/([^"']+))(["'][^>]*>)/gi,
(full, prefix, src, file, suffix) => {
if (/^https?:\/\//i.test(src)) return full;
const absPath = resolve(projectDir, src);
if (existsSync(absPath)) return full;
console.log(
`[Compiler] Rewriting missing gsap script to CDN: ${src}${GSAP_CDN_BASE}${file}`,
);
return `${prefix}${GSAP_CDN_BASE}${file}${suffix}`;
},
);
}
/** /**
* Compile an HTML composition project into a single self-contained HTML string * Compile an HTML composition project into a single self-contained HTML string
* with all media metadata resolved. * with all media metadata resolved.
@@ -881,7 +899,7 @@ export async function compileForRender(
downloadDir: string, downloadDir: string,
options: CompileForRenderOptions = {}, options: CompileForRenderOptions = {},
): Promise<CompiledComposition> { ): Promise<CompiledComposition> {
const rawHtml = readFileSync(htmlPath, "utf-8"); const rawHtml = rewriteUnresolvableGsapToCdn(readFileSync(htmlPath, "utf-8"), projectDir);
const { html: compiledHtml, unresolvedCompositions } = await compileHtmlFile( const { html: compiledHtml, unresolvedCompositions } = await compileHtmlFile(
rawHtml, rawHtml,
projectDir, projectDir,
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d2ca0d92ccf9740ad67c4ea46f50f65c3e3cefac88a07a171bd0fdcaf865efe0 oid sha256:36ef88d84340f4ab2aabc17b702d09139fb6614475a4497f1995fd7be539dede
size 13513666 size 12350905
@@ -0,0 +1,12 @@
{
"name": "wysiwyg-subcomp-css",
"description": "Verifies sub-composition CSS is correctly scoped and applied in rendered output. Regression test for compound-selector bug where [data-composition-id] [data-hf-authored-id] used a descendant combinator (space) instead of compound (no space) when both attributes coexist on the same host element after inlining.",
"tags": ["regression", "css-scoping", "sub-compositions", "wysiwyg"],
"minPsnr": 25,
"maxFrameFailures": 2,
"minAudioCorrelation": 0,
"maxAudioLagWindows": 0,
"renderConfig": {
"fps": 30
}
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b2ee4eefee44ae1ec3e957c3a4a03b72edcd4e44b375965edf3334e3212de644
size 9290
@@ -0,0 +1,47 @@
<template id="overlay-template">
<div id="overlay-root" data-composition-id="overlay" data-start="0" data-width="1280" data-height="720" data-duration="2">
<div class="chrome">
<div class="brand">WYSIWYG Test</div>
<div class="badge">CSS Loaded</div>
</div>
<style>
#overlay-root {
--accent: #3cE6ac;
font-family: system-ui, sans-serif;
}
#overlay-root .chrome {
position: absolute;
bottom: 40px;
left: 40px;
right: 40px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: rgba(0, 0, 0, 0.7);
border: 1px solid var(--accent);
border-radius: 12px;
}
#overlay-root .brand {
font-size: 24px;
font-weight: 700;
color: #fff;
letter-spacing: 0.05em;
}
#overlay-root .badge {
font-size: 14px;
font-weight: 600;
color: var(--accent);
background: rgba(60, 230, 172, 0.15);
padding: 6px 16px;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
</style>
</div>
</template>
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
* { box-sizing: border-box; margin: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0a; }
#root {
position: relative;
width: 1280px;
height: 720px;
overflow: hidden;
background: #0a0a0a;
color: #fff;
font-family: system-ui, sans-serif;
}
#root > .layer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="2" data-width="1280" data-height="720">
<div id="overlay-layer" class="layer"
data-composition-id="overlay"
data-composition-src="compositions/overlay.html"
data-start="0" data-duration="2" data-track-index="0">
</div>
</div>
</body>
</html>
@@ -0,0 +1,68 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { trackStudioEvent } from "../utils/studioTelemetry";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class StudioErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("[Studio] Uncaught error:", error, info.componentStack);
trackStudioEvent("crash", {
error_message: error.message,
error_name: error.name,
component_stack: info.componentStack?.slice(0, 500) ?? null,
});
}
render() {
if (!this.state.error) return this.props.children;
return (
<div
style={{
position: "fixed",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "#0a0a0a",
color: "#e5e5e5",
fontFamily: "system-ui, -apple-system, sans-serif",
gap: 16,
}}
>
<div style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</div>
<div style={{ fontSize: 13, color: "#888", maxWidth: 480, textAlign: "center" }}>
{this.state.error.message}
</div>
<button
onClick={() => this.setState({ error: null })}
style={{
marginTop: 8,
padding: "8px 20px",
background: "#2563eb",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 14,
cursor: "pointer",
}}
>
Try again
</button>
</div>
);
}
}
@@ -8,6 +8,7 @@ import { getHistoryShortcutLabel } from "../utils/studioHelpers";
import { useStudioContext } from "../contexts/StudioContext"; import { useStudioContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useDomEditContext } from "../contexts/DomEditContext"; import { useDomEditContext } from "../contexts/DomEditContext";
import { trackStudioEvent } from "../utils/studioTelemetry";
export interface StudioHeaderProps { export interface StudioHeaderProps {
captureFrameHref: string; captureFrameHref: string;
@@ -165,7 +166,10 @@ export function StudioHeader({
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button <button
type="button" type="button"
onClick={() => void handleUndo()} onClick={() => {
trackStudioEvent("toolbar_action", { action: "undo" });
void handleUndo();
}}
disabled={!editHistory.canUndo} disabled={!editHistory.canUndo}
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${ className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
editHistory.canUndo editHistory.canUndo
@@ -183,7 +187,10 @@ export function StudioHeader({
</button> </button>
<button <button
type="button" type="button"
onClick={() => void handleRedo()} onClick={() => {
trackStudioEvent("toolbar_action", { action: "redo" });
void handleRedo();
}}
disabled={!editHistory.canRedo} disabled={!editHistory.canRedo}
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${ className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
editHistory.canRedo editHistory.canRedo
@@ -202,7 +209,10 @@ export function StudioHeader({
<a <a
href={captureFrameHref} href={captureFrameHref}
download={captureFrameFilename} download={captureFrameFilename}
onClick={handleCaptureFrameClick} onClick={(e) => {
trackStudioEvent("toolbar_action", { action: "capture_frame" });
handleCaptureFrameClick(e);
}}
onFocus={refreshCaptureFrameTime} onFocus={refreshCaptureFrameTime}
onPointerDown={refreshCaptureFrameTime} onPointerDown={refreshCaptureFrameTime}
className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800" className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
@@ -217,10 +227,12 @@ export function StudioHeader({
onClick={() => { onClick={() => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return; if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
if (rightCollapsed || !inspectorPanelActive) { if (rightCollapsed || !inspectorPanelActive) {
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
setRightPanelTab("design"); setRightPanelTab("design");
setRightCollapsed(false); setRightCollapsed(false);
return; return;
} }
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
clearDomSelection(); clearDomSelection();
setRightCollapsed(true); setRightCollapsed(true);
}} }}
@@ -74,7 +74,10 @@ function TimingSection({
onSetAttribute: (attr: string, value: string) => void | Promise<void>; onSetAttribute: (attr: string, value: string) => void | Promise<void>;
}) { }) {
const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0; const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
const duration = Number.parseFloat(element.dataAttributes.duration ?? "0") || 0; const duration =
Number.parseFloat(
element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
) || 0;
const end = start + duration; const end = start + duration;
const commitStart = (nextValue: string) => { const commitStart = (nextValue: string) => {
@@ -1,4 +1,5 @@
import { ArrowLeft, CaretRight } from "@phosphor-icons/react"; import { ArrowLeft, CaretRight } from "@phosphor-icons/react";
import { trackStudioEvent } from "../../utils/studioTelemetry";
export interface CompositionLevel { export interface CompositionLevel {
/** Unique id — "master" or composition file path */ /** Unique id — "master" or composition file path */
@@ -25,7 +26,13 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
{/* Back button — always goes to parent */} {/* Back button — always goes to parent */}
<button <button
type="button" type="button"
onClick={() => onNavigate(stack.length - 2)} onClick={() => {
trackStudioEvent("navigation", {
action: "back",
target: stack[stack.length - 2]?.label,
});
onNavigate(stack.length - 2);
}}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors" className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
title="Back (Esc)" title="Back (Esc)"
> >
@@ -43,7 +50,10 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => onNavigate(i)} onClick={() => {
trackStudioEvent("navigation", { action: "breadcrumb", target: level.label });
onNavigate(i);
}}
className="text-xs text-neutral-500 hover:text-neutral-200 transition-colors" className="text-xs text-neutral-500 hover:text-neutral-200 transition-colors"
> >
{level.label} {level.label}
@@ -2,6 +2,7 @@ import { memo, useState, useRef, useEffect } from "react";
import { RenderQueueItem } from "./RenderQueueItem"; import { RenderQueueItem } from "./RenderQueueItem";
import type { RenderJob, ResolutionPreset } from "./useRenderQueue"; import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings"; import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
import { trackStudioEvent } from "../../utils/studioTelemetry";
export interface CompositionDimensions { export interface CompositionDimensions {
width: number; width: number;
@@ -277,6 +278,7 @@ function FormatExportButton({
</select> </select>
<button <button
onClick={() => { onClick={() => {
trackStudioEvent("render_start", { format, quality, resolution, fps });
void onStartRender(format, quality, resolution, fps); void onStartRender(format, quality, resolution, fps);
}} }}
disabled={isRendering} disabled={isRendering}
@@ -8,6 +8,7 @@ import {
} from "react"; } from "react";
import { CompositionsTab } from "./CompositionsTab"; import { CompositionsTab } from "./CompositionsTab";
import { AssetsTab } from "./AssetsTab"; import { AssetsTab } from "./AssetsTab";
import { trackStudioEvent } from "../../utils/studioTelemetry";
import { BlocksTab } from "./BlocksTab"; import { BlocksTab } from "./BlocksTab";
import { FileTree } from "../editor/FileTree"; import { FileTree } from "../editor/FileTree";
import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability"; import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability";
@@ -90,6 +91,7 @@ export const LeftSidebar = memo(
const selectTab = useCallback((t: SidebarTab) => { const selectTab = useCallback((t: SidebarTab) => {
setTab(t); setTab(t);
localStorage.setItem(STORAGE_KEY, t); localStorage.setItem(STORAGE_KEY, t);
trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
}, []); }, []);
useImperativeHandle(ref, () => ({ selectTab }), [selectTab]); useImperativeHandle(ref, () => ({ selectTab }), [selectTab]);
@@ -21,7 +21,7 @@ export function FileManagerProvider({
setFileTree, setFileTree,
editingPathRef, editingPathRef,
projectIdRef, projectIdRef,
saveTimerRef, saveRafRef,
importedFontAssetsRef, importedFontAssetsRef,
readProjectFile, readProjectFile,
writeProjectFile, writeProjectFile,
@@ -59,7 +59,7 @@ export function FileManagerProvider({
setFileTree, setFileTree,
editingPathRef, editingPathRef,
projectIdRef, projectIdRef,
saveTimerRef, saveRafRef,
importedFontAssetsRef, importedFontAssetsRef,
readProjectFile, readProjectFile,
writeProjectFile, writeProjectFile,
@@ -91,7 +91,7 @@ export function FileManagerProvider({
setFileTree, setFileTree,
editingPathRef, editingPathRef,
projectIdRef, projectIdRef,
saveTimerRef, saveRafRef,
importedFontAssetsRef, importedFontAssetsRef,
readProjectFile, readProjectFile,
writeProjectFile, writeProjectFile,
+52 -24
View File
@@ -1,7 +1,8 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { usePlayerStore } from "../player"; import { usePlayerStore } from "../player";
import { FONT_EXT } from "../utils/mediaTypes"; import { FONT_EXT } from "../utils/mediaTypes";
import { applyPatchByTarget } from "../utils/sourcePatcher"; import type { PatchOperation } from "../utils/sourcePatcher";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { primaryFontFamilyValue } from "../utils/studioFontHelpers"; import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing"; import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
@@ -45,7 +46,7 @@ interface RecordEditInput {
export type PersistDomEditOperations = ( export type PersistDomEditOperations = (
selection: DomEditSelection, selection: DomEditSelection,
operations: Parameters<typeof applyPatchByTarget>[2][], operations: PatchOperation[],
options?: { options?: {
label?: string; label?: string;
coalesceKey?: string; coalesceKey?: string;
@@ -134,39 +135,61 @@ export function useDomEditCommits({
if (options?.shouldSave && !options.shouldSave()) return; if (options?.shouldSave && !options.shouldSave()) return;
const targetPath = selection.sourceFile || activeCompPath || "index.html"; const targetPath = selection.sourceFile || activeCompPath || "index.html";
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
if (!response.ok) {
throw new Error(`Failed to read ${targetPath}`);
}
const data = (await response.json()) as { content?: string }; const readResponse = await fetch(
const originalContent = data.content; `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
);
if (!readResponse.ok) throw new Error(`Failed to read ${targetPath}`);
const readData = (await readResponse.json()) as { content?: string };
const originalContent = readData.content;
if (typeof originalContent !== "string") { if (typeof originalContent !== "string") {
throw new Error(`Missing file contents for ${targetPath}`); throw new Error(`Missing file contents for ${targetPath}`);
} }
let patchedContent = originalContent;
for (const operation of operations) {
patchedContent = applyPatchByTarget(patchedContent, selection, operation);
}
if (options?.prepareContent) {
patchedContent = options.prepareContent(patchedContent, targetPath);
}
if (options?.shouldSave && !options.shouldSave()) return; if (options?.shouldSave && !options.shouldSave()) return;
if (patchedContent === originalContent) { const patchTarget: { id?: string | null; selector?: string; selectorIndex?: number } = {
id: selection.id,
selector: selection.selector,
selectorIndex: selection.selectorIndex,
};
const patchResponse = await fetch(
`/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: patchTarget, operations }),
},
);
if (!patchResponse.ok) throw new Error(`Failed to patch ${targetPath}`);
const patchData = (await patchResponse.json()) as {
ok?: boolean;
changed?: boolean;
content?: string;
};
if (!patchData.changed) {
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`); throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
} }
await saveProjectFilesWithHistory({ const patchedContent =
projectId: pid, typeof patchData.content === "string" ? patchData.content : originalContent;
let finalContent = patchedContent;
if (options?.prepareContent) {
finalContent = options.prepareContent(patchedContent, targetPath);
if (finalContent !== patchedContent) {
await writeProjectFile(targetPath, finalContent);
}
}
await editHistory.recordEdit({
label: options?.label ?? "Edit layer", label: options?.label ?? "Edit layer",
kind: "manual", kind: "manual",
coalesceKey: options?.coalesceKey, coalesceKey: options?.coalesceKey,
files: { [targetPath]: patchedContent }, files: { [targetPath]: { before: originalContent, after: finalContent } },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit: editHistory.recordEdit,
}); });
if (options?.skipRefresh) { if (options?.skipRefresh) {
@@ -177,7 +200,7 @@ export function useDomEditCommits({
}, },
[ [
activeCompPath, activeCompPath,
editHistory.recordEdit, editHistory,
writeProjectFile, writeProjectFile,
projectIdRef, projectIdRef,
domEditSaveTimestampRef, domEditSaveTimestampRef,
@@ -212,7 +235,7 @@ export function useDomEditCommits({
const commitPositionPatchToHtml = useCallback( const commitPositionPatchToHtml = useCallback(
( (
selection: DomEditSelection, selection: DomEditSelection,
patches: Parameters<typeof applyPatchByTarget>[2][], patches: PatchOperation[],
options: { label: string; coalesceKey: string; skipRefresh?: boolean }, options: { label: string; coalesceKey: string; skipRefresh?: boolean },
) => { ) => {
void queueDomEditSave(async () => { void queueDomEditSave(async () => {
@@ -224,6 +247,11 @@ export function useDomEditCommits({
}).catch((error) => { }).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to save position"; const message = error instanceof Error ? error.message : "Failed to save position";
showToast(message); showToast(message);
trackStudioEvent("save_failure", {
source: "dom_edit",
label: options.label,
error_message: message,
});
}); });
}, },
[persistDomEditOperations, queueDomEditSave, showToast], [persistDomEditOperations, queueDomEditSave, showToast],
+15 -13
View File
@@ -5,6 +5,7 @@ import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/e
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import type { EditHistoryKind } from "../utils/editHistory"; import type { EditHistoryKind } from "../utils/editHistory";
import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher"; import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
import { trackStudioEvent } from "../utils/studioTelemetry";
// ── Types ── // ── Types ──
@@ -48,8 +49,8 @@ export function useFileManager({
const projectIdRef = useRef(projectId); const projectIdRef = useRef(projectId);
projectIdRef.current = projectId; projectIdRef.current = projectId;
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const saveRafRef = useRef<number | null>(null);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const refreshRafRef = useRef<number | null>(null);
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]); const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
// ── Load file tree when projectId changes ── // ── Load file tree when projectId changes ──
@@ -145,12 +146,8 @@ export function useFileManager({
const path = editingPathRef.current; const path = editingPathRef.current;
if (!path) return; if (!path) return;
// Debounce the server write (600ms) if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveRafRef.current = requestAnimationFrame(() => {
saveTimerRef.current = setTimeout(() => {
// Suppress the file-change watcher echo — the save callback triggers
// its own refresh, so a second one from the watcher causes a double-reload
// race that can leave the player in a non-playable state.
domEditSaveTimestampRef.current = Date.now(); domEditSaveTimestampRef.current = Date.now();
saveProjectFilesWithHistory({ saveProjectFilesWithHistory({
projectId: pid, projectId: pid,
@@ -163,11 +160,16 @@ export function useFileManager({
recordEdit, recordEdit,
}) })
.then(() => { .then(() => {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600); refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
}) })
.catch(() => {}); .catch((error) => {
}, 600); trackStudioEvent("save_failure", {
source: "code_editor",
error_message: error instanceof Error ? error.message : "unknown",
});
});
});
}, },
[domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile], [domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile],
); );
@@ -449,7 +451,7 @@ export function useFileManager({
// Refs // Refs
editingPathRef, editingPathRef,
projectIdRef, projectIdRef,
saveTimerRef, saveRafRef,
importedFontAssetsRef, importedFontAssetsRef,
// Core I/O // Core I/O
+11 -1
View File
@@ -1,6 +1,7 @@
import { useState, useCallback, useRef } from "react"; import { useState, useCallback, useRef } from "react";
import type { RightPanelTab } from "../utils/studioHelpers"; import type { RightPanelTab } from "../utils/studioHelpers";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
import { trackStudioEvent } from "../utils/studioTelemetry";
export interface InitialPanelLayoutState { export interface InitialPanelLayoutState {
rightCollapsed?: boolean | null; rightCollapsed?: boolean | null;
@@ -26,6 +27,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
const toggleLeftSidebar = useCallback(() => { const toggleLeftSidebar = useCallback(() => {
setLeftCollapsed((collapsed) => { setLeftCollapsed((collapsed) => {
writeStudioUiPreferences({ leftCollapsed: !collapsed }); writeStudioUiPreferences({ leftCollapsed: !collapsed });
trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: !collapsed });
return !collapsed; return !collapsed;
}); });
}, []); }, []);
@@ -63,6 +65,14 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
panelDragRef.current = null; panelDragRef.current = null;
}, []); }, []);
const trackedSetRightPanelTab = useCallback(
(tab: RightPanelTab) => {
setRightPanelTab(tab);
trackStudioEvent("tab_switch", { panel: "right_panel", tab });
},
[setRightPanelTab],
);
return { return {
leftWidth, leftWidth,
setLeftWidth, setLeftWidth,
@@ -72,7 +82,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
rightCollapsed, rightCollapsed,
setRightCollapsed, setRightCollapsed,
rightPanelTab, rightPanelTab,
setRightPanelTab, setRightPanelTab: trackedSetRightPanelTab,
toggleLeftSidebar, toggleLeftSidebar,
handlePanelResizeStart, handlePanelResizeStart,
handlePanelResizeMove, handlePanelResizeMove,
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting"; import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting";
import { useMountEffect } from "./useMountEffect"; import { useMountEffect } from "./useMountEffect";
@@ -67,5 +67,15 @@ export function useServerConnection(): ServerConnectionState {
}; };
}); });
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
const onHashChange = () => {
const next = parseProjectIdFromHash(window.location.hash);
if (next && next !== projectId) setProjectId(next);
};
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, [projectId]);
return { projectId, resolving, waitingForServer }; return { projectId, resolving, waitingForServer };
} }
+22 -1
View File
@@ -1,10 +1,31 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { StudioApp } from "./App"; import { StudioApp } from "./App";
import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
import { trackStudioEvent } from "./utils/studioTelemetry";
import "./styles/studio.css"; import "./styles/studio.css";
trackStudioEvent("session_start");
window.addEventListener("error", (event) => {
trackStudioEvent("unhandled_error", {
error_message: event.message,
filename: event.filename ?? null,
lineno: event.lineno ?? null,
colno: event.colno ?? null,
});
});
window.addEventListener("unhandledrejection", (event) => {
trackStudioEvent("unhandled_promise_rejection", {
error_message: event.reason instanceof Error ? event.reason.message : String(event.reason),
});
});
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<StudioApp /> <StudioErrorBoundary>
<StudioApp />
</StudioErrorBoundary>
</StrictMode>, </StrictMode>,
); );
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time"; import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers"; import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
import { usePlayerStore, liveTime } from "../store/playerStore"; 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 SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
const SEEK_EDGE_SNAP_PX = 8; const SEEK_EDGE_SNAP_PX = 8;
@@ -337,7 +338,10 @@ export const PlayerControls = memo(function PlayerControls({
<button <button
type="button" type="button"
aria-label={isPlaying ? "Pause" : "Play"} aria-label={isPlaying ? "Pause" : "Play"}
onClick={onTogglePlay} onClick={() => {
trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
onTogglePlay();
}}
disabled={controlsDisabled} 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" 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)" }} style={{ background: "rgba(255,255,255,0.06)" }}
@@ -463,7 +467,10 @@ export const PlayerControls = memo(function PlayerControls({
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
if (!audioAutoMuted) setAudioMuted(!audioMuted); if (!audioAutoMuted) {
trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
setAudioMuted(!audioMuted);
}
}} }}
disabled={controlsDisabled || audioAutoMuted} disabled={controlsDisabled || audioAutoMuted}
title={muteButtonLabel} title={muteButtonLabel}
@@ -530,6 +537,7 @@ export const PlayerControls = memo(function PlayerControls({
<button <button
key={rate} key={rate}
onClick={() => { onClick={() => {
trackStudioEvent("playback", { action: "speed_change", rate });
setPlaybackRate(rate); setPlaybackRate(rate);
setShowSpeedMenu(false); setShowSpeedMenu(false);
}} }}
@@ -555,7 +563,10 @@ export const PlayerControls = memo(function PlayerControls({
<button <button
type="button" type="button"
onClick={() => setLoopEnabled(!loopEnabled)} onClick={() => {
trackStudioEvent("playback", { action: "loop_toggle", enabled: !loopEnabled });
setLoopEnabled(!loopEnabled);
}}
disabled={disabled} disabled={disabled}
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${ className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
loopEnabled loopEnabled
+20 -5
View File
@@ -1,5 +1,3 @@
import { Input, UrlSource, ALL_FORMATS } from "mediabunny";
export interface MediaProbeResult { export interface MediaProbeResult {
duration: number; duration: number;
width?: number; width?: number;
@@ -11,6 +9,20 @@ export interface MediaProbeResult {
const cache = new Map<string, MediaProbeResult>(); const cache = new Map<string, MediaProbeResult>();
const inflight = new Map<string, Promise<MediaProbeResult | null>>(); 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 { function normalizeUrl(url: string): string {
try { try {
return new URL(url, window.location.href).href; 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> { async function probeOne(url: string): Promise<MediaProbeResult | null> {
const input = new Input({ const mb = await loadMediabunny();
source: new UrlSource(url), if (!mb) return null;
formats: ALL_FORMATS,
const input = new mb.Input({
source: new mb.UrlSource(url),
formats: mb.ALL_FORMATS,
}); });
try { try {
const duration = await input.getDurationFromMetadata(); const duration = await input.getDurationFromMetadata();
@@ -0,0 +1,128 @@
// PostHog public ingest key — write-only, safe to ship in the client bundle
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
const POSTHOG_HOST = "https://us.i.posthog.com";
const FLUSH_INTERVAL_MS = 30_000;
const FLUSH_TIMEOUT_MS = 5_000;
interface EventProperties {
[key: string]: string | number | boolean | null | undefined;
}
interface QueuedEvent {
event: string;
properties: EventProperties;
timestamp: string;
}
let queue: QueuedEvent[] = [];
let flushTimer: ReturnType<typeof setInterval> | null = null;
let distinctId: string | null = null;
function getDistinctId(): string {
if (distinctId) return distinctId;
try {
const stored = localStorage.getItem("hf-studio-anon-id");
if (stored) {
distinctId = stored;
return stored;
}
} catch {
// localStorage may be unavailable
}
distinctId = crypto.randomUUID();
try {
localStorage.setItem("hf-studio-anon-id", distinctId);
} catch {
// best-effort persistence
}
return distinctId;
}
function isEnabled(): boolean {
try {
return localStorage.getItem("hf-studio-telemetry-opt-out") !== "1";
} catch {
return true;
}
}
function getSessionProperties(): EventProperties {
return {
studio_version: typeof __STUDIO_VERSION__ !== "undefined" ? __STUDIO_VERSION__ : "dev",
screen_width: window.screen?.width,
screen_height: window.screen?.height,
viewport_width: window.innerWidth,
viewport_height: window.innerHeight,
user_agent: navigator.userAgent,
url_hash: location.hash.replace(/#project\//, ""),
};
}
declare const __STUDIO_VERSION__: string;
export function trackStudioEvent(event: string, properties: EventProperties = {}): void {
if (!isEnabled()) return;
queue.push({
event: `studio:${event}`,
properties: { ...getSessionProperties(), ...properties },
timestamp: new Date().toISOString(),
});
if (!flushTimer) {
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
}
}
async function flushEvents(): Promise<void> {
if (queue.length === 0) return;
const batch = queue.map((e) => ({
event: e.event,
properties: { ...e.properties, $ip: null },
distinct_id: getDistinctId(),
timestamp: e.timestamp,
}));
queue = [];
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
try {
await fetch(`${POSTHOG_HOST}/batch/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
signal: controller.signal,
});
} catch {
// Telemetry must never break the studio
} finally {
clearTimeout(timeout);
}
}
if (typeof window !== "undefined") {
window.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
if (flushTimer) {
clearInterval(flushTimer);
flushTimer = null;
}
if (queue.length === 0) return;
const batch = queue.map((e) => ({
event: e.event,
properties: { ...e.properties, $ip: null },
distinct_id: getDistinctId(),
timestamp: e.timestamp,
}));
queue = [];
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
try {
navigator.sendBeacon(`${POSTHOG_HOST}/batch/`, body);
} catch {
// best-effort
}
}
});
}
+3
View File
@@ -166,6 +166,9 @@ function devProjectApi(): Plugin {
export default defineConfig({ export default defineConfig({
plugins: [react(), devProjectApi()], plugins: [react(), devProjectApi()],
define: {
__STUDIO_VERSION__: JSON.stringify(process.env.npm_package_version ?? "dev"),
},
resolve: { resolve: {
alias: { alias: {
"@hyperframes/player": resolve(__dirname, "../player/src/hyperframes-player.ts"), "@hyperframes/player": resolve(__dirname, "../player/src/hyperframes-player.ts"),