mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
initial code (#2)
* feat: initial code port from hyperframes-internal Port all OSS-ready packages from the internal monorepo: - @hyperframes/core — shared types, HTML generation, GSAP utilities, runtime - @hyperframes/cli — CLI for creating, previewing, and rendering compositions - @hyperframes/engine — framework-agnostic rendering engine (BeginFrame + FFmpeg) - @hyperframes/producer — video rendering pipeline (Puppeteer + FFmpeg) - @hyperframes/ui-player — browser-based video player component - @hyperframes/studio — composition editor (React frontend + Hono backend) Includes regression test suite with Docker-based test harness. All HeyGen-internal references, deployment infrastructure, and proprietary assets have been removed. Package names migrated from @app/* to @hyperframes/*. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: scrub internal codenames and stale references from OSS port - Replace static.heygen.ai runtime URLs in test fixtures - Remove internal CDN publish script (publish-hyperframe-runtime.ts) - Replace sandbox-studio, sandbox-interceptor, __magicEditRuntime with neutral names (studio, hyperframe-runtime, __hyperframeRuntime) - Fix stale Vault API / localhost references in docs - Remove broken deprecated_studio link Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove remaining internal codenames and stale references - Delete stale producer README.md and PIPELINE.md (referenced nonexistent files) - Replace "Cerberus" codename with "HyperFrames" in test design reviews - Replace magic-edit postMessage identifiers with hf-preview/hf-parent - Rename debug-magic-edit-timeline.ts to debug-timeline.ts - Replace "Motion Cut" with "HyperFrames" in Timeline comments - Fix studio/CLI references to nonexistent archive package (use local data/projects/ dir, stub render proxy) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
10621e7903
commit
9f8e5ba5a1
@@ -0,0 +1,338 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useMountEffect } from "./useMountEffect";
|
||||
import { resolveSourceFile, applyPatch } from "../utils/sourcePatcher";
|
||||
|
||||
export interface PickedElement {
|
||||
id: string | null;
|
||||
tagName: string;
|
||||
selector: string;
|
||||
label: string;
|
||||
boundingBox: { x: number; y: number; width: number; height: number };
|
||||
textContent: string | null;
|
||||
src: string | null;
|
||||
dataAttributes: Record<string, string>;
|
||||
computedStyles: Record<string, string>;
|
||||
}
|
||||
|
||||
interface UseElementPickerReturn {
|
||||
isPickMode: boolean;
|
||||
pickedElement: PickedElement | null;
|
||||
enablePick: () => void;
|
||||
disablePick: () => void;
|
||||
clearPick: () => void;
|
||||
/** Update a CSS property on the picked element live + persist to source */
|
||||
setStyle: (prop: string, value: string) => void;
|
||||
/** Update a data attribute on the picked element + persist to source */
|
||||
setDataAttr: (attr: string, value: string) => void;
|
||||
/** Update the text content of the picked element + persist to source */
|
||||
setTextContent: (text: string) => void;
|
||||
/** Override the active iframe (for zoomed canvas view). Pass null to restore primary. */
|
||||
setActiveIframe: (el: HTMLIFrameElement | null) => void;
|
||||
/** Ref that always points to the active iframe (focused canvas frame or preview panel) */
|
||||
activeIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
}
|
||||
|
||||
interface PickerOptions {
|
||||
/** Workspace files for source patching */
|
||||
workspaceFiles?: Record<string, string>;
|
||||
/** Callback to sync patched files to the project */
|
||||
onSyncFiles?: (files: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for element picking via the HyperFrame runtime's picker API.
|
||||
* Communicates with the iframe via postMessage.
|
||||
*/
|
||||
export function useElementPicker(
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>,
|
||||
options?: PickerOptions,
|
||||
): UseElementPickerReturn {
|
||||
const [isPickMode, setIsPickMode] = useState(false);
|
||||
const [pickedElement, setPickedElement] = useState<PickedElement | null>(null);
|
||||
|
||||
// Secondary/override iframe ref — set when a zoomed frame is active.
|
||||
// When set, all postMessage sends and DOM reads go to this ref instead.
|
||||
const activeOverrideRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
const getActiveIframe = useCallback((): HTMLIFrameElement | null => {
|
||||
return activeOverrideRef.current ?? iframeRef.current;
|
||||
}, [iframeRef]);
|
||||
|
||||
// Exposed so the host page can wire the focused view's iframe into the picker
|
||||
const setActiveIframe = useCallback((el: HTMLIFrameElement | null) => {
|
||||
activeOverrideRef.current = el;
|
||||
}, []);
|
||||
|
||||
const enablePick = useCallback(() => {
|
||||
try {
|
||||
getActiveIframe()?.contentWindow?.postMessage(
|
||||
{ source: "hf-parent", type: "control", action: "enable-pick-mode" },
|
||||
"*",
|
||||
);
|
||||
setIsPickMode(true);
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
}, [getActiveIframe]);
|
||||
|
||||
const disablePick = useCallback(() => {
|
||||
try {
|
||||
getActiveIframe()?.contentWindow?.postMessage(
|
||||
{ source: "hf-parent", type: "control", action: "disable-pick-mode" },
|
||||
"*",
|
||||
);
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
setIsPickMode(false);
|
||||
}, [getActiveIframe]);
|
||||
|
||||
const clearPick = useCallback(() => {
|
||||
setPickedElement(null);
|
||||
}, []);
|
||||
|
||||
// Listen for picker messages from the iframe
|
||||
useMountEffect(() => {
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
if (data?.source !== "hf-preview") return;
|
||||
// Accept events from either the primary iframe or the active override
|
||||
const activeIframe = getActiveIframe();
|
||||
if (!activeIframe) return;
|
||||
if (e.source !== activeIframe.contentWindow && e.source !== iframeRef.current?.contentWindow) return;
|
||||
|
||||
if (data.type === "element-picked") {
|
||||
const el = data.elementInfo;
|
||||
if (el) {
|
||||
const styles = readComputedStyles(activeIframe, el.selector);
|
||||
setPickedElement({
|
||||
id: el.id ?? null,
|
||||
tagName: el.tagName ?? "div",
|
||||
selector: el.selector ?? "",
|
||||
label: el.label ?? el.tagName ?? "Element",
|
||||
boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
|
||||
textContent: el.textContent ?? null,
|
||||
src: el.src ?? null,
|
||||
dataAttributes: el.dataAttributes ?? {},
|
||||
computedStyles: styles,
|
||||
});
|
||||
setIsPickMode(false);
|
||||
}
|
||||
} else if (data.type === "element-pick-candidates") {
|
||||
// Multiple candidates at click point — pick the first one
|
||||
const el = data.candidates?.[data.selectedIndex ?? 0];
|
||||
if (el) {
|
||||
const styles = readComputedStyles(activeIframe, el.selector);
|
||||
setPickedElement({
|
||||
id: el.id ?? null,
|
||||
tagName: el.tagName ?? "div",
|
||||
selector: el.selector ?? "",
|
||||
label: el.label ?? el.tagName ?? "Element",
|
||||
boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
|
||||
textContent: el.textContent ?? null,
|
||||
src: el.src ?? null,
|
||||
dataAttributes: el.dataAttributes ?? {},
|
||||
computedStyles: styles,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === "pick-mode-cancelled") {
|
||||
setIsPickMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
});
|
||||
|
||||
// Ref for options to avoid stale closures in debounced callback
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
// Sync immediately (not debounced) — save on every change for reliability
|
||||
const syncToSource = useCallback(
|
||||
(
|
||||
elementId: string,
|
||||
selector: string,
|
||||
op: { type: "inline-style" | "attribute" | "text-content"; property: string; value: string },
|
||||
) => {
|
||||
const opts = optionsRef.current;
|
||||
if (!opts?.workspaceFiles || !opts.onSyncFiles || !elementId) return;
|
||||
const files = opts.workspaceFiles;
|
||||
const sourceFile = resolveSourceFile(elementId, selector, files);
|
||||
if (!sourceFile || !files[sourceFile]) {
|
||||
return;
|
||||
}
|
||||
const patched = applyPatch(files[sourceFile], elementId, op);
|
||||
if (patched !== files[sourceFile]) {
|
||||
opts.onSyncFiles({ [sourceFile]: patched });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setStyle = useCallback(
|
||||
(prop: string, value: string) => {
|
||||
const activeIframe = getActiveIframe();
|
||||
if (!pickedElement?.selector || !activeIframe) return;
|
||||
try {
|
||||
const doc = activeIframe.contentDocument;
|
||||
const el = doc?.querySelector(pickedElement.selector) as HTMLElement | null;
|
||||
if (el) {
|
||||
el.style.setProperty(prop, value);
|
||||
setPickedElement((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
computedStyles: { ...prev.computedStyles, [prop]: value },
|
||||
}
|
||||
: null,
|
||||
);
|
||||
// Persist to source file
|
||||
if (pickedElement.id) {
|
||||
// ID-based patching — surgical edit of just the element's style
|
||||
syncToSource(pickedElement.id, pickedElement.selector, { type: "inline-style", property: prop, value });
|
||||
} else {
|
||||
// No ID — save the full composition HTML from the iframe
|
||||
// This captures ALL inline style changes, not just the targeted one
|
||||
try {
|
||||
const fullHtml = activeIframe.contentDocument?.documentElement.outerHTML;
|
||||
if (fullHtml && optionsRef.current?.onSyncFiles) {
|
||||
// Determine which file this iframe represents
|
||||
const src = activeIframe.getAttribute("src") ?? "";
|
||||
const compMatch = src.match(/\/comp\/(.+?)(?:\?|$)/);
|
||||
const filePath = compMatch ? compMatch[1] : "index.html";
|
||||
optionsRef.current.onSyncFiles({ [filePath]: `<!DOCTYPE html>\n<html>${fullHtml.replace(/<html[^>]*>/, "")}` });
|
||||
}
|
||||
} catch { /* cross-origin */ }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
},
|
||||
[pickedElement, getActiveIframe, syncToSource],
|
||||
);
|
||||
|
||||
const setDataAttr = useCallback(
|
||||
(attr: string, value: string) => {
|
||||
const activeIframe = getActiveIframe();
|
||||
if (!pickedElement?.selector || !activeIframe) return;
|
||||
try {
|
||||
const doc = activeIframe.contentDocument;
|
||||
const el = doc?.querySelector(pickedElement.selector);
|
||||
if (el) {
|
||||
el.setAttribute(`data-${attr}`, value);
|
||||
setPickedElement((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
dataAttributes: { ...prev.dataAttributes, [attr]: value },
|
||||
}
|
||||
: null,
|
||||
);
|
||||
// Persist to source file immediately
|
||||
if (pickedElement.id) {
|
||||
syncToSource(pickedElement.id, pickedElement.selector, { type: "attribute", property: attr, value });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
},
|
||||
[pickedElement, getActiveIframe, syncToSource],
|
||||
);
|
||||
|
||||
const setTextContent = useCallback(
|
||||
(text: string) => {
|
||||
const activeIframe = getActiveIframe();
|
||||
if (!pickedElement?.selector || !activeIframe) return;
|
||||
try {
|
||||
const doc = activeIframe.contentDocument;
|
||||
const el = doc?.querySelector(pickedElement.selector);
|
||||
if (el) {
|
||||
el.textContent = text;
|
||||
setPickedElement((prev) => (prev ? { ...prev, textContent: text } : null));
|
||||
// Persist to source file
|
||||
if (pickedElement.id) {
|
||||
syncToSource(pickedElement.id, pickedElement.selector, {
|
||||
type: "text-content",
|
||||
property: "textContent",
|
||||
value: text,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
},
|
||||
[pickedElement, getActiveIframe, syncToSource],
|
||||
);
|
||||
|
||||
// Ref-like object that always points to the active iframe (override or primary)
|
||||
const activeIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
activeIframeRef.current = getActiveIframe();
|
||||
|
||||
return {
|
||||
isPickMode,
|
||||
pickedElement,
|
||||
enablePick,
|
||||
disablePick,
|
||||
clearPick,
|
||||
setStyle,
|
||||
setDataAttr,
|
||||
setTextContent,
|
||||
setActiveIframe,
|
||||
/** Ref that always points to the active iframe (focused canvas frame or preview panel) */
|
||||
activeIframeRef,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read a subset of computed styles from an element in the iframe */
|
||||
function readComputedStyles(iframe: HTMLIFrameElement, selector: string): Record<string, string> {
|
||||
const styles: Record<string, string> = {};
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
const el = doc?.querySelector(selector);
|
||||
if (!el) return styles;
|
||||
const computed = iframe.contentWindow?.getComputedStyle(el);
|
||||
if (!computed) return styles;
|
||||
|
||||
const props = [
|
||||
"position",
|
||||
"top",
|
||||
"left",
|
||||
"right",
|
||||
"bottom",
|
||||
"width",
|
||||
"height",
|
||||
"margin-top",
|
||||
"margin-right",
|
||||
"margin-bottom",
|
||||
"margin-left",
|
||||
"padding-top",
|
||||
"padding-right",
|
||||
"padding-bottom",
|
||||
"padding-left",
|
||||
"font-size",
|
||||
"font-weight",
|
||||
"font-family",
|
||||
"color",
|
||||
"background-color",
|
||||
"background",
|
||||
"opacity",
|
||||
"border-radius",
|
||||
"transform",
|
||||
"z-index",
|
||||
];
|
||||
|
||||
for (const prop of props) {
|
||||
const val = computed.getPropertyValue(prop);
|
||||
if (val) styles[prop] = val;
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
Reference in New Issue
Block a user