From 6d3bce661544e159ff1be8747d6a2c33fd573d28 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miguel=20=C3=81ngel?=
Date: Wed, 25 Mar 2026 23:52:49 -0400
Subject: [PATCH 1/2] feat(studio): update to v0.1.3 with v2 design and
timeline improvements
- v2 teal color scheme (buttons, playhead, progress bar)
- CompositionThumbnail: server-rendered JPEG film strips for timeline clips
- TimelineToolbar: Split, Delete, and Zoom controls
- TimelineClip: draggable clips with resize handles
- VideoThumbnail: client-side video frame extraction
- Teal playhead line with glow, always visible above clips
- Progress thumb: always-visible teal dot
- Auto-scroll timeline to follow playhead during seek
- Vite plugin: thumbnail endpoint, render proxy, file watcher
- Standalone dev mode with full editor
Co-Authored-By: Claude Opus 4.6 (1M context)
---
bun.lock | 58 +-
packages/studio/.gitignore | 3 +
packages/studio/package.json | 12 +-
packages/studio/src/App.tsx | 160 +++-
.../src/components/editor/SourceEditor.tsx | 4 +-
.../studio/src/components/nle/NLELayout.tsx | 69 +-
.../components/timeline/TimelineToolbar.tsx | 168 +++++
packages/studio/src/hooks/useCodeEditor.ts | 2 +-
packages/studio/src/hooks/useElementPicker.ts | 6 +-
packages/studio/src/index.ts | 17 +-
.../player/components/AgentActivityTrack.tsx | 93 ---
.../components/CompositionThumbnail.tsx | 171 +++++
.../studio/src/player/components/Player.tsx | 9 +-
.../src/player/components/PlayerControls.tsx | 122 ++--
.../src/player/components/Timeline.test.ts | 149 ++++
.../studio/src/player/components/Timeline.tsx | 684 +++++++++++-------
.../src/player/components/TimelineClip.tsx | 277 +++++++
.../src/player/components/VideoThumbnail.tsx | 193 +++++
.../src/player/hooks/useTimelinePlayer.ts | 462 +++++++++---
packages/studio/src/player/index.ts | 7 +-
packages/studio/src/player/lib/time.test.ts | 59 ++
.../src/player/store/playerStore.test.ts | 265 +++++++
.../studio/src/player/store/playerStore.ts | 47 +-
packages/studio/src/utils/htmlEditor.ts | 475 ++++++++++++
packages/studio/src/utils/styleCapture.ts | 257 +++++++
packages/studio/tsconfig.json | 10 +-
packages/studio/vite.config.ts | 314 +++++++-
27 files changed, 3530 insertions(+), 563 deletions(-)
create mode 100644 packages/studio/.gitignore
create mode 100644 packages/studio/src/components/timeline/TimelineToolbar.tsx
delete mode 100644 packages/studio/src/player/components/AgentActivityTrack.tsx
create mode 100644 packages/studio/src/player/components/CompositionThumbnail.tsx
create mode 100644 packages/studio/src/player/components/Timeline.test.ts
create mode 100644 packages/studio/src/player/components/TimelineClip.tsx
create mode 100644 packages/studio/src/player/components/VideoThumbnail.tsx
create mode 100644 packages/studio/src/player/lib/time.test.ts
create mode 100644 packages/studio/src/player/store/playerStore.test.ts
create mode 100644 packages/studio/src/utils/htmlEditor.ts
create mode 100644 packages/studio/src/utils/styleCapture.ts
diff --git a/bun.lock b/bun.lock
index 22d85f2c8..cd65a3634 100644
--- a/bun.lock
+++ b/bun.lock
@@ -18,7 +18,7 @@
},
},
"packages/cli": {
- "name": "@hyperframes/cli",
+ "name": "hyperframes",
"version": "0.1.2",
"bin": {
"hyperframes": "./dist/cli.js",
@@ -121,7 +121,7 @@
},
"packages/studio": {
"name": "@hyperframes/studio",
- "version": "0.1.2",
+ "version": "0.1.3",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
@@ -135,6 +135,7 @@
"@codemirror/view": "^6.40.0",
"@hyperframes/core": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
+ "@use-gesture/react": "^10.3.1",
"codemirror": "^6.0.1",
},
"devDependencies": {
@@ -143,9 +144,12 @@
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
+ "puppeteer-core": "^24.40.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0",
"vite": "^5.0.0",
+ "vitest": "^3.2.4",
+ "zustand": "^5.0.0",
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
@@ -367,8 +371,6 @@
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "4.12.8" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
- "@hyperframes/cli": ["@hyperframes/cli@workspace:packages/cli"],
-
"@hyperframes/core": ["@hyperframes/core@workspace:packages/core"],
"@hyperframes/engine": ["@hyperframes/engine@workspace:packages/engine"],
@@ -667,6 +669,10 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "22.19.15" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
+ "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="],
+
+ "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="],
+
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "7.29.0", "@babel/plugin-transform-react-jsx-self": "7.27.1", "@babel/plugin-transform-react-jsx-source": "7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "7.20.5", "react-refresh": "0.17.0" }, "peerDependencies": { "vite": "5.4.21" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@bcoe/v8-coverage": "1.0.2", "ast-v8-to-istanbul": "0.3.12", "debug": "4.4.3", "istanbul-lib-coverage": "3.2.2", "istanbul-lib-report": "3.0.1", "istanbul-lib-source-maps": "5.0.6", "istanbul-reports": "3.2.0", "magic-string": "0.30.21", "magicast": "0.3.5", "std-env": "3.10.0", "test-exclude": "7.0.2", "tinyrainbow": "2.0.0" }, "peerDependencies": { "vitest": "3.2.4" } }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="],
@@ -771,7 +777,7 @@
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "1.0.0", "css-select": "5.2.2", "css-what": "6.2.2", "domelementtype": "2.3.0", "domhandler": "5.0.3", "domutils": "3.2.2" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
- "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "4.1.2" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "3.0.1", "zod": "3.25.76" }, "peerDependencies": { "devtools-protocol": "0.0.1581282" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="],
@@ -869,7 +875,7 @@
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
- "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@@ -961,6 +967,8 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+ "hyperframes": ["hyperframes@workspace:packages/cli"],
+
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "1.0.1", "resolve-from": "4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -1213,7 +1221,7 @@
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
- "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
+ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "2.3.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
@@ -1361,8 +1369,6 @@
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "3.2.0", "picocolors": "1.1.1" }, "peerDependencies": { "browserslist": "4.28.1" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
- "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "18.3.1" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
-
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.8", "rollup": "4.59.0" }, "optionalDependencies": { "@types/node": "22.19.15", "fsevents": "2.3.3" }, "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
@@ -1383,7 +1389,7 @@
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
- "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
+ "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "1.15.0", "tr46": "6.0.0", "webidl-conversions": "8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
@@ -1419,7 +1425,7 @@
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
- "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "1.6.0" }, "optionalDependencies": { "@types/react": "19.2.14", "react": "18.3.1" } }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
+ "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@@ -1429,8 +1435,6 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
- "@hyperframes/cli/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
-
"@hyperframes/core/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@hyperframes/engine/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
@@ -1459,9 +1463,13 @@
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+ "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
+
+ "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
- "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
+ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -1471,6 +1479,8 @@
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+ "hyperframes/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
+
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -1479,26 +1489,24 @@
"jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "6.0.1" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
- "jsdom/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
-
"loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
- "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
-
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"postcss-load-config/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
- "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
+ "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
- "tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
+ "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
+ "tsup/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "4.1.2" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+
"tsup/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"tsup/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
@@ -1517,8 +1525,6 @@
"vitest/tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
- "@hyperframes/cli/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
-
"@hyperframes/core/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@hyperframes/engine/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@@ -1643,11 +1649,9 @@
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
- "jsdom/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+ "hyperframes/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
- "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
-
- "tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "2.3.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
+ "tsup/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"tsup/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
@@ -1804,7 +1808,5 @@
"vitest/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
-
- "tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
}
}
diff --git a/packages/studio/.gitignore b/packages/studio/.gitignore
new file mode 100644
index 000000000..9012af4f9
--- /dev/null
+++ b/packages/studio/.gitignore
@@ -0,0 +1,3 @@
+dist/
+node_modules/
+data/projects/
diff --git a/packages/studio/package.json b/packages/studio/package.json
index 7afd1152c..b4e94b2a8 100644
--- a/packages/studio/package.json
+++ b/packages/studio/package.json
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/studio",
- "version": "0.1.2",
+ "version": "0.1.3",
"files": [
"src",
"dist"
@@ -15,7 +15,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "typecheck": "tsc --noEmit"
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
@@ -30,6 +32,7 @@
"@codemirror/view": "^6.40.0",
"@hyperframes/core": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
+ "@use-gesture/react": "^10.3.1",
"codemirror": "^6.0.1"
},
"devDependencies": {
@@ -38,9 +41,12 @@
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
+ "puppeteer-core": "^24.40.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0",
- "vite": "^5.0.0"
+ "vite": "^5.0.0",
+ "vitest": "^3.2.4",
+ "zustand": "^5.0.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx
index e8f3022fa..9304aa862 100644
--- a/packages/studio/src/App.tsx
+++ b/packages/studio/src/App.tsx
@@ -1,7 +1,14 @@
-import { useState, useCallback, useRef, useEffect } from "react";
+import { useState, useCallback, useRef, useEffect, type ReactNode } from "react";
import { NLELayout } from "./components/nle/NLELayout";
import { SourceEditor } from "./components/editor/SourceEditor";
import { FileTree } from "./components/editor/FileTree";
+import { CompositionThumbnail } from "./player/components/CompositionThumbnail";
+import { TimelineToolbar } from "./components/timeline/TimelineToolbar";
+import { usePlayerStore } from "./player/store/playerStore";
+import { splitElement, deleteElement } from "./utils/htmlEditor";
+import { captureTreeStyles } from "./utils/styleCapture";
+import { VideoThumbnail } from "./player/components/VideoThumbnail";
+import type { TimelineElement } from "./player/store/playerStore";
import {
XIcon,
CodeIcon,
@@ -98,8 +105,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
) : (
-
-
+
+
)}
@@ -139,8 +146,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
{f.file &&
{f.file}
}
{f.fixHint && (
-
-
{f.fixHint}
+
+
{f.fixHint}
)}
@@ -156,8 +163,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
{f.file &&
{f.file}
}
{f.fixHint && (
-
-
{f.fixHint}
+
+
{f.fixHint}
)}
@@ -202,6 +209,67 @@ export function StudioApp() {
const [editingFile, setEditingFile] = useState(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [fileTree, setFileTree] = useState([]);
+ const [compIdToSrc, setCompIdToSrc] = useState
")) {
+ bundled = bundled.replace("", `${runtimeTag}\n`);
+ } else {
+ bundled += `\n${runtimeTag}`;
+ }
+ }
+
+ // Inject for relative asset resolution
+ const baseHref = `/api/projects/${projectId}/preview/`;
+ if (!bundled.includes("/i, `
`);
+ }
+
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
@@ -245,7 +444,9 @@ function devProjectApi(): Plugin {
);
// Build a standalone HTML page with GSAP + runtime
- const runtimeUrl = (process.env.HYPERFRAME_RUNTIME_URL || "").trim() || "";
+ const runtimeUrl =
+ (process.env.HYPERFRAME_RUNTIME_URL || "").trim() ||
+ "https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
const standalone = `
@@ -264,6 +465,105 @@ ${content}
return;
}
+ // GET /api/projects/:id/thumbnail/* — generate JPEG thumbnail via Puppeteer
+ if (req.method === "GET" && rest.startsWith("/thumbnail/")) {
+ const compPath = decodeURIComponent(rest.replace("/thumbnail/", "").split("?")[0]);
+ const url = new URL(req.url!, `http://${req.headers.host}`);
+ const seekTime = parseFloat(url.searchParams.get("t") || "0.5") || 0.5;
+ const vpWidth = parseInt(url.searchParams.get("w") || "0") || 0;
+ const vpHeight = parseInt(url.searchParams.get("h") || "0") || 0;
+
+ // Determine the preview URL for this composition
+ const previewUrl =
+ compPath === "index.html"
+ ? `http://${req.headers.host}/api/projects/${projectId}/preview`
+ : `http://${req.headers.host}/api/projects/${projectId}/preview/comp/${compPath}`;
+
+ // Cache path
+ const cacheDir = join(projectDir, ".thumbnails");
+ const cacheKey = `${compPath.replace(/\//g, "_")}_${seekTime.toFixed(2)}.jpg`;
+ const cachePath = join(cacheDir, cacheKey);
+
+ // Return cached thumbnail if available
+ if (existsSync(cachePath)) {
+ res.writeHead(200, {
+ "Content-Type": "image/jpeg",
+ "Cache-Control": "public, max-age=60",
+ });
+ res.end(readFileSync(cachePath));
+ return;
+ }
+
+ try {
+ const browser = await getSharedBrowser();
+ if (!browser) {
+ res.writeHead(501, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Chrome not found for thumbnails" }));
+ return;
+ }
+ // Detect composition dimensions from the HTML file
+ let compW = vpWidth || 1920;
+ let compH = vpHeight || 1080;
+ if (!vpWidth) {
+ const htmlFile = join(projectDir, compPath);
+ if (existsSync(htmlFile)) {
+ const html = readFileSync(htmlFile, "utf-8");
+ const wMatch = html.match(/data-width=["'](\d+)["']/);
+ const hMatch = html.match(/data-height=["'](\d+)["']/);
+ if (wMatch) compW = parseInt(wMatch[1]);
+ if (hMatch) compH = parseInt(hMatch[1]);
+ }
+ }
+
+ const page = await browser.newPage();
+ await page.setViewport({ width: compW, height: compH, deviceScaleFactor: 0.5 });
+ await page.goto(previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
+
+ // Wait for GSAP + seek
+ await page
+ .waitForFunction(
+ `!!(window.__timelines && Object.keys(window.__timelines).length > 0)`,
+ { timeout: 5000 },
+ )
+ .catch(() => {});
+ await page.evaluate((t: number) => {
+ const w = window as Window & {
+ __timelines?: Record void; pause: () => void }>;
+ };
+ if (w.__timelines) {
+ const tl = Object.values(w.__timelines)[0];
+ if (tl) {
+ tl.seek(t);
+ tl.pause();
+ }
+ }
+ }, seekTime);
+ await page.evaluate("document.fonts?.ready");
+ await new Promise((r) => setTimeout(r, 100));
+
+ const buffer = await page.screenshot({ type: "jpeg", quality: 75 });
+ await page.close();
+
+ // Cache
+ if (!existsSync(cacheDir)) {
+ const { mkdirSync } = await import("fs");
+ mkdirSync(cacheDir, { recursive: true });
+ }
+ writeFileSync(cachePath, buffer);
+
+ res.writeHead(200, {
+ "Content-Type": "image/jpeg",
+ "Cache-Control": "public, max-age=60",
+ });
+ res.end(buffer);
+ } catch (err) {
+ console.warn("[Studio] Thumbnail generation failed:", err);
+ res.writeHead(500, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Thumbnail generation failed" }));
+ }
+ return;
+ }
+
// GET /api/projects/:id/preview/* — serve static assets (images, audio, etc.)
if (req.method === "GET" && rest.startsWith("/preview/")) {
const subPath = decodeURIComponent(rest.replace("/preview/", "").split("?")[0]);
From a4c5fd16e976f8a9d0f59779c92940f472b9f69e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miguel=20=C3=81ngel?=
Date: Thu, 26 Mar 2026 13:59:35 -0400
Subject: [PATCH 2/2] feat(studio): replace split/delete with Edit Range + Copy
to Agent
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Remove the Split and Delete toolbar buttons which had many edge cases
(style capture, media offset, depth counting, DOMParser + regex hybrid).
Replace with an Edit button that:
1. Opens a modal with time range selection (start/end sliders)
2. Shows all elements that overlap the selected range
3. Provides a prompt textarea for describing the desired change
4. "Copy to Agent" button copies structured context to clipboard
The AI agent handles the actual HTML mutation — which is what it's
good at. This eliminates ~300 lines of fragile split/delete code.
Removed: splitElement, deleteElement, applyBakedStyles, styleCapture.ts
Kept: parseStyleString, mergeStyleIntoTag, findElementBlock (useful utilities)
---
bun.lock | 44 ++-
packages/studio/src/App.tsx | 76 +---
.../src/components/timeline/EditModal.tsx | 209 ++++++++++
.../components/timeline/TimelineToolbar.tsx | 61 +--
packages/studio/src/index.ts | 4 +-
.../studio/src/player/store/playerStore.ts | 11 +
packages/studio/src/utils/htmlEditor.ts | 364 ++----------------
packages/studio/src/utils/styleCapture.ts | 257 -------------
8 files changed, 286 insertions(+), 740 deletions(-)
create mode 100644 packages/studio/src/components/timeline/EditModal.tsx
delete mode 100644 packages/studio/src/utils/styleCapture.ts
diff --git a/bun.lock b/bun.lock
index cd65a3634..aae8f7dc3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -18,7 +18,7 @@
},
},
"packages/cli": {
- "name": "hyperframes",
+ "name": "@hyperframes/cli",
"version": "0.1.2",
"bin": {
"hyperframes": "./dist/cli.js",
@@ -371,6 +371,8 @@
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "4.12.8" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
+ "@hyperframes/cli": ["@hyperframes/cli@workspace:packages/cli"],
+
"@hyperframes/core": ["@hyperframes/core@workspace:packages/core"],
"@hyperframes/engine": ["@hyperframes/engine@workspace:packages/engine"],
@@ -777,7 +779,7 @@
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "1.0.0", "css-select": "5.2.2", "css-what": "6.2.2", "domelementtype": "2.3.0", "domhandler": "5.0.3", "domutils": "3.2.2" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
- "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
+ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "4.1.2" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "3.0.1", "zod": "3.25.76" }, "peerDependencies": { "devtools-protocol": "0.0.1581282" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="],
@@ -875,7 +877,7 @@
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
- "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+ "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@@ -967,8 +969,6 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
- "hyperframes": ["hyperframes@workspace:packages/cli"],
-
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "1.0.1", "resolve-from": "4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -1221,7 +1221,7 @@
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
- "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "2.3.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
+ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
@@ -1389,7 +1389,7 @@
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
- "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
+ "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "1.15.0", "tr46": "6.0.0", "webidl-conversions": "8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
@@ -1435,6 +1435,8 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "@hyperframes/cli/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
+
"@hyperframes/core/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@hyperframes/engine/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
@@ -1463,13 +1465,9 @@
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
- "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
-
- "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
-
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
- "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+ "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -1479,8 +1477,6 @@
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
- "hyperframes/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
-
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -1489,23 +1485,25 @@
"jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "6.0.1" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
+ "jsdom/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
+
"loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+ "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"postcss-load-config/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
- "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
-
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
- "tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
+ "tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
- "tsup/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "4.1.2" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+ "tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"tsup/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
@@ -1525,6 +1523,8 @@
"vitest/tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
+ "@hyperframes/cli/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
"@hyperframes/core/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@hyperframes/engine/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@@ -1649,9 +1649,11 @@
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
- "hyperframes/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+ "jsdom/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
+ "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
- "tsup/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
+ "tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "2.3.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"tsup/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
@@ -1808,5 +1810,7 @@
"vitest/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+
+ "tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
}
}
diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx
index 9304aa862..2d0c02974 100644
--- a/packages/studio/src/App.tsx
+++ b/packages/studio/src/App.tsx
@@ -5,8 +5,7 @@ import { FileTree } from "./components/editor/FileTree";
import { CompositionThumbnail } from "./player/components/CompositionThumbnail";
import { TimelineToolbar } from "./components/timeline/TimelineToolbar";
import { usePlayerStore } from "./player/store/playerStore";
-import { splitElement, deleteElement } from "./utils/htmlEditor";
-import { captureTreeStyles } from "./utils/styleCapture";
+import { EditModal } from "./components/timeline/EditModal";
import { VideoThumbnail } from "./player/components/VideoThumbnail";
import type { TimelineElement } from "./player/store/playerStore";
import {
@@ -271,6 +270,7 @@ export function StudioApp() {
[compIdToSrc],
);
const [lintModal, setLintModal] = useState(null);
+ const [editModalOpen, setEditModalOpen] = useState(false);
const [linting, setLinting] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [renderState, setRenderState] = useState<"idle" | "rendering" | "complete" | "error">(
@@ -532,74 +532,7 @@ export function StudioApp() {
onIframeRef={(iframe) => {
previewIframeRef.current = iframe;
}}
- timelineToolbar={
- {
- const store = usePlayerStore.getState();
- const selectedId = store.selectedElementId;
- const pid = projectIdRef.current;
- if (!selectedId || !pid) return;
- const el = store.elements.find((e) => e.id === selectedId);
- if (!el) return;
- const currentTime = store.currentTime;
- if (currentTime <= el.start || currentTime >= el.start + el.duration) return;
-
- try {
- const res = await fetch(`/api/projects/${pid}/files/index.html`);
- const data = await res.json();
- const html = data.content as string;
-
- // Capture computed styles from the live iframe at the current playhead position
- let capturedStyles = null;
- const iframe = previewIframeRef.current;
- if (iframe?.contentDocument) {
- capturedStyles = captureTreeStyles(
- iframe.contentDocument,
- selectedId,
- currentTime,
- );
- }
-
- const newHtml = splitElement(html, selectedId, currentTime, el, capturedStyles);
- if (newHtml === html) return; // no change
-
- await fetch(`/api/projects/${pid}/files/index.html`, {
- method: "PUT",
- headers: { "Content-Type": "text/plain" },
- body: newHtml,
- });
- setRefreshKey((k) => k + 1);
- } catch (err) {
- console.error("Split failed:", err);
- }
- }}
- onDelete={async () => {
- const store = usePlayerStore.getState();
- const selectedId = store.selectedElementId;
- const pid = projectIdRef.current;
- if (!selectedId || !pid) return;
-
- try {
- const res = await fetch(`/api/projects/${pid}/files/index.html`);
- const data = await res.json();
- const html = data.content as string;
-
- const newHtml = deleteElement(html, selectedId);
- if (newHtml === html) return; // no change
-
- await fetch(`/api/projects/${pid}/files/index.html`, {
- method: "PUT",
- headers: { "Content-Type": "text/plain" },
- body: newHtml,
- });
- store.setSelectedElementId(null);
- setRefreshKey((k) => k + 1);
- } catch (err) {
- console.error("Delete failed:", err);
- }
- }}
- />
- }
+ timelineToolbar={ setEditModalOpen(true)} />}
/>
@@ -694,6 +627,9 @@ export function StudioApp() {
{/* Lint modal */}
{lintModal !== null && setLintModal(null)} />}
+
+ {/* Edit modal */}
+ {editModalOpen && setEditModalOpen(false)} />}
);
}
diff --git a/packages/studio/src/components/timeline/EditModal.tsx b/packages/studio/src/components/timeline/EditModal.tsx
new file mode 100644
index 000000000..1823e9998
--- /dev/null
+++ b/packages/studio/src/components/timeline/EditModal.tsx
@@ -0,0 +1,209 @@
+import { useState, useCallback, useMemo } from "react";
+import { usePlayerStore } from "../../player/store/playerStore";
+
+function formatTime(seconds: number): string {
+ const m = Math.floor(seconds / 60);
+ const s = seconds % 60;
+ return m > 0 ? `${m}:${s.toFixed(1).padStart(4, "0")}` : `${s.toFixed(1)}s`;
+}
+
+interface EditModalProps {
+ onClose: () => void;
+}
+
+export function EditModal({ onClose }: EditModalProps) {
+ const elements = usePlayerStore((s) => s.elements);
+ const currentTime = usePlayerStore((s) => s.currentTime);
+ const duration = usePlayerStore((s) => s.duration);
+ const selectedId = usePlayerStore((s) => s.selectedElementId);
+
+ const [rangeStart, setRangeStart] = useState(() => {
+ // Default: if an element is selected, use its start. Otherwise use playhead.
+ const sel = elements.find((e) => e.id === selectedId);
+ return sel ? sel.start : Math.max(0, currentTime - 1);
+ });
+ const [rangeEnd, setRangeEnd] = useState(() => {
+ const sel = elements.find((e) => e.id === selectedId);
+ return sel ? sel.start + sel.duration : Math.min(duration, currentTime + 5);
+ });
+ const [prompt, setPrompt] = useState("");
+ const [copied, setCopied] = useState(false);
+
+ const elementsInRange = useMemo(() => {
+ const start = Math.min(rangeStart, rangeEnd);
+ const end = Math.max(rangeStart, rangeEnd);
+ return elements.filter((el) => {
+ const elEnd = el.start + el.duration;
+ return el.start < end && elEnd > start;
+ });
+ }, [elements, rangeStart, rangeEnd]);
+
+ const buildClipboardText = useCallback(() => {
+ const start = Math.min(rangeStart, rangeEnd);
+ const end = Math.max(rangeStart, rangeEnd);
+
+ const elementLines = elementsInRange
+ .map(
+ (el) =>
+ `- #${el.id} (${el.tag}) — ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
+ )
+ .join("\n");
+
+ return `Edit the following HyperFrames composition:
+
+Time range: ${formatTime(start)} — ${formatTime(end)}
+
+Elements in range:
+${elementLines}
+
+User request:
+${prompt.trim() || "(no prompt provided)"}
+
+Instructions:
+Modify only the elements listed above within the specified time range.
+The composition uses HyperFrames data attributes (data-start, data-duration, data-track-index) and GSAP for animations.
+Preserve all other elements and timing outside this range.`;
+ }, [rangeStart, rangeEnd, elementsInRange, prompt]);
+
+ const handleCopy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(buildClipboardText());
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // Fallback
+ const textarea = document.createElement("textarea");
+ textarea.value = buildClipboardText();
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand("copy");
+ document.body.removeChild(textarea);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ }, [buildClipboardText]);
+
+ const start = Math.min(rangeStart, rangeEnd);
+ const end = Math.max(rangeStart, rangeEnd);
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+
Edit Range
+
+ {formatTime(start)} — {formatTime(end)}
+
+
+
+
+
+ {/* Range sliders */}
+
+
+ {/* Elements in range */}
+
+
+ {elementsInRange.length} element{elementsInRange.length !== 1 ? "s" : ""} in range
+
+ {elementsInRange.length === 0 ? (
+
No elements in this range
+ ) : (
+
+ {elementsInRange.map((el) => (
+
+ #{el.id}
+ ({el.tag})
+
+ {formatTime(el.start)}–{formatTime(el.start + el.duration)}
+
+
+ ))}
+
+ )}
+
+
+ {/* Prompt */}
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+}
diff --git a/packages/studio/src/components/timeline/TimelineToolbar.tsx b/packages/studio/src/components/timeline/TimelineToolbar.tsx
index 668af612b..3380ea1fd 100644
--- a/packages/studio/src/components/timeline/TimelineToolbar.tsx
+++ b/packages/studio/src/components/timeline/TimelineToolbar.tsx
@@ -6,28 +6,13 @@ const MIN_PPS = 20;
const MAX_PPS = 800;
interface TimelineToolbarProps {
- onSplit?: () => void;
- onDelete?: () => void;
+ onEdit?: () => void;
}
-export const TimelineToolbar = memo(function TimelineToolbar({
- onSplit,
- onDelete,
-}: TimelineToolbarProps) {
- const selectedId = usePlayerStore((s) => s.selectedElementId);
+export const TimelineToolbar = memo(function TimelineToolbar({ onEdit }: TimelineToolbarProps) {
const zoomMode = usePlayerStore((s) => s.zoomMode);
const pixelsPerSecond = usePlayerStore((s) => s.pixelsPerSecond);
- const handleSplit = useCallback(() => {
- if (!selectedId || !onSplit) return;
- onSplit();
- }, [selectedId, onSplit]);
-
- const handleDelete = useCallback(() => {
- if (!selectedId || !onDelete) return;
- onDelete();
- }, [selectedId, onDelete]);
-
const handleZoomFit = useCallback(() => {
usePlayerStore.getState().setZoomMode("fit");
}, []);
@@ -44,43 +29,18 @@ export const TimelineToolbar = memo(function TimelineToolbar({
store.setPixelsPerSecond(Math.max(MIN_PPS, (store.pixelsPerSecond || 100) / ZOOM_STEP));
}, []);
- const btn =
- "flex items-center gap-1 px-2 py-1.5 text-xs rounded transition-colors disabled:opacity-30 disabled:cursor-default";
+ const btn = "flex items-center gap-1 px-2 py-1.5 text-xs rounded transition-colors";
const active = "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800";
- const disabled = "text-neutral-600";
return (
- {/* Editing actions */}
+ {/* Edit action */}
-
diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts
index 71e510b08..2d4e1f2e0 100644
--- a/packages/studio/src/index.ts
+++ b/packages/studio/src/index.ts
@@ -39,6 +39,4 @@ export type { PickedElement } from "./hooks/useElementPicker";
// Utilities
export { resolveSourceFile, applyPatch } from "./utils/sourcePatcher";
export type { PatchOperation } from "./utils/sourcePatcher";
-export { splitElement, deleteElement } from "./utils/htmlEditor";
-export { captureTreeStyles } from "./utils/styleCapture";
-export type { CapturedStyles } from "./utils/styleCapture";
+export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/htmlEditor";
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index ded1862b5..a504db337 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -27,6 +27,10 @@ interface PlayerState {
zoomMode: ZoomMode;
/** Pixels per second when in manual zoom mode */
pixelsPerSecond: number;
+ /** Edit range selection */
+ editRangeStart: number | null;
+ editRangeEnd: number | null;
+ editMode: boolean;
setIsPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
@@ -35,6 +39,8 @@ interface PlayerState {
setTimelineReady: (ready: boolean) => void;
setElements: (elements: TimelineElement[]) => void;
setSelectedElementId: (id: string | null) => void;
+ setEditRange: (start: number | null, end: number | null) => void;
+ setEditMode: (active: boolean) => void;
updateElementStart: (elementId: string, newStart: number) => void;
updateElementDuration: (elementId: string, newDuration: number) => void;
updateElementTrack: (elementId: string, newTrack: number) => void;
@@ -70,6 +76,9 @@ export const usePlayerStore = create
((set) => ({
playbackRate: 1,
zoomMode: "fit",
pixelsPerSecond: 100,
+ editRangeStart: null,
+ editRangeEnd: null,
+ editMode: false,
setIsPlaying: (playing) => set({ isPlaying: playing }),
setPlaybackRate: (rate) => set({ playbackRate: rate }),
@@ -80,6 +89,8 @@ export const usePlayerStore = create((set) => ({
setTimelineReady: (ready) => set({ timelineReady: ready }),
setElements: (elements) => set({ elements }),
setSelectedElementId: (id) => set({ selectedElementId: id }),
+ setEditRange: (start, end) => set({ editRangeStart: start, editRangeEnd: end }),
+ setEditMode: (active) => set({ editMode: active, editRangeStart: null, editRangeEnd: null }),
updateElementStart: (elementId, newStart) =>
set((state) => ({
elements: state.elements.map((el) => (el.id === elementId ? { ...el, start: newStart } : el)),
diff --git a/packages/studio/src/utils/htmlEditor.ts b/packages/studio/src/utils/htmlEditor.ts
index 6818b94ff..dd3b285d1 100644
--- a/packages/studio/src/utils/htmlEditor.ts
+++ b/packages/studio/src/utils/htmlEditor.ts
@@ -1,15 +1,7 @@
/**
- * HTML Editor — Surgical split/delete operations on HyperFrame HTML source.
- *
- * Uses a hybrid approach: DOMParser for validation + regex for mutation.
- * - DOMParser validates the element exists, gets tag name, checks structure
- * - Regex operates on the raw source string, preserving ALL formatting
- * - Scripts are never parsed/executed — opaque string content
- * - Only targeted attributes change — everything else stays byte-identical
+ * HTML Editor — Utility functions for parsing and manipulating HyperFrame HTML source.
*/
-import type { CapturedStyles } from "./styleCapture";
-
/**
* Parse a CSS inline style string into a key-value map.
* e.g. "opacity: 0.5; transform: matrix(1,0,0,1,0,0)" →
@@ -60,184 +52,22 @@ export function mergeStyleIntoTag(tag: string, newStyles: string): string {
}
/**
- * Apply baked (computed) styles from a `CapturedStyles` snapshot onto a clone
- * HTML string. This overwrites any initial animation state (e.g. opacity:0)
- * with the live computed values captured at the split point.
- */
-export function applyBakedStyles(cloneHtml: string, captured: CapturedStyles): string {
- let result = cloneHtml;
-
- // Patch the root element (first opening tag in the string)
- if (captured.rootStyle) {
- // The root element is the first tag — find its end
- const firstTagEnd = result.indexOf(">");
- if (firstTagEnd >= 0) {
- const firstTag = result.slice(0, firstTagEnd + 1);
- const patched = mergeStyleIntoTag(firstTag, captured.rootStyle);
- result = patched + result.slice(firstTagEnd + 1);
- }
- }
-
- // Patch each child element that has an id
- for (const [id, style] of captured.elementStyles) {
- if (!style) continue;
- const idIdx = result.indexOf(`id="${id}"`);
- if (idIdx < 0) continue;
-
- // Walk backward to find the `<` that starts this tag
- let tagStart = idIdx;
- while (tagStart > 0 && result[tagStart] !== "<") tagStart--;
-
- // Walk forward to find the closing `>` of the opening tag (quote-aware)
- let tagEnd = idIdx;
- let inQuote: string | null = null;
- while (tagEnd < result.length) {
- const ch = result[tagEnd];
- if (inQuote) {
- if (ch === inQuote) inQuote = null;
- } else {
- if (ch === '"' || ch === "'") inQuote = ch;
- if (ch === ">") {
- tagEnd++;
- break;
- }
- }
- tagEnd++;
- }
-
- const openTag = result.slice(tagStart, tagEnd);
- const patched = mergeStyleIntoTag(openTag, style);
- result = result.slice(0, tagStart) + patched + result.slice(tagEnd);
- }
-
- // Second pass: apply styles for positional (class-based) captures — elements without IDs
- // that were captured under keys like __cls_{className}_{index}.
- // We use DOMParser on the clone HTML only (format preservation doesn't matter for clones).
- const positionalEntries = [...captured.elementStyles.entries()].filter(([key]) =>
- key.startsWith("__cls_"),
- );
-
- if (positionalEntries.length > 0) {
- const posParser = new DOMParser();
- const posDoc = posParser.parseFromString(result, "text/html");
-
- for (const [key, style] of positionalEntries) {
- if (!style) continue;
- // Key format: __cls_{className}_{index}
- const match = key.match(/^__cls_(.+)_(\d+)$/);
- if (!match) continue;
- const [, className, idxStr] = match;
- const targetIdx = parseInt(idxStr, 10);
-
- // Find all elements carrying this class; pick the one at targetIdx within its parent.
- const candidates = posDoc.querySelectorAll(`.${CSS.escape(className)}`);
- for (const el of candidates) {
- const parent = el.parentElement;
- if (!parent) continue;
- const siblingIdx = Array.from(parent.children).indexOf(el as Element);
- if (siblingIdx === targetIdx) {
- // Merge existing inline style with captured style (captured values win).
- const existing = parseStyleString(el.getAttribute("style") || "");
- const incoming = parseStyleString(style);
- const merged = { ...existing, ...incoming };
- const serialized = Object.entries(merged)
- .map(([k, v]) => `${k}: ${v}`)
- .join("; ");
- el.setAttribute("style", serialized);
- break;
- }
- }
- }
-
- result = posDoc.body.innerHTML;
- }
-
- // Apply captured textContent for leaf elements (GSAP counter / typewriter effects).
- // Only replace innerHTML for elements that contain no child element tags — replacing
- // a parent's innerHTML would destroy its children.
- if (captured.textContents) {
- for (const [id, text] of captured.textContents) {
- const idIdx = result.indexOf(`id="${id}"`);
- if (idIdx < 0) continue;
-
- // Walk backward to find the opening `<`
- let tagStart = idIdx;
- while (tagStart > 0 && result[tagStart] !== "<") tagStart--;
-
- // Walk forward to find the end of the opening tag (quote-aware)
- let tagEnd = tagStart;
- let inQ: string | null = null;
- while (tagEnd < result.length) {
- const ch = result[tagEnd];
- if (inQ) {
- if (ch === inQ) inQ = null;
- } else {
- if (ch === '"' || ch === "'") inQ = ch;
- if (ch === ">") {
- tagEnd++;
- break;
- }
- }
- tagEnd++;
- }
-
- const openTag = result.slice(tagStart, tagEnd);
-
- // Skip self-closing tags — they can't have text content.
- if (openTag.trimEnd().endsWith("/>")) continue;
-
- // Find the tag name to locate the closing tag.
- const tagNameMatch = openTag.match(/^<([a-z][a-z0-9]*)/i);
- if (!tagNameMatch) continue;
- const tagName = tagNameMatch[1].toLowerCase();
- const closeTag = `${tagName}>`;
-
- const closeIdx = result.indexOf(closeTag, tagEnd);
- if (closeIdx < 0) continue;
-
- // Extract current innerHTML between opening and closing tags.
- const currentInner = result.slice(tagEnd, closeIdx);
-
- // Only replace if this is a text-only element — no child element tags.
- if (currentInner.includes("<")) continue;
-
- result = result.slice(0, tagEnd) + text + result.slice(closeIdx);
- }
- }
-
- // Inject seek scripts for canvas renderers (Lottie, Three.js)
- if (captured.seekScripts.length > 0) {
- result += "\n" + captured.seekScripts.join("\n");
- }
-
- return result;
-}
-
-/**
- * Replace a single attribute value in an opening tag string.
- * Returns the tag with the attribute updated, or unchanged if not found.
- */
-function patchAttrInTag(tag: string, attrName: string, newValue: string): string {
- const re = new RegExp(`(${attrName}=["'])([^"']*)(["'])`);
- return tag.replace(re, `$1${newValue}$3`);
-}
-
-/**
- * Find the full opening tag in the source HTML for an element with a given ID.
- * Returns the match with: [fullTag, indent, openTag, tagName] or null.
+ * Find the full element block (opening tag through closing tag) in the source.
+ * Uses quote-aware scanning to handle attributes containing >.
+ * Uses depth counting to handle nested same-name tags.
*/
-function findOpeningTag(
+export function findElementBlock(
html: string,
elementId: string,
): {
- fullTag: string;
- indent: string;
+ start: number;
+ end: number;
openTag: string;
tagName: string;
- index: number;
+ indent: string;
+ innerContent: string;
+ isSelfClosing: boolean;
} | null {
- // Match the opening tag containing this ID — [^>]* handles single-line attrs
- // For multi-line attrs: the id="X" anchors us, then we find < before and > after
const idIdx = html.indexOf(`id="${elementId}"`);
if (idIdx < 0) return null;
@@ -245,7 +75,6 @@ function findOpeningTag(
let tagStart = idIdx;
while (tagStart > 0 && html[tagStart] !== "<") tagStart--;
- // Capture indent (whitespace before the <)
let indentStart = tagStart;
while (indentStart > 0 && html[indentStart - 1] !== "\n") indentStart--;
const indent = html.slice(indentStart, tagStart);
@@ -271,89 +100,57 @@ function findOpeningTag(
const tagNameMatch = openTag.match(/^<([a-z][a-z0-9]*)/i);
if (!tagNameMatch) return null;
- return {
- fullTag: openTag,
- indent: /^[\t ]*$/.test(indent) ? indent : "",
- openTag,
- tagName: tagNameMatch[1],
- index: tagStart,
- };
-}
-
-/**
- * Find the full element block (opening tag through closing tag) in the source.
- * Uses quote-aware scanning to handle attributes containing >.
- * Uses depth counting to handle nested same-name tags.
- */
-function findElementBlock(
- html: string,
- elementId: string,
-): {
- start: number;
- end: number;
- openTag: string;
- tagName: string;
- indent: string;
- innerContent: string;
- isSelfClosing: boolean;
-} | null {
- const info = findOpeningTag(html, elementId);
- if (!info) return null;
-
- const { openTag, tagName, index: start, indent } = info;
+ const tagName = tagNameMatch[1];
const isSelfClosing =
openTag.trimEnd().endsWith("/>") ||
["img", "br", "hr", "input", "meta", "link", "source"].includes(tagName.toLowerCase());
if (isSelfClosing) {
return {
- start,
- end: start + openTag.length,
+ start: tagStart,
+ end: tagStart + openTag.length,
openTag,
tagName,
- indent,
+ indent: /^[\t ]*$/.test(indent) ? indent : "",
innerContent: "",
isSelfClosing: true,
};
}
// Find matching closing tag using depth counting
- // Skip content inside blocks (not the target element's script children — those are counted)
- if (lower.startsWith("", pos);
- pos = scriptEnd < 0 ? html.length : scriptEnd + 9;
- continue;
- }
-
- // Check for opening tag of same name
if (lower.startsWith(openPattern, pos) && /[\s>/]/.test(html[pos + openPattern.length] || "")) {
depth++;
pos += openPattern.length;
continue;
}
- // Check for closing tag of same name
if (lower.startsWith(closeTag, pos)) {
depth--;
if (depth === 0) {
const end = pos + closeTag.length;
- const innerContent = html.slice(start + openTag.length, pos);
- return { start, end, openTag, tagName, indent, innerContent, isSelfClosing: false };
+ const innerContent = html.slice(tagStart + openTag.length, pos);
+ return {
+ start: tagStart,
+ end,
+ openTag,
+ tagName,
+ indent: /^[\t ]*$/.test(indent) ? indent : "",
+ innerContent,
+ isSelfClosing: false,
+ };
}
pos += closeTag.length;
continue;
@@ -362,114 +159,5 @@ function findElementBlock(
pos++;
}
- return null; // closing tag not found
-}
-
-/**
- * Split an element at a given time. Modifies the HTML source string.
- *
- * - Shortens the original element's data-duration
- * - Clones the full element block with new id, data-start, data-duration
- * - For video/audio: adjusts data-media-start on the clone
- * - Inserts the clone right after the original
- */
-export function splitElement(
- html: string,
- elementId: string,
- currentTime: number,
- element: { start: number; duration: number; playbackStart?: number },
- capturedStyles?: CapturedStyles | null,
-): string {
- // Validate with DOMParser first
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, "text/html");
- const domEl = doc.getElementById(elementId);
- if (!domEl) return html;
-
- const splitTime = currentTime - element.start;
- const remainingDuration = element.duration - splitTime;
- if (splitTime <= 0 || remainingDuration <= 0) return html;
-
- // Find the element block in the source string
- const block = findElementBlock(html, elementId);
- if (!block) return html;
-
- const { start, end, openTag, indent, innerContent, isSelfClosing } = block;
- const newId = `${elementId}-b`;
- const firstDuration = Math.round(splitTime * 100) / 100;
- const secondStart = Math.round(currentTime * 100) / 100;
- const secondDuration = Math.round(remainingDuration * 100) / 100;
-
- // Build the shortened original
- const shortenedOpenTag = patchAttrInTag(openTag, "data-duration", String(firstDuration));
-
- // Build the clone's opening tag
- let cloneOpenTag = patchAttrInTag(openTag, "id", newId);
- cloneOpenTag = patchAttrInTag(cloneOpenTag, "data-start", String(secondStart));
- cloneOpenTag = patchAttrInTag(cloneOpenTag, "data-duration", String(secondDuration));
-
- // Update data-composition-id if present (for sub-composition hosts)
- if (openTag.includes("data-composition-id=")) {
- cloneOpenTag = patchAttrInTag(cloneOpenTag, "data-composition-id", newId);
- }
-
- // For video/audio: adjust data-media-start on the clone
- if (openTag.includes("data-media-start=")) {
- const existingMediaStart = parseFloat(
- (openTag.match(/data-media-start=["']([^"']*)["']/) || [])[1] || "0",
- );
- cloneOpenTag = patchAttrInTag(
- cloneOpenTag,
- "data-media-start",
- String(Math.round((existingMediaStart + splitTime) * 100) / 100),
- );
- } else if (domEl.tagName === "VIDEO" || domEl.tagName === "AUDIO") {
- // Add data-media-start if not present
- cloneOpenTag = cloneOpenTag.replace(/>$/, ` data-media-start="${firstDuration}">`);
- }
-
- // Reconstruct
- const closingTag = isSelfClosing ? "" : `${block.tagName}>`;
-
- const originalBlock = shortenedOpenTag + innerContent + closingTag;
- let cloneBlock = cloneOpenTag + innerContent + closingTag;
-
- // Apply captured computed styles to the clone so the second half starts at
- // the exact visual state the element was in at the split point. This
- // correctly overwrites any entrance-animation initial values (e.g. opacity:0)
- // without clobbering unrelated inline styles.
- if (capturedStyles) {
- cloneBlock = applyBakedStyles(cloneBlock, capturedStyles);
- }
-
- return html.slice(0, start) + originalBlock + "\n" + indent + cloneBlock + html.slice(end);
-}
-
-/**
- * Delete an element from the HTML source string.
- * Removes the entire element block including children.
- */
-export function deleteElement(html: string, elementId: string): string {
- // Validate with DOMParser first
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, "text/html");
- if (!doc.getElementById(elementId)) return html;
-
- const block = findElementBlock(html, elementId);
- if (!block) return html;
-
- // Remove the element block + any trailing newline
- let { start, end } = block;
-
- // Also consume the leading whitespace/indent on the same line
- let lineStart = start;
- while (lineStart > 0 && html[lineStart - 1] !== "\n") lineStart--;
- if (html.slice(lineStart, start).trim() === "") {
- start = lineStart;
- }
-
- // Consume trailing newline
- if (html[end] === "\n") end++;
-
- return html.slice(0, start) + html.slice(end);
+ return null;
}
diff --git a/packages/studio/src/utils/styleCapture.ts b/packages/studio/src/utils/styleCapture.ts
deleted file mode 100644
index a7997f5ca..000000000
--- a/packages/studio/src/utils/styleCapture.ts
+++ /dev/null
@@ -1,257 +0,0 @@
-/**
- * Style Capture — Reads live computed styles from an iframe document at the
- * current GSAP playhead position. Used during clip splits to snapshot the
- * exact visual state of every element so the clone (second half) can render
- * correctly without replaying entrance animations.
- */
-
-/**
- * CSS properties that GSAP commonly animates on HTML elements.
- */
-export const CAPTURE_PROPERTIES: readonly string[] = [
- "opacity",
- "transform",
- "visibility",
- "display",
- "clip-path",
- "width",
- "height",
- "top",
- "left",
- "right",
- "bottom",
- "color",
- "background-color",
- "background",
- "border-color",
- "box-shadow",
- "filter",
- "backdrop-filter",
-];
-
-/**
- * Presentational SVG attributes surfaced as CSS properties by browsers.
- * GSAP animates these directly on SVG elements.
- */
-export const SVG_CAPTURE_PROPERTIES: readonly string[] = [
- "stroke-dashoffset",
- "stroke-dasharray",
- "fill",
- "stroke",
- "stroke-width",
- "fill-opacity",
- "stroke-opacity",
-];
-
-/**
- * Property values that are browser defaults and carry no visual information.
- * Skipping them keeps the captured style strings lean.
- */
-const DEFAULT_VALUES: ReadonlyMap = new Map([
- ["opacity", "1"],
- ["transform", "none"],
- ["visibility", "visible"],
- ["display", "block"], // common default — still captured for flex/grid elements
- ["clip-path", "none"],
- ["box-shadow", "none"],
- ["filter", "none"],
- ["backdrop-filter", "none"],
- // SVG defaults
- ["stroke-dashoffset", "0px"],
- ["stroke-dasharray", "none"],
- ["fill", "rgb(0, 0, 0)"],
- ["stroke", "none"],
- ["stroke-width", "1px"],
- ["fill-opacity", "1"],
- ["stroke-opacity", "1"],
-]);
-
-/**
- * Snapshot of captured styles for a composition element tree at a point in
- * time. Used to inject visual state into the clone produced by a clip split.
- */
-export interface CapturedStyles {
- /** Map from element ID → semicolon-separated inline style string. */
- elementStyles: Map;
- /** The captured inline style string for the root element itself. */
- rootStyle: string;
- /** Map from element ID → textContent at split time (for GSAP counter/typewriter effects). */
- textContents: Map;
- /** `,
- );
- }
-
- // Three.js seek script
- if (win && ("__hfThreeTime" in win || root.querySelector("canvas[data-three]"))) {
- const t = splitTimeSeconds;
- seekScripts.push(
- ``,
- );
- }
- }
-
- return { elementStyles, rootStyle, textContents, seekScripts };
- } catch {
- // Cross-origin iframe or other DOM access error — fail silently.
- return null;
- }
-}