From efea6497fc9f9710daac70423cafe6a113f5a85b Mon Sep 17 00:00:00 2001 From: Jefsky Agent Date: Wed, 13 May 2026 19:33:42 +0800 Subject: [PATCH 1/3] fix(studio): keep layer selection seek within clip range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only seek when the current playback time is outside the selected clip's range. When the user clicks a layer whose clip already contains the current time, the seek is skipped — avoiding unwanted preview jumps during playback. Fixes heygen-com/hyperframes#792 --- .../src/components/editor/LayersPanel.tsx | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 packages/studio/src/components/editor/LayersPanel.tsx diff --git a/packages/studio/src/components/editor/LayersPanel.tsx b/packages/studio/src/components/editor/LayersPanel.tsx new file mode 100644 index 000000000..662458d71 --- /dev/null +++ b/packages/studio/src/components/editor/LayersPanel.tsx @@ -0,0 +1,295 @@ +import { memo, useState, useCallback, useEffect, useRef } from "react"; +import { + collectDomEditLayerItems, + getDomEditLayerKey, + resolveDomEditSelection, + type DomEditLayerItem, +} from "./domEditing"; +import { useStudioContext } from "../../contexts/StudioContext"; +import { useDomEditContext } from "../../contexts/DomEditContext"; +import { usePlayerStore } from "../../player"; +import { findMatchingTimelineElementId } from "../../utils/studioHelpers"; +import { Layers } from "../../icons/SystemIcons"; + +const TAG_ICONS: Record = { + video: "Vi", + audio: "Au", + img: "Im", + svg: "Sv", + canvas: "Cn", + div: "Di", + section: "Se", + span: "Sp", + p: "P", + h1: "H1", + h2: "H2", + h3: "H3", + h4: "H4", + h5: "H5", + h6: "H6", + a: "A", + button: "Bt", + ul: "Ul", + ol: "Ol", + li: "Li", + style: "St", + template: "Te", +}; + +function getTagBadge(tagName: string): string { + return TAG_ICONS[tagName] ?? tagName.slice(0, 2).toUpperCase(); +} + +function isCompositionHost(el: HTMLElement): boolean { + return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file"); +} + +interface CollapsedState { + [key: string]: boolean; +} + +export const LayersPanel = memo(function LayersPanel() { + const { previewIframeRef, activeCompPath, refreshKey, compositionLoading, timelineElements } = + useStudioContext(); + const { domEditSelection, applyDomSelection, updateDomEditHoverSelection } = useDomEditContext(); + + const [layers, setLayers] = useState([]); + const [collapsed, setCollapsed] = useState({}); + const prevDocVersionRef = useRef(0); + + const isMasterView = !activeCompPath || activeCompPath === "index.html"; + + const collectLayers = useCallback(() => { + const iframe = previewIframeRef.current; + if (!iframe) return; + let doc: Document | null = null; + try { + doc = iframe.contentDocument; + } catch { + return; + } + if (!doc) return; + + const root = + doc.querySelector("[data-composition-id]") ?? doc.documentElement ?? null; + if (!root) return; + + const items = collectDomEditLayerItems(root, { + activeCompositionPath: activeCompPath, + isMasterView, + }); + setLayers(items); + }, [previewIframeRef, activeCompPath, isMasterView]); + + useEffect(() => { + collectLayers(); + }, [collectLayers, refreshKey]); + + useEffect(() => { + const iframe = previewIframeRef.current; + if (!iframe) return; + const handleLoad = () => { + prevDocVersionRef.current += 1; + collectLayers(); + }; + iframe.addEventListener("load", handleLoad); + return () => iframe.removeEventListener("load", handleLoad); + }, [previewIframeRef, collectLayers]); + + useEffect(() => { + if (!compositionLoading) { + const timer = setTimeout(collectLayers, 100); + return () => clearTimeout(timer); + } + }, [compositionLoading, collectLayers]); + + const resolveSelection = useCallback( + (layer: DomEditLayerItem) => + resolveDomEditSelection(layer.element, { + activeCompositionPath: activeCompPath, + isMasterView, + preferClipAncestor: false, + }), + [activeCompPath, isMasterView], + ); + + const seekToLayer = useCallback( + (layer: DomEditLayerItem) => { + const selection = resolveSelection(layer); + if (!selection) return; + + let matchedId = findMatchingTimelineElementId(selection, timelineElements); + + // No direct match — walk up DOM ancestors to find the nearest element + // that has a timeline entry (e.g. a child of scene1 seeks to scene1.start) + if (!matchedId) { + const sourceFile = selection.sourceFile ?? "index.html"; + let ancestor = layer.element.parentElement; + while (ancestor && !matchedId) { + const elId = ancestor.id; + if (elId) { + const found = timelineElements.find( + (e) => e.domId === elId && (e.sourceFile ?? "index.html") === sourceFile, + ); + if (found) matchedId = found.key ?? found.id; + } + ancestor = ancestor.parentElement; + } + } + + if (matchedId) { + const el = timelineElements.find((e) => (e.key ?? e.id) === matchedId); + if (el) { + const { currentTime } = usePlayerStore.getState(); + const clipStart = el.start; + const clipEnd = el.start + el.duration; + // Only seek if current time is outside the clip range + if (currentTime < clipStart || currentTime > clipEnd) { + usePlayerStore.getState().requestSeek(clipStart + el.duration / 2); + } + } + } + }, + [resolveSelection, timelineElements], + ); + + const handleSelectLayer = useCallback( + (layer: DomEditLayerItem) => { + const selection = resolveSelection(layer); + if (!selection) return; + applyDomSelection(selection); + seekToLayer(layer); + }, + [resolveSelection, applyDomSelection, seekToLayer], + ); + + const handleLayerHover = useCallback( + (layer: DomEditLayerItem | null) => { + if (!layer) { + updateDomEditHoverSelection(null); + return; + } + const selection = resolveSelection(layer); + updateDomEditHoverSelection(selection); + }, + [resolveSelection, updateDomEditHoverSelection], + ); + + const toggleCollapse = useCallback((key: string, e: React.MouseEvent) => { + e.stopPropagation(); + setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })); + }, []); + + const selectedKey = domEditSelection ? getDomEditLayerKey(domEditSelection) : null; + + const visibleLayers = getVisibleLayers(layers, collapsed); + + if (layers.length === 0) { + return ( +
+ +

No layers

+

Load a composition to see its element tree

+
+ ); + } + + return ( +
handleLayerHover(null)} + > +
+ {layers.length} layer{layers.length === 1 ? "" : "s"} +
+
+ {visibleLayers.map((layer) => { + const selected = layer.key === selectedKey; + const isCollapsed = collapsed[layer.key] ?? false; + const hasChildren = layer.childCount > 0; + const isCompHost = isCompositionHost(layer.element); + + return ( +
handleSelectLayer(layer)} + onPointerEnter={() => handleLayerHover(layer)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelectLayer(layer); + } + }} + className={`group flex w-full cursor-pointer items-center gap-1.5 px-2 py-1 text-left transition-colors ${ + selected + ? "bg-studio-accent/14 text-studio-accent" + : "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100" + }`} + style={{ paddingLeft: 8 + layer.depth * 16 }} + > + {hasChildren ? ( + + ) : ( + + )} + + {getTagBadge(layer.tagName)} + + {layer.label} + {hasChildren && ( + {layer.childCount} + )} +
+ ); + })} +
+
+ ); +}); + +function getVisibleLayers( + layers: DomEditLayerItem[], + collapsed: CollapsedState, +): DomEditLayerItem[] { + if (Object.keys(collapsed).length === 0) return layers; + + const result: DomEditLayerItem[] = []; + let skipDepth = -1; + + for (const layer of layers) { + if (skipDepth >= 0 && layer.depth > skipDepth) continue; + skipDepth = -1; + + result.push(layer); + + if (collapsed[layer.key] && layer.childCount > 0) { + skipDepth = layer.depth; + } + } + + return result; +} From 7a68285c506fa0db87bf1b484dac57a46c452a92 Mon Sep 17 00:00:00 2001 From: Jefsky Agent Date: Wed, 13 May 2026 20:03:40 +0800 Subject: [PATCH 2/3] fix(studio): use JSON.stringify for caption text escaping Replace manual backslash/single-quote escaping with JSON.stringify() in caption JS generation. The manual approach missed newlines, carriage returns, and other special characters that can break the generated JavaScript syntax. JSON.stringify handles all special characters and produces a double-quoted string literal that is always valid JS. Fixes heygen-com/hyperframes#625 --- packages/studio/src/captions/generator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/captions/generator.ts b/packages/studio/src/captions/generator.ts index a0a2a45b6..44b8ed075 100644 --- a/packages/studio/src/captions/generator.ts +++ b/packages/studio/src/captions/generator.ts @@ -303,14 +303,14 @@ function generateJs(model: CaptionModel): string { // Build word spans const wordLines: string[] = groupSegments.map((seg) => { - const escaped = seg.text.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + const escaped = JSON.stringify(seg.text); const segVar = `w_${seg.id.replace(/[^a-zA-Z0-9_]/g, "_")}`; const idLine = seg.wordId ? `\n ${segVar}.id = ${JSON.stringify(seg.wordId)};` : ""; return ( ` const ${segVar} = document.createElement('span');` + `\n ${segVar}.className = 'word clip';` + idLine + - `\n ${segVar}.textContent = '${escaped}';` + + `\n ${segVar}.textContent = ${escaped};` + `\n ${segVar}.dataset.start = '${seg.start}';` + `\n ${segVar}.dataset.end = '${seg.end}';` + `\n groupEl_${groupVar}.appendChild(${segVar});` From d93fa954e7706b490b57bcda59a9fa59866efa78 Mon Sep 17 00:00:00 2001 From: Jefsky Agent Date: Wed, 13 May 2026 22:50:02 +0800 Subject: [PATCH 3/3] fix(studio): delay ObjectURL revocation to prevent failed downloads setTimeout(..., 0) fires before the browser has started reading from the blob, causing empty/failed frame captures. Increase the delay to 1000ms to give the browser time to initiate the download before the blob URL is revoked. Also adds ignoreDeprecations: "5.0" to cli and studio tsconfigs to silence TypeScript 7.0 baseUrl deprecation warnings, and rootDir: ".." to cli/tsconfig.json to fix the "common source directory" warning from paths-based module resolution. Fixes heygen-com/hyperframes#622 Fixes heygen-com/hyperframes#472 --- packages/cli/tsconfig.json | 19 +++++++++---- packages/studio/src/hooks/useFrameCapture.ts | 2 +- packages/studio/tsconfig.json | 29 +++++++++++++++----- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 855ecc53f..2f873e69e 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -5,15 +5,24 @@ "moduleResolution": "bundler", "baseUrl": ".", "paths": { - "@hyperframes/producer": ["../producer/src/index.ts"] + "@hyperframes/producer": [ + "../producer/src/index.ts" + ] }, "strict": true, "noUncheckedIndexedAccess": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "./dist", - "declaration": true + "declaration": true, + "ignoreDeprecations": "5.0", + "rootDir": ".." }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/studio/src/hooks/useFrameCapture.ts b/packages/studio/src/hooks/useFrameCapture.ts index e777206bb..f4ab27d1f 100644 --- a/packages/studio/src/hooks/useFrameCapture.ts +++ b/packages/studio/src/hooks/useFrameCapture.ts @@ -51,7 +51,7 @@ export function useFrameCapture({ document.body.appendChild(link); link.click(); link.remove(); - setTimeout(() => URL.revokeObjectURL(blobUrl), 0); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); } catch (err) { showToast(err instanceof Error ? err.message : "Capture failed"); } diff --git a/packages/studio/tsconfig.json b/packages/studio/tsconfig.json index 2d6dc608f..f720d6c76 100644 --- a/packages/studio/tsconfig.json +++ b/packages/studio/tsconfig.json @@ -5,7 +5,9 @@ "moduleResolution": "bundler", "baseUrl": ".", "paths": { - "@hyperframes/player": ["../player/src/hyperframes-player.ts"] + "@hyperframes/player": [ + "../player/src/hyperframes-player.ts" + ] }, "jsx": "react-jsx", "strict": true, @@ -17,14 +19,27 @@ "sourceMap": true, "outDir": "dist", "rootDir": "..", - "types": ["vite/client"], - "lib": ["dom", "dom.iterable", "esnext"], + "types": [ + "vite/client" + ], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "noEmit": true, "incremental": true, "resolveJsonModule": true, - "isolatedModules": true + "isolatedModules": true, + "ignoreDeprecations": "5.0" }, - "include": ["src"], - "exclude": ["dist", "node_modules", "src/**/*.test.ts"] -} + "include": [ + "src" + ], + "exclude": [ + "dist", + "node_modules", + "src/**/*.test.ts" + ] +} \ No newline at end of file