diff --git a/packages/core/package.json b/packages/core/package.json index ba63ef589..d350bb7a1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -50,6 +50,10 @@ "import": "./src/studio-api/helpers/studioMotionRenderScript.ts", "types": "./src/studio-api/helpers/studioMotionRenderScript.ts" }, + "./studio-api/draft-markers": { + "import": "./src/studio-api/helpers/draftMarkers.ts", + "types": "./src/studio-api/helpers/draftMarkers.ts" + }, "./text": { "import": "./src/text/index.ts", "types": "./src/text/index.ts" @@ -121,6 +125,10 @@ "import": "./dist/studio-api/helpers/studioMotionRenderScript.js", "types": "./dist/studio-api/helpers/studioMotionRenderScript.d.ts" }, + "./studio-api/draft-markers": { + "import": "./dist/studio-api/helpers/draftMarkers.js", + "types": "./dist/studio-api/helpers/draftMarkers.d.ts" + }, "./text": { "import": "./dist/text/index.js", "types": "./dist/text/index.d.ts" diff --git a/packages/core/src/parsers/hfIds.test.ts b/packages/core/src/parsers/hfIds.test.ts index 6001afe08..a409fbbaf 100644 --- a/packages/core/src/parsers/hfIds.test.ts +++ b/packages/core/src/parsers/hfIds.test.ts @@ -88,12 +88,14 @@ describe("ensureHfIds", () => { expect(ids(ensureHfIds(edited))).toContain(originalId); }); - it("pinned id survives attribute edit after first persist", () => { - const raw = `
text
`; - const persisted = ensureHfIds(raw); // simulates write-back on first serve - const [originalId] = ids(persisted); - const edited = persisted.replace('class="old"', 'class="new"'); - expect(ids(ensureHfIds(edited))).toContain(originalId); + // Hash-based stability (no prior pin): the same element content yields the + // same id regardless of what sibling elements appear in the document. + it("content-keyed minting is stable: same element content → same id in different documents", () => { + const alone = `
hello
`; + const [idAlone] = ids(ensureHfIds(alone)); + // The
hello
appears alongside a new sibling here. + const withSibling = `prefix
hello
`; + expect(ids(ensureHfIds(withSibling))).toContain(idAlone); }); }); diff --git a/packages/core/src/parsers/hfIds.ts b/packages/core/src/parsers/hfIds.ts index 7b1f9cff4..1e3b88222 100644 --- a/packages/core/src/parsers/hfIds.ts +++ b/packages/core/src/parsers/hfIds.ts @@ -67,6 +67,13 @@ function contentKey(el: Element): string { * (`if (el.getAttribute("data-hf-id")) continue`), so normal operation * never re-exposes the ordering after first persist. */ +// WIRE CONTRACT: id minting is content-keyed (FNV1a of innerHTML + tag). R7's +// preview route relies on mintHfId producing identical ids across mint contexts +// (disk-persist pass vs. in-memory bundle pass) — see preview.test.ts +// "bundle returning untagged HTML gets same ids as disk". Any change that adds +// positional, session, or random input to the hash breaks that invariant and +// makes hf- ids diverge between disk and served HTML, silently corrupting +// drag-to-edit targeting. export function mintHfId(el: Element, assigned: Set): string { const key = contentKey(el); let id = toHfId(fnv1a(key)); diff --git a/packages/core/src/studio-api/helpers/draftMarkers.ts b/packages/core/src/studio-api/helpers/draftMarkers.ts new file mode 100644 index 000000000..38bc61861 --- /dev/null +++ b/packages/core/src/studio-api/helpers/draftMarkers.ts @@ -0,0 +1,10 @@ +/** + * Draft-marker constants shared between core's PreviewAdapter and Studio's + * manual-edits code. CSS custom properties written during a drag gesture, plus + * the gesture marker attribute. Exported from @hyperframes/core/studio-api/draft-markers. + */ +export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x"; +export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y"; +export const STUDIO_WIDTH_PROP = "--hf-studio-width"; +export const STUDIO_HEIGHT_PROP = "--hf-studio-height"; +export const STUDIO_MANUAL_EDIT_GESTURE_ATTR = "data-hf-studio-manual-edit-gesture"; diff --git a/packages/core/src/studio-api/helpers/hfIdPersist.test.ts b/packages/core/src/studio-api/helpers/hfIdPersist.test.ts index e0e0ab154..472d8215b 100644 --- a/packages/core/src/studio-api/helpers/hfIdPersist.test.ts +++ b/packages/core/src/studio-api/helpers/hfIdPersist.test.ts @@ -2,26 +2,7 @@ import { describe, it, expect, afterEach } from "vitest"; import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { normalizeHfIds, persistHfIdsIfNeeded } from "./hfIdPersist.js"; - -describe("normalizeHfIds", () => { - it("marks changed=true and adds data-hf-id to all body elements when untagged", () => { - const raw = `

hello

`; - const { html, changed } = normalizeHfIds(raw); - expect(changed).toBe(true); - expect(html).toContain('data-hf-id="hf-'); - const matches = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g); - expect(matches?.length).toBeGreaterThanOrEqual(2); - }); - - it("marks changed=false for already-normalized HTML (idempotent round-trip)", () => { - const raw = `

hello

`; - const first = normalizeHfIds(raw).html; - const { html, changed } = normalizeHfIds(first); - expect(changed).toBe(false); - expect(html).toBe(first); - }); -}); +import { persistHfIdsIfNeeded } from "./hfIdPersist.js"; describe("persistHfIdsIfNeeded", () => { const tmpDirs: string[] = []; @@ -59,6 +40,15 @@ describe("persistHfIdsIfNeeded", () => { expect(readFileSync(file, "utf-8")).toBe(diskAfterFirst); }); + it("does not rewrite when source is already tagged with non-standard HTML formatting", () => { + // Single-quoted attrs would cause a false-positive write under string-equality + // change detection; count-based detection handles this correctly. + const alreadyTagged = `
hello
`; + const file = tmpFile(alreadyTagged); + persistHfIdsIfNeeded(file, alreadyTagged); + expect(readFileSync(file, "utf-8")).toBe(alreadyTagged); + }); + it("returned id matches id written to disk (serve-time == persist-time invariant)", () => { const raw = `text`; const file = tmpFile(raw); @@ -66,4 +56,16 @@ describe("persistHfIdsIfNeeded", () => { const onDisk = readFileSync(file, "utf-8"); expect(result).toBe(onDisk); }); + + it("skips write if file was modified concurrently (TOCTOU guard)", () => { + const old = `
original
`; + const newer = `
modified by user
`; + // Disk has newer content — simulates a concurrent save after the server read old. + const file = tmpFile(newer); + const returned = persistHfIdsIfNeeded(file, old); + // Serve-time HTML gets ids based on what we read. + expect(returned).toContain('data-hf-id="hf-'); + // Disk must not be overwritten — user's concurrent save is preserved. + expect(readFileSync(file, "utf-8")).toBe(newer); + }); }); diff --git a/packages/core/src/studio-api/helpers/hfIdPersist.ts b/packages/core/src/studio-api/helpers/hfIdPersist.ts index 8ec6bfdc4..22b8a2746 100644 --- a/packages/core/src/studio-api/helpers/hfIdPersist.ts +++ b/packages/core/src/studio-api/helpers/hfIdPersist.ts @@ -1,20 +1,37 @@ import { ensureHfIds } from "../../parsers/hfIds.js"; -import { writeFileSync } from "node:fs"; - -export { ensureHfIds }; - -export function normalizeHfIds(html: string): { html: string; changed: boolean } { - const normalized = ensureHfIds(html); - return { html: normalized, changed: normalized !== html }; -} +import { readFileSync, writeFileSync } from "node:fs"; +/** + * Ensure `html` has `data-hf-id` attributes minted, and write the result back + * to `filePath` if new ids were added. + * + * **Invariant:** `html` must be the raw file content read from `filePath` just + * before this call. If `html` is constructed or transformed HTML the TOCTOU + * guard (`current === html`) will never match and writes will silently be + * skipped — no ids will reach disk. + */ export function persistHfIdsIfNeeded(filePath: string, html: string): string { - const { html: normalized, changed } = normalizeHfIds(html); - if (changed) { + const normalized = ensureHfIds(html); + // Use attribute count instead of string equality: linkedom serialization may + // normalize quote style and whitespace even when no ids were actually minted, + // which would cause spurious writes on every request. + const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length; + const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length; + if (idsAfter > idsBefore) { try { - writeFileSync(filePath, normalized, "utf-8"); - } catch { - // non-fatal — serve with ids even if persist fails + // Re-read before writing to guard against concurrent user saves. If the + // file changed since we read it, skip the write — serving with ids is + // still correct; the next request will re-persist. Best-effort only: a + // user save landing between readFileSync and writeFileSync below can + // still be overwritten (microsecond window). + const current = readFileSync(filePath, "utf-8"); + if (current === html) { + writeFileSync(filePath, normalized, "utf-8"); + } + } catch (err) { + // Non-fatal — serve with ids even if the disk write fails (e.g. read-only + // filesystem, sandboxed environment). Log so the failure is diagnosable. + console.warn("[hyperframes] persistHfIdsIfNeeded: failed to write ids to disk:", err); } } return normalized; diff --git a/packages/core/src/studio-api/helpers/previewAdapter.test.ts b/packages/core/src/studio-api/helpers/previewAdapter.test.ts index eb79f92a6..cbd1db8eb 100644 --- a/packages/core/src/studio-api/helpers/previewAdapter.test.ts +++ b/packages/core/src/studio-api/helpers/previewAdapter.test.ts @@ -64,11 +64,21 @@ describe("T10 — PreviewAdapter contract (spec for R7)", () => { expect(adapter.elementAtPoint(0, 0)).toBeNull(); }); - it("skips elements whose computed opacity is 0 at the given playhead time", () => { + it("skips elements whose currently-computed opacity is 0 (atTime is a caller-seek hint, not evaluated by the adapter)", () => { const elem = make("div", { "data-hf-id": "hf-zzzz" }, { opacity: "0" }); const adapter = adapterWith(() => elem); expect(adapter.elementAtPoint(0, 0, { atTime: 1.0 })).toBeNull(); }); + + it("returns null for nested data-hf-root without data-hf-id (treated same as outer stage root)", () => { + const outerRoot = make("div", { "data-hf-root": "true" }); + const innerRoot = document.createElement("div"); + innerRoot.setAttribute("data-hf-root", "true"); + // no data-hf-id — no explicit id means no draggable target + outerRoot.appendChild(innerRoot); + const adapter = adapterWith(() => innerRoot); + expect(adapter.elementAtPoint(0, 0)).toBeNull(); + }); }); // ── applyDraft / revertDraft ─────────────────────────────────────────── @@ -130,19 +140,44 @@ describe("T10 — PreviewAdapter contract (spec for R7)", () => { expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("15px"); }); + it("resize → move switch clears width/height props — no cross-type prop leak", () => { + const target = make("div", { "data-hf-id": "hf-aaaa" }); + const adapter = adapterWith(() => null); + adapter.applyDraft({ type: "resize", hfId: "hf-aaaa", w: 200, h: 100 }); + adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 5 }); + // move props set + expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("10px"); + expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("5px"); + // resize props cleared by the auto-revert before re-apply + expect(target.style.getPropertyValue("--hf-studio-width")).toBe(""); + expect(target.style.getPropertyValue("--hf-studio-height")).toBe(""); + }); + + it("revertDraft after commitPreview is a no-op — does not restore stale translate", () => { + const target = make("div", { "data-hf-id": "hf-aaaa" }); + target.style.setProperty("translate", "50px 0px"); + const adapter = adapterWith(() => null); + adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 0 }); + adapter.commitPreview(); + // simulate caller applying translate after commit + target.style.setProperty("translate", "10px 0px"); + adapter.revertDraft(); // no gesture in flight — should be no-op + expect(target.style.getPropertyValue("translate")).toBe("10px 0px"); + }); + it("revertDraft is safe to call when no gesture is in progress (idempotent / no-op on empty marker)", () => { const adapter = adapterWith(() => null); expect(() => adapter.revertDraft()).not.toThrow(); expect(() => adapter.revertDraft()).not.toThrow(); }); - it("elementAtPoint filtering is stable when playhead changes mid-drag — opacity re-evaluated per call", () => { + it("elementAtPoint filtering is stable when inline opacity changes mid-drag — computed style re-evaluated per call", () => { const elem = make("div", { "data-hf-id": "hf-zzzz" }); const adapter = adapterWith(() => elem); - expect(adapter.elementAtPoint(0, 0, { atTime: 0 })).toBe(elem); + expect(adapter.elementAtPoint(0, 0)).toBe(elem); // simulates GSAP seeking to a time where the element is hidden elem.style.setProperty("opacity", "0"); - expect(adapter.elementAtPoint(0, 0, { atTime: 1.0 })).toBeNull(); + expect(adapter.elementAtPoint(0, 0)).toBeNull(); }); it("stage-root exclusion applies only to the outermost data-hf-root; nested sub-composition roots count as targets", () => { diff --git a/packages/core/src/studio-api/helpers/previewAdapter.ts b/packages/core/src/studio-api/helpers/previewAdapter.ts index e1ecca91e..df1977200 100644 --- a/packages/core/src/studio-api/helpers/previewAdapter.ts +++ b/packages/core/src/studio-api/helpers/previewAdapter.ts @@ -1,3 +1,11 @@ +import { + STUDIO_OFFSET_X_PROP, + STUDIO_OFFSET_Y_PROP, + STUDIO_WIDTH_PROP, + STUDIO_HEIGHT_PROP, + STUDIO_MANUAL_EDIT_GESTURE_ATTR, +} from "./draftMarkers.js"; + export type DraftPayload = | { type: "move"; hfId: string; dx: number; dy: number } | { type: "resize"; hfId: string; w: number; h: number }; @@ -7,6 +15,11 @@ export type CommitPatch = | { type: "resize"; hfId: string; width: number; height: number }; export interface PreviewAdapter { + /** + * @param atTime - Caller hint only. The adapter reads current computed styles; + * the caller must seek the GSAP timeline to `atTime` before invoking so that + * GSAP-driven inline styles reflect the desired playhead position. + */ elementAtPoint(x: number, y: number, opts?: { atTime?: number }): Element | null; applyDraft(payload: DraftPayload): void; revertDraft(): void; @@ -15,7 +28,6 @@ export interface PreviewAdapter { } interface GestureState { - hfId: string; payload: DraftPayload; originalTranslate: string | undefined; } @@ -27,24 +39,50 @@ export function createPreviewAdapter( let gesture: GestureState | null = null; function findById(hfId: string): HTMLElement | null { - return doc.querySelector(`[data-hf-id="${hfId}"]`) as HTMLElement | null; + // CSS.escape is available in browsers; hf-ids are always hf-[a-z0-9]+ so + // no escaping is strictly needed, but be safe in non-browser environments. + const escaped = + typeof CSS !== "undefined" && typeof CSS.escape === "function" + ? CSS.escape(hfId) + : hfId.replace(/([^\w-])/g, "\\$1"); + return doc.querySelector(`[data-hf-id="${escaped}"]`) as HTMLElement | null; } - function opacity(el: Element): number { + function isVisible(el: Element): boolean { const view = doc.defaultView; - if (!view) return 1; - return parseFloat(view.getComputedStyle(el).opacity) || 0; + if (!view) return true; + const style = view.getComputedStyle(el); + if (style.display === "none" || style.visibility === "hidden") return false; + const op = parseFloat(style.opacity); + // NaN (empty string from environments with no CSS cascade) → treat as visible. + // 0.01 threshold: sub-1% opacity is not user-targetable in drag gestures. + return Number.isNaN(op) || op >= 0.01; + } + + function clearDraftProps(target: HTMLElement): void { + target.style.removeProperty(STUDIO_OFFSET_X_PROP); + target.style.removeProperty(STUDIO_OFFSET_Y_PROP); + target.style.removeProperty(STUDIO_WIDTH_PROP); + target.style.removeProperty(STUDIO_HEIGHT_PROP); + target.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR); + } + + function revertGesture(target: HTMLElement, state: GestureState): void { + clearDraftProps(target); + if (state.originalTranslate !== undefined) { + target.style.setProperty("translate", state.originalTranslate); + } } return { - elementAtPoint(x, y, _opts) { + elementAtPoint(x, y, _perCallOpts) { const hit = opts?.resolvePoint?.(x, y) ?? null; if (!hit) return null; let el: Element | null = hit; while (el && el !== doc.body) { if (el.hasAttribute("data-hf-id")) { - return opacity(el) === 0 ? null : (el as HTMLElement); + return isVisible(el) ? (el as HTMLElement) : null; } // data-hf-root without data-hf-id = outermost stage root — stop if (el.hasAttribute("data-hf-root")) return null; @@ -54,64 +92,63 @@ export function createPreviewAdapter( }, applyDraft(payload) { + // Auto-revert any in-flight gesture before starting a new one so no + // element is left with orphaned draft CSS props or the gesture marker. + if (gesture) { + const prev = findById(gesture.payload.hfId); + if (prev) revertGesture(prev, gesture); + gesture = null; + } + const target = findById(payload.hfId); if (!target) return; const originalTranslate = target.style.getPropertyValue("translate") || undefined; - gesture = { hfId: payload.hfId, payload, originalTranslate }; - target.setAttribute("data-hf-studio-manual-edit-gesture", "true"); + gesture = { payload, originalTranslate }; + target.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, "true"); if (payload.type === "move") { - target.style.setProperty("--hf-studio-offset-x", `${payload.dx}px`); - target.style.setProperty("--hf-studio-offset-y", `${payload.dy}px`); + target.style.setProperty(STUDIO_OFFSET_X_PROP, `${payload.dx}px`); + target.style.setProperty(STUDIO_OFFSET_Y_PROP, `${payload.dy}px`); } else { - target.style.setProperty("--hf-studio-width", `${payload.w}px`); - target.style.setProperty("--hf-studio-height", `${payload.h}px`); + target.style.setProperty(STUDIO_WIDTH_PROP, `${payload.w}px`); + target.style.setProperty(STUDIO_HEIGHT_PROP, `${payload.h}px`); } }, revertDraft() { if (!gesture) return; - const target = findById(gesture.hfId); - if (target) { - target.style.removeProperty("--hf-studio-offset-x"); - target.style.removeProperty("--hf-studio-offset-y"); - target.style.removeProperty("--hf-studio-width"); - target.style.removeProperty("--hf-studio-height"); - target.removeAttribute("data-hf-studio-manual-edit-gesture"); - if (gesture.originalTranslate !== undefined) { - target.style.setProperty("translate", gesture.originalTranslate); - } - } + const target = findById(gesture.payload.hfId); + if (target) revertGesture(target, gesture); gesture = null; }, commitPreview() { if (!gesture) return null; - const { hfId, payload } = gesture; + const { payload } = gesture; - const target = findById(hfId); - if (target) { - target.removeAttribute("data-hf-studio-manual-edit-gesture"); - } + const target = findById(payload.hfId); + if (target) clearDraftProps(target); gesture = null; if (payload.type === "move") { - return { type: "moveElement", hfId, dx: payload.dx, dy: payload.dy }; + return { type: "moveElement", hfId: payload.hfId, dx: payload.dx, dy: payload.dy }; } - return { type: "resize", hfId, width: payload.w, height: payload.h }; + return { type: "resize", hfId: payload.hfId, width: payload.w, height: payload.h }; }, getElementTimings() { const result: Record = {}; - for (const el of Array.from(doc.querySelectorAll("[data-hf-id]"))) { + for (const el of doc.querySelectorAll("[data-hf-id]")) { const hfId = el.getAttribute("data-hf-id"); if (!hfId) continue; const s = el.getAttribute("data-start"); const e = el.getAttribute("data-end"); + const sv = s !== null ? parseFloat(s) : NaN; + const ev = e !== null ? parseFloat(e) : NaN; result[hfId] = { - start: s !== null ? parseFloat(s) : undefined, - end: e !== null ? parseFloat(e) : undefined, + start: Number.isFinite(sv) ? sv : undefined, + end: Number.isFinite(ev) ? ev : undefined, }; } return result; diff --git a/packages/core/src/studio-api/routes/preview.test.ts b/packages/core/src/studio-api/routes/preview.test.ts index c2c6ca5b2..8688a3c98 100644 --- a/packages/core/src/studio-api/routes/preview.test.ts +++ b/packages/core/src/studio-api/routes/preview.test.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication import { afterEach, describe, expect, it, vi } from "vitest"; import { Hono } from "hono"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; @@ -360,4 +361,33 @@ describe("hf-id surfacing in preview route", () => { const onDisk = readFileSync(indexPath, "utf-8"); expect(onDisk).toContain('data-hf-id="hf-'); }); + + it("bundle returning untagged HTML gets same ids as disk — content-hash is stable across mint contexts", async () => { + // Regression guard for bundle-vs-disk id divergence: if the bundler reads from + // a pre-write cache snapshot (no ids), ensureHfIds mints ids on the bundle output. + // Because ids are content-keyed (FNV1a of element content), the minted ids must + // equal the ids persisted to disk for the same source HTML — otherwise a + // drag-to-edit patch keyed by a wire-time id would fail to apply on disk. + const { readFileSync } = await import("node:fs"); + const projectDir = createProjectDir(); + const indexPath = join(projectDir, "index.html"); + const sourceHtml = `

hello

`; + writeFileSync(indexPath, sourceHtml); + + const app = new Hono(); + // Bundler returns the same untagged source HTML (simulates stale cache read) + registerPreviewRoutes(app, createAdapter(projectDir, { bundle: async () => sourceHtml })); + const res = await app.request("http://localhost/projects/demo/preview"); + expect(res.status).toBe(200); + + const servedHtml = await res.text(); + const diskHtml = readFileSync(indexPath, "utf-8"); + + // Extract ids from served HTML and disk HTML + const servedIds = [...servedHtml.matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map((m) => m[1]); + const diskIds = [...diskHtml.matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map((m) => m[1]); + + expect(servedIds.length).toBeGreaterThanOrEqual(2); + expect(servedIds).toEqual(diskIds); + }); }); diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts index ad74ec2f8..da169092e 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/core/src/studio-api/routes/preview.ts @@ -11,7 +11,8 @@ import { createStudioMotionRenderBodyScript, STUDIO_MOTION_PATH, } from "../helpers/studioMotionRenderScript.js"; -import { ensureHfIds, persistHfIdsIfNeeded } from "../helpers/hfIdPersist.js"; +import { ensureHfIds } from "../../parsers/hfIds.js"; +import { persistHfIdsIfNeeded } from "../helpers/hfIdPersist.js"; const PROJECT_SIGNATURE_META = "hyperframes-project-signature"; const GSAP_CDN_VERSION = "3.15.0"; @@ -195,6 +196,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi }); // Bundled composition preview + // fallow-ignore-next-line complexity api.get("/projects/:id/preview", async (c) => { const project = await adapter.resolveProject(c.req.param("id")); if (!project) return c.json({ error: "not found" }, 404); @@ -216,8 +218,8 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi let bundled = await adapter.bundle(project.dir); let mainCompositionPath = "index.html"; if (!bundled) { - if (!diskMain || normalizedDisk === null) return c.text("not found", 404); - bundled = normalizedDisk; + if (!diskMain) return c.text("not found", 404); + bundled = normalizedDisk ?? diskMain.html; mainCompositionPath = diskMain.compositionPath; } @@ -238,6 +240,11 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi bundled = bundled.replace(//i, ``); } + // ensureHfIds runs after transformPreviewHtml in case the adapter injected + // new elements. On the no-bundle path bundled=normalizedDisk (already tagged) + // so this is idempotent. On the bundled path the bundler may return untagged + // HTML (stale cache); because ids are content-keyed the minted ids will match + // the ids already written to disk by persistHfIdsIfNeeded above. bundled = injectStudioPreviewAugmentations( ensureHfIds(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)), adapter, @@ -246,20 +253,20 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi ); return c.html(bundled, 200, previewCacheHeaders(etag)); } catch { - if (diskMain && normalizedDisk !== null) { + // Re-read disk on bundle failure so we serve the latest file content, + // not the pre-request snapshot that may have been saved over. + const fallback = resolveProjectMainHtml(project.dir, project.id); + if (fallback) { + const fallbackHtml = persistHfIdsIfNeeded( + join(project.dir, fallback.compositionPath), + fallback.html, + ); return c.html( injectStudioPreviewAugmentations( - ensureHfIds( - await transformPreviewHtml( - normalizedDisk, - adapter, - project, - diskMain.compositionPath, - ), - ), + await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath), adapter, project.dir, - diskMain.compositionPath, + fallback.compositionPath, ), 200, previewCacheHeaders(etag), @@ -305,6 +312,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi }); // Static asset serving (with range request support for audio/video seeking) + // fallow-ignore-next-line complexity api.get("/projects/:id/preview/*", async (c) => { const project = await adapter.resolveProject(c.req.param("id")); if (!project) return c.json({ error: "not found" }, 404); diff --git a/packages/studio/src/components/editor/manualEditsTypes.ts b/packages/studio/src/components/editor/manualEditsTypes.ts index e21ddacbf..f54071182 100644 --- a/packages/studio/src/components/editor/manualEditsTypes.ts +++ b/packages/studio/src/components/editor/manualEditsTypes.ts @@ -1,13 +1,15 @@ /* ── Public constants ──────────────────────────────────────────────── */ -export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x"; -export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y"; -export const STUDIO_WIDTH_PROP = "--hf-studio-width"; -export const STUDIO_HEIGHT_PROP = "--hf-studio-height"; +export { + STUDIO_OFFSET_X_PROP, + STUDIO_OFFSET_Y_PROP, + STUDIO_WIDTH_PROP, + STUDIO_HEIGHT_PROP, + STUDIO_MANUAL_EDIT_GESTURE_ATTR, +} from "@hyperframes/core/studio-api/draft-markers"; export const STUDIO_ROTATION_PROP = "--hf-studio-rotation"; /* ── Internal DOM attribute names ─────────────────────────────────── */ export const STUDIO_PATH_OFFSET_ATTR = "data-hf-studio-path-offset"; -export const STUDIO_MANUAL_EDIT_GESTURE_ATTR = "data-hf-studio-manual-edit-gesture"; export const STUDIO_BOX_SIZE_ATTR = "data-hf-studio-box-size"; export const STUDIO_ROTATION_ATTR = "data-hf-studio-rotation"; export const STUDIO_ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";