mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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:
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user