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>(new Map()); + + const renderClipContent = useCallback( + (el: TimelineElement, style: { clip: string; label: string }): ReactNode => { + const pid = projectIdRef.current; + if (!pid) return null; + + // Resolve composition source path using the compIdToSrc map + let compSrc = el.compositionSrc; + if (compSrc && compIdToSrc.size > 0) { + const resolved = + compIdToSrc.get(el.id) || + compIdToSrc.get(compSrc.replace(/^compositions\//, "").replace(/\.html$/, "")); + if (resolved) compSrc = resolved; + } + + if (compSrc) { + const previewUrl = `/api/projects/${pid}/preview/comp/${compSrc}`; + return ( + + ); + } + + if ((el.tag === "video" || el.tag === "img") && el.src) { + const mediaSrc = el.src.startsWith("http") + ? el.src + : `/api/projects/${pid}/preview/${el.src}`; + return ( + + ); + } + + // HTML scene divs — render from index.html at the scene's time + if (el.tag === "div" && el.duration > 0) { + const previewUrl = `/api/projects/${pid}/preview`; + return ( + + ); + } + + return null; + }, + [compIdToSrc], + ); const [lintModal, setLintModal] = useState(null); const [linting, setLinting] = useState(false); const [refreshKey, setRefreshKey] = useState(0); @@ -212,6 +280,7 @@ export function StudioApp() { const [_renderError, setRenderError] = useState(null); const refreshTimerRef = useRef | null>(null); const projectIdRef = useRef(projectId); + const previewIframeRef = useRef(null); // Listen for external file changes (user editing HTML outside the editor) useEffect(() => { @@ -458,6 +527,79 @@ export function StudioApp() { activeCompositionPath={ editingFile?.path?.startsWith("compositions/") ? editingFile.path : null } + renderClipContent={renderClipContent} + onCompIdToSrcChange={setCompIdToSrc} + 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); + } + }} + /> + } /> @@ -481,7 +623,7 @@ export function StudioApp() { diff --git a/packages/studio/src/components/editor/SourceEditor.tsx b/packages/studio/src/components/editor/SourceEditor.tsx index a631216f4..098b93db8 100644 --- a/packages/studio/src/components/editor/SourceEditor.tsx +++ b/packages/studio/src/components/editor/SourceEditor.tsx @@ -26,7 +26,9 @@ function getLanguageExtension(language: string) { case "js": case "typescript": case "ts": - return javascript({ typescript: language === "typescript" || language === "ts" }); + return javascript({ + typescript: language === "typescript" || language === "ts", + }); default: return html(); } diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 8faadbac1..48752bf40 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect, useCallback, useRef, memo, type ReactNode } from "react"; +import { useState, useCallback, useRef, memo, type ReactNode } from "react"; +import { useMountEffect } from "../../hooks/useMountEffect"; import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player"; import type { TimelineElement } from "../../player"; import { NLEPreview } from "./NLEPreview"; @@ -9,7 +10,9 @@ interface NLELayoutProps { portrait?: boolean; /** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */ previewOverlay?: ReactNode; - /** Slot rendered below the timeline tracks (e.g., agent activity swim lanes) */ + /** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */ + timelineToolbar?: ReactNode; + /** Slot rendered below the timeline tracks */ timelineFooter?: ReactNode; /** Increment to force the preview to reload (e.g., after file writes) */ refreshKey?: number; @@ -17,6 +20,15 @@ interface NLELayoutProps { activeCompositionPath?: string | null; /** Callback to expose the iframe ref (for element picker, etc.) */ onIframeRef?: (iframe: HTMLIFrameElement | null) => void; + /** Callback when the viewed composition changes (drill-down/back) */ + onCompositionChange?: (compositionPath: string | null) => void; + /** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */ + renderClipContent?: ( + element: TimelineElement, + style: { clip: string; label: string }, + ) => ReactNode; + /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ + onCompIdToSrcChange?: (map: Map) => void; } const MIN_TIMELINE_H = 100; @@ -27,10 +39,14 @@ export const NLELayout = memo(function NLELayout({ projectId, portrait, previewOverlay, + timelineToolbar, timelineFooter, refreshKey, activeCompositionPath, onIframeRef, + onCompositionChange, + renderClipContent, + onCompIdToSrcChange, }: NLELayoutProps) { const { iframeRef, @@ -38,8 +54,17 @@ export const NLELayout = memo(function NLELayout({ seek, onIframeLoad: baseOnIframeLoad, saveSeekPosition, + resetPlayer, } = useTimelinePlayer(); + // Reset timeline state when the project changes to prevent stale data from a + // previous project leaking into the new one. + const prevProjectIdRef = useRef(null); + if (prevProjectIdRef.current !== projectId) { + prevProjectIdRef.current = projectId; + resetPlayer(); + } + // Preserve seek position when refreshKey changes (iframe will remount via key prop). const prevRefreshKeyRef = useRef(refreshKey); if (refreshKey !== prevRefreshKeyRef.current) { @@ -55,7 +80,7 @@ export const NLELayout = memo(function NLELayout({ // Composition ID → actual file path mapping, built from the raw index.html const [compIdToSrc, setCompIdToSrc] = useState>(new Map()); - useEffect(() => { + useMountEffect(() => { fetch(`/api/projects/${projectId}/files/index.html`) .then((r) => r.json()) .then((data: { content?: string }) => { @@ -70,15 +95,28 @@ export const NLELayout = memo(function NLELayout({ if (id && src) map.set(id, src); } setCompIdToSrc(map); + onCompIdToSrcChange?.(map); }) .catch(() => {}); - }, [projectId]); + }); // Composition drill-down stack const [compositionStack, setCompositionStack] = useState([ { id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` }, ]); + // Wrap setCompositionStack to auto-notify parent on composition change + const onCompositionChangeRef = useRef(onCompositionChange); + onCompositionChangeRef.current = onCompositionChange; + const updateCompositionStack: typeof setCompositionStack = useCallback((action) => { + setCompositionStack((prev) => { + const next = typeof action === "function" ? action(prev) : action; + const id = next[next.length - 1]?.id; + queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id)); + return next; + }); + }, []); + // Resizable timeline height const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H); const isDragging = useRef(false); @@ -126,7 +164,7 @@ export const NLELayout = memo(function NLELayout({ usePlayerStore.getState().setElements([]); // Toggle: if already viewing this composition, go back to parent (like Premiere) - setCompositionStack((prev) => { + updateCompositionStack((prev) => { const currentId = prev[prev.length - 1].id; if (currentId === resolvedPath && prev.length > 1) { return prev.slice(0, -1); @@ -141,14 +179,15 @@ export const NLELayout = memo(function NLELayout({ return [...prev, { id: resolvedPath, label, previewUrl }]; }); }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- iframeRef_ is a stable ref; .current mutates and should not be a dep + // eslint-disable-next-line react-hooks/exhaustive-deps [projectId, compIdToSrc], ); // Navigate back to a specific breadcrumb level const handleNavigateComposition = useCallback((index: number) => { usePlayerStore.getState().setElements([]); - setCompositionStack((prev) => prev.slice(0, index + 1)); + updateCompositionStack((prev) => prev.slice(0, index + 1)); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Navigate to a composition when activeCompositionPath changes @@ -157,11 +196,11 @@ export const NLELayout = memo(function NLELayout({ prevActiveCompRef.current = activeCompositionPath; queueMicrotask(() => usePlayerStore.getState().setElements([])); if (activeCompositionPath === "index.html") { - setCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev)); + updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev)); } else if (activeCompositionPath.startsWith("compositions/")) { const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, ""); const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`; - setCompositionStack((prev) => { + updateCompositionStack((prev) => { if (prev[prev.length - 1].id === activeCompositionPath) return prev; return [ { id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` }, @@ -201,9 +240,10 @@ export const NLELayout = memo(function NLELayout({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape" && compositionStack.length > 1) { - setCompositionStack((prev) => prev.slice(0, -1)); + updateCompositionStack((prev) => prev.slice(0, -1)); } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [compositionStack.length], ); @@ -255,11 +295,16 @@ export const NLELayout = memo(function NLELayout({ onDoubleClick={(e) => { if ((e.target as HTMLElement).closest("[data-clip]")) return; if (compositionStack.length > 1) { - setCompositionStack((prev) => prev.slice(0, -1)); + updateCompositionStack((prev) => prev.slice(0, -1)); } }} > - + {timelineToolbar} + {timelineFooter} diff --git a/packages/studio/src/components/timeline/TimelineToolbar.tsx b/packages/studio/src/components/timeline/TimelineToolbar.tsx new file mode 100644 index 000000000..668af612b --- /dev/null +++ b/packages/studio/src/components/timeline/TimelineToolbar.tsx @@ -0,0 +1,168 @@ +import { memo, useCallback } from "react"; +import { usePlayerStore } from "../../player/store/playerStore"; + +const ZOOM_STEP = 1.3; +const MIN_PPS = 20; +const MAX_PPS = 800; + +interface TimelineToolbarProps { + onSplit?: () => void; + onDelete?: () => void; +} + +export const TimelineToolbar = memo(function TimelineToolbar({ + onSplit, + onDelete, +}: TimelineToolbarProps) { + const selectedId = usePlayerStore((s) => s.selectedElementId); + 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"); + }, []); + + const handleZoomIn = useCallback(() => { + const store = usePlayerStore.getState(); + store.setZoomMode("manual"); + store.setPixelsPerSecond(Math.min(MAX_PPS, (store.pixelsPerSecond || 100) * ZOOM_STEP)); + }, []); + + const handleZoomOut = useCallback(() => { + const store = usePlayerStore.getState(); + store.setZoomMode("manual"); + 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 active = "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"; + const disabled = "text-neutral-600"; + + return ( +
+ {/* Editing actions */} +
+ + +
+ +
+ + {/* Zoom controls */} +
+ +
+ + + {zoomMode === "fit" ? "Fit" : `${Math.round((pixelsPerSecond / 100) * 100)}%`} + + +
+
+ ); +}); diff --git a/packages/studio/src/hooks/useCodeEditor.ts b/packages/studio/src/hooks/useCodeEditor.ts index d26d3fc47..467343db7 100644 --- a/packages/studio/src/hooks/useCodeEditor.ts +++ b/packages/studio/src/hooks/useCodeEditor.ts @@ -16,7 +16,7 @@ export interface UseCodeEditorReturn { setActiveFile: (path: string) => void; updateContent: (content: string) => void; markSaved: (path: string) => void; - /** External update from agent — updates saved content, shows reload indicator */ + /** External update — updates saved content, shows reload indicator */ externalUpdate: (path: string, content: string) => void; } diff --git a/packages/studio/src/hooks/useElementPicker.ts b/packages/studio/src/hooks/useElementPicker.ts index ea22a0728..6ba1dfb61 100644 --- a/packages/studio/src/hooks/useElementPicker.ts +++ b/packages/studio/src/hooks/useElementPicker.ts @@ -156,7 +156,11 @@ export function useElementPicker( ( elementId: string, selector: string, - op: { type: "inline-style" | "attribute" | "text-content"; property: string; value: string }, + op: { + type: "inline-style" | "attribute" | "text-content"; + property: string; + value: string; + }, ) => { const opts = optionsRef.current; if (!opts?.workspaceFiles || !opts.onSyncFiles || !elementId) return; diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts index 5ead3f48b..71e510b08 100644 --- a/packages/studio/src/index.ts +++ b/packages/studio/src/index.ts @@ -10,13 +10,14 @@ export { PlayerControls, Timeline, PreviewPanel, - AgentActivityTrack, + VideoThumbnail, + CompositionThumbnail, useTimelinePlayer, usePlayerStore, liveTime, formatTime, } from "./player"; -export type { AgentActivity, TimelineElement, ActiveEdits } from "./player"; +export type { TimelineElement } from "./player"; // Editor export { SourceEditor } from "./components/editor/SourceEditor"; @@ -26,6 +27,18 @@ export { FileTree } from "./components/editor/FileTree"; // App export { StudioApp } from "./App"; +// Timeline toolbar +export { TimelineToolbar } from "./components/timeline/TimelineToolbar"; + // Hooks export { useCodeEditor } from "./hooks/useCodeEditor"; +export type { OpenFile, UseCodeEditorReturn } from "./hooks/useCodeEditor"; export { useElementPicker } from "./hooks/useElementPicker"; +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"; diff --git a/packages/studio/src/player/components/AgentActivityTrack.tsx b/packages/studio/src/player/components/AgentActivityTrack.tsx deleted file mode 100644 index da491c8c5..000000000 --- a/packages/studio/src/player/components/AgentActivityTrack.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { memo } from "react"; - -const TRACK_H = 20; -const GUTTER = 32; - -export interface AgentActivity { - agentId: string; - name: string; - color: string; - /** Active work periods mapped to VIDEO time (not wall clock) */ - periods: Array<{ start: number; end: number }>; - /** Element creation events at specific video times */ - events: Array<{ time: number; type: "create" | "modify" }>; -} - -interface AgentActivityTrackProps { - agents: AgentActivity[]; - duration: number; -} - -export const AgentActivityTrack = memo(function AgentActivityTrack({ - agents, - duration, -}: AgentActivityTrackProps) { - if (agents.length === 0 || duration <= 0) return null; - - return ( -
- {/* Section header */} -
- - - - Agent Activity -
- - {agents.map((agent) => ( -
- {/* Gutter: agent name */} -
-
-
- - {/* Lane */} -
- {/* Active work periods */} - {agent.periods.map((period, i) => { - const leftPct = (period.start / duration) * 100; - const widthPct = ((period.end - period.start) / duration) * 100; - return ( -
- ); - })} - - {/* Events: diamonds for create, circles for modify */} - {agent.events.map((event, i) => { - const leftPct = (event.time / duration) * 100; - return ( -
- {event.type === "create" ? ( -
- ) : ( -
- )} -
- ); - })} -
-
- ))} -
- ); -}); diff --git a/packages/studio/src/player/components/CompositionThumbnail.tsx b/packages/studio/src/player/components/CompositionThumbnail.tsx new file mode 100644 index 000000000..9eea2e48e --- /dev/null +++ b/packages/studio/src/player/components/CompositionThumbnail.tsx @@ -0,0 +1,171 @@ +/** + * CompositionThumbnail — Film-strip of server-rendered JPEG thumbnails. + * + * Requests multiple thumbnails at different timestamps across the clip duration + * and tiles them horizontally — like VideoThumbnail does for video clips. + * Each frame is a separate from /api/projects/:id/thumbnail/:path?t=X. + * + * Lazy-loaded via IntersectionObserver. Uses ResizeObserver to adapt frame count + * when the clip width changes (zoom). + */ + +import { memo, useRef, useState, useCallback, useEffect } from "react"; + +const CLIP_HEIGHT = 66; +const MAX_UNIQUE_FRAMES = 6; + +interface CompositionThumbnailProps { + previewUrl: string; + label: string; + labelColor: string; + seekTime?: number; + duration?: number; + width?: number; + height?: number; +} + +export const CompositionThumbnail = memo(function CompositionThumbnail({ + previewUrl, + label, + labelColor, + seekTime = 0.4, + duration = 5, + width = 1920, + height = 1080, +}: CompositionThumbnailProps) { + const [visible, setVisible] = useState(false); + const [containerWidth, setContainerWidth] = useState(0); + const [loadedFrames, setLoadedFrames] = useState>(new Set()); + const ioRef = useRef(null); + const roRef = useRef(null); + + const setRef = useCallback((el: HTMLDivElement | null) => { + ioRef.current?.disconnect(); + roRef.current?.disconnect(); + if (!el) return; + + // Walk up to data-clip parent for accurate width + let target: HTMLElement = el; + let parent = el.parentElement; + while (parent && !parent.hasAttribute("data-clip")) parent = parent.parentElement; + if (parent) target = parent; + + requestAnimationFrame(() => { + const w = target.clientWidth || target.getBoundingClientRect().width; + if (w > 0) setContainerWidth(w); + }); + + ioRef.current = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + ioRef.current?.disconnect(); + requestAnimationFrame(() => { + const w = target.clientWidth || target.getBoundingClientRect().width; + if (w > 0) setContainerWidth(w); + }); + } + }, + { rootMargin: "300px" }, + ); + ioRef.current.observe(el); + + roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width)); + roRef.current.observe(target); + }, []); + + useEffect( + () => () => { + ioRef.current?.disconnect(); + roRef.current?.disconnect(); + }, + [], + ); + + // Convert preview URL to thumbnail base URL + const thumbnailBase = previewUrl + .replace("/preview/comp/", "/thumbnail/") + .replace(/\/preview$/, "/thumbnail/index.html"); + + // Calculate frame layout + const aspect = width / height; + const frameW = Math.round(CLIP_HEIGHT * aspect); + const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1; + const uniqueFrames = Math.min(frameCount, MAX_UNIQUE_FRAMES); + + // Generate timestamps spread across the clip duration. + // Start at 30% into the scene to skip entrance animations (opacity:0 → 1). + // End at 90% to avoid catching exit animations. + const timestamps: number[] = []; + const startOffset = duration * 0.3; + const endOffset = duration * 0.9; + const range = endOffset - startOffset; + for (let i = 0; i < uniqueFrames; i++) { + const frac = uniqueFrames === 1 ? 0 : i / (uniqueFrames - 1); + timestamps.push(seekTime + startOffset + frac * range); + } + + const hasAnyFrame = loadedFrames.size > 0; + + return ( +
+ {/* Film strip */} + {visible && ( +
+ {Array.from({ length: frameCount }).map((_, i) => { + const uniqueIdx = i % uniqueFrames; + const t = timestamps[uniqueIdx]; + const url = `${thumbnailBase}?t=${t.toFixed(2)}`; + return ( +
+ setLoadedFrames((prev) => new Set(prev).add(uniqueIdx))} + className="absolute inset-0 w-full h-full object-cover" + style={{ + opacity: loadedFrames.has(uniqueIdx) ? 1 : 0, + transition: "opacity 200ms ease-out", + }} + /> +
+ ); + })} +
+ )} + + {/* Shimmer while loading */} + {(!visible || !hasAnyFrame) && ( +
+ )} + + {/* Label */} +
+ + {label} + +
+
+ ); +}); diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 61d39ed8b..618dda722 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -39,7 +39,7 @@ export const Player = forwardRef( const handleMessage = (e: MessageEvent) => { const data = e.data; if ( - (data?.source === "hf-preview" || data?.source === "hf-preview") && + data?.source === "hf-preview" && data?.type === "stage-size" && data.width > 0 && data.height > 0 @@ -83,8 +83,8 @@ export const Player = forwardRef( } } } - } catch { - // Cross-origin + } catch (err) { + console.warn("[Player] Could not read iframe dimensions (cross-origin)", err); } if (loadCountRef.current > 1) { @@ -103,7 +103,7 @@ export const Player = forwardRef( return (