From c3975c3cbb2176224d9dd9bb134dcd9361a0d024 Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Tue, 1 Sep 2026 13:45:59 +0200 Subject: [PATCH 01/17] Rename Studio browser render action --- packages/browser-studio/e2e/browser-studio.test.ts | 4 ++-- packages/docs/docs/client-side-rendering/index.mdx | 2 +- packages/docs/docs/contributing/web-renderer.mdx | 2 +- packages/studio/src/components/RenderButton.tsx | 4 ++-- packages/studio/src/helpers/use-menu-structure.tsx | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/browser-studio/e2e/browser-studio.test.ts b/packages/browser-studio/e2e/browser-studio.test.ts index b7121d0a970..654910a2ce5 100644 --- a/packages/browser-studio/e2e/browser-studio.test.ts +++ b/packages/browser-studio/e2e/browser-studio.test.ts @@ -273,7 +273,7 @@ test('loads Browser Studio, opens external links, and can add, delete, and dupli await secondQuickSwitcherInput.press('Escape'); await studio.locator('[data-compname="MyComp"]').click(); - await studio.getByRole('button', {name: 'Render on web'}).click(); + await studio.getByRole('button', {name: 'Render in browser'}).click(); await expect( studio.getByText('Input Props', {exact: true}), ).toBeVisible(); @@ -343,7 +343,7 @@ test('loads Browser Studio, opens external links, and can add, delete, and dupli 'MyComp / template-blank - Remotion Studio', {timeout: 5000}, ); - await studio.getByRole('button', {name: 'Render on web'}).click(); + await studio.getByRole('button', {name: 'Render in browser'}).click(); await expect( studio.getByText('Render MyComp', {exact: true}), ).toBeVisible(); diff --git a/packages/docs/docs/client-side-rendering/index.mdx b/packages/docs/docs/client-side-rendering/index.mdx index c2d9a483738..4f01fe7b02d 100644 --- a/packages/docs/docs/client-side-rendering/index.mdx +++ b/packages/docs/docs/client-side-rendering/index.mdx @@ -61,7 +61,7 @@ const {getBlob} = await renderMediaOnWeb({ ## Remotion Studio -Client-side rendering is always enabled in the Remotion Studio from . Use the "Render on web" button to render in the browser. +Client-side rendering is always enabled in the Remotion Studio from . Use the "Render in browser" button to start a render. ## Telemetry diff --git a/packages/docs/docs/contributing/web-renderer.mdx b/packages/docs/docs/contributing/web-renderer.mdx index 7d4c6b420be..5893cdf39cc 100644 --- a/packages/docs/docs/contributing/web-renderer.mdx +++ b/packages/docs/docs/contributing/web-renderer.mdx @@ -75,7 +75,7 @@ Sloppy, unfiltered AI PRs will not be processed by us. ## Studio Render Button -In [`packages/example`](/docs/contributing/#testing-your-changes), and in any template, you will see a "Render on web" button. +In [`packages/example`](/docs/contributing/#testing-your-changes), and in any template, you will see a "Render in browser" button. ## See also diff --git a/packages/studio/src/components/RenderButton.tsx b/packages/studio/src/components/RenderButton.tsx index 45f28244825..70e3fcd1210 100644 --- a/packages/studio/src/components/RenderButton.tsx +++ b/packages/studio/src/components/RenderButton.tsx @@ -303,7 +303,7 @@ const RenderButtonInner: React.FC<{ { type: 'item' as const, id: 'client-render', - label: 'Render on web', + label: 'Render in browser', value: 'client-render', onClick: () => handleRenderTypeChange('client-render'), keyHint: null, @@ -360,7 +360,7 @@ const RenderButtonInner: React.FC<{ ? 'Render' : renderType === 'render-command' ? 'Render via CLI' - : 'Render on web'; + : 'Render in browser'; const showRenderLabel = !narrow || renderType !== 'server-render'; const segments = useMemo((): SegmentedButtonSegment[] => { return [ diff --git a/packages/studio/src/helpers/use-menu-structure.tsx b/packages/studio/src/helpers/use-menu-structure.tsx index ac1602e2b0e..fe62bb45974 100644 --- a/packages/studio/src/helpers/use-menu-structure.tsx +++ b/packages/studio/src/helpers/use-menu-structure.tsx @@ -264,7 +264,7 @@ const getRenderMenuItems = ({ { id: 'render-on-web', value: 'render-on-web', - label: 'Render on web...', + label: 'Render in browser...', onClick: () => { closeMenu(); @@ -278,7 +278,7 @@ const getRenderMenuItems = ({ keyHint: null, leftItem: null, subMenu: null, - quickSwitcherLabel: 'Render on web...', + quickSwitcherLabel: 'Render in browser...', }, { type: 'divider' as const, From 1da37e1e85c615cd30e2af7280de308f37021f0c Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 14:06:42 +0200 Subject: [PATCH 02/17] Docs: Add Canvas Capture documentation --- packages/docs/docs/canvas-capture/index.mdx | 12 ++++ .../docs/docs/canvas-capture/installation.mdx | 67 ++++++++++++++++++ packages/docs/sidebars.ts | 16 +++++ packages/docs/src/data/articles.ts | 18 +++++ .../articles-docs-canvas-capture-index.png | Bin 0 -> 41179 bytes ...icles-docs-canvas-capture-installation.png | Bin 0 -> 43262 bytes packages/docs/vercel.ts | 3 + 7 files changed, 116 insertions(+) create mode 100644 packages/docs/docs/canvas-capture/index.mdx create mode 100644 packages/docs/docs/canvas-capture/installation.mdx create mode 100644 packages/docs/static/generated/articles-docs-canvas-capture-index.png create mode 100644 packages/docs/static/generated/articles-docs-canvas-capture-installation.png diff --git a/packages/docs/docs/canvas-capture/index.mdx b/packages/docs/docs/canvas-capture/index.mdx new file mode 100644 index 00000000000..0c0dd622f4a --- /dev/null +++ b/packages/docs/docs/canvas-capture/index.mdx @@ -0,0 +1,12 @@ +--- +image: /generated/articles-docs-canvas-capture-index.png +title: Canvas Capture +--- + +# Canvas Capture + +Remotion Canvas Capture is a high-fidelity webpage recorder which leverages HTML-in-canvas. + +It can record content at a higher resolution than the native display resolution. + +Perfect for product demos showing closeups of UI. diff --git a/packages/docs/docs/canvas-capture/installation.mdx b/packages/docs/docs/canvas-capture/installation.mdx new file mode 100644 index 00000000000..6c403f2a488 --- /dev/null +++ b/packages/docs/docs/canvas-capture/installation.mdx @@ -0,0 +1,67 @@ +--- +image: /generated/articles-docs-canvas-capture-installation.png +title: Install Canvas Capture +sidebar_label: Installation +--- + +# Install Canvas Capture + +> Tip: Copy the URL of this page into an agent to let it install it for you. + +Canvas Capture currently requires an Apple Silicon Mac, [Bun](https://bun.com/) and a checkout of the Remotion repository. + +## Install the dependencies + +From the root of the Remotion repository, install the dependencies: + +```bash title="Terminal" +bun install +``` + +## Install Chrome for Testing + +Canvas Capture uses Chrome for Testing `150.0.7842.0` at revision `r1631007`. This is the exact version known to support the required HTML-in-canvas API. + +Install the browser using the bundled script: + +```bash title="Terminal" +.agents/skills/install-canvas-capture-browser/scripts/install-browser.sh \ + --install-app "$HOME/Applications/Recorder Chrome.app" +``` + +This browser does not receive security updates. Only use it with websites you trust. + +## Build the extension + +Build Canvas Capture and copy it to a directory outside the repository: + +```bash title="Terminal" +.agents/skills/canvas-capture-extension/scripts/rebuild-extension.sh \ + --repo "$PWD" \ + --install-dir "$HOME/Applications/Remotion Canvas Capture Extension" \ + --browser-executable "$HOME/Applications/Recorder Chrome.app/Contents/MacOS/Google Chrome for Testing" +``` + +Keeping the extension outside the repository prevents it from breaking when the checkout or worktree is deleted. + +## Launch the browser + +Launch the pinned browser with the HTML-in-canvas API enabled: + +```bash title="Terminal" +"$HOME/Applications/Recorder Chrome.app/Contents/MacOS/Google Chrome for Testing" \ + --user-data-dir="$HOME/Library/Application Support/Chrome for Testing Canvas Capture r1631007" \ + --enable-features=CanvasDrawElement \ + --enable-blink-features=CanvasDrawElement \ + --disable-component-update \ + --no-first-run \ + --no-default-browser-check +``` + +## Load the extension + +Open `chrome://extensions`, enable Developer mode and select Load unpacked. + +Choose `$HOME/Applications/Remotion Canvas Capture Extension`. + +Open a webpage and click the Remotion Canvas Capture extension icon to start recording. diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts index 8515f217fac..03e4a8385f8 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -1750,6 +1750,11 @@ const sidebars: SidebarsConfig = { href: '/docs/recorder', label: 'Recorder', }, + { + type: 'link', + href: '/docs/canvas-capture', + label: 'Canvas Capture', + }, { type: 'html', value: @@ -1967,6 +1972,17 @@ const sidebars: SidebarsConfig = { ], }, ], + canvasCaptureSidebar: [ + { + type: 'category', + label: 'Canvas Capture', + link: { + type: 'doc', + id: 'canvas-capture/index', + }, + items: ['canvas-capture/installation'], + }, + ], }; export default sidebars; diff --git a/packages/docs/src/data/articles.ts b/packages/docs/src/data/articles.ts index 03c26b171c8..a492a185f79 100644 --- a/packages/docs/src/data/articles.ts +++ b/packages/docs/src/data/articles.ts @@ -575,6 +575,24 @@ export const articles = [ noAi: false, slug: 'cancel-render', }, + { + id: 'canvas-capture/index', + title: 'Canvas Capture', + relativePath: 'docs/canvas-capture/index.mdx', + compId: 'articles-docs-canvas-capture-index', + crumb: null, + noAi: false, + slug: 'canvas-capture/index', + }, + { + id: 'canvas-capture/installation', + title: 'Install Canvas Capture', + relativePath: 'docs/canvas-capture/installation.mdx', + compId: 'articles-docs-canvas-capture-installation', + crumb: null, + noAi: false, + slug: 'canvas-capture/installation', + }, { id: 'canvasimage', title: '', diff --git a/packages/docs/static/generated/articles-docs-canvas-capture-index.png b/packages/docs/static/generated/articles-docs-canvas-capture-index.png new file mode 100644 index 0000000000000000000000000000000000000000..c72b8b7a649d3c26634a58a02ce59635ca1625db GIT binary patch literal 41179 zcmZ5Iby$<#+b}>%2?3FwfTVzmNW*B65|9=#Ub?$uBBHc{bc1vw9V3+PZjkQo{LV0c z-#@#!o;~O8zR&pZL|*C|4ml1A3d%LRi2p07B#{~`n!MU*r4!rOBiwA!nW1$m5p;sZ_%m5518TumK?;`{OD5x)a zj3_-XfxxEG4ahF>z|*h^2qgVK`TWKSH^6;D15OcWp+f40j+OX-LjagN(3!ko5Ogdo zA`kiCe~?0hCLm^Hf~W~v9q4C&X-EDweFp}+NoqkZ@`oej(=kREY_Zx}H1LzQ$2r|^ zp0J=n^bqv7(pHI!3=phRWsm2#et*>rz=XHy2UcIc{7u=T_$R%Ocmb6m{9mqO{gN6j zY5Wx=?}C8J-WdVT$A5+C4wi{$Xp0p~j))1{69r;>q^+btDv1bR_|( zY}8H@jPPH!;6WHzSYiw_D1$g%_x?SBCGFvHm!2Jm&cj0seG&5y4%jR}`;kHk4fzCS zf{{k`i`-ZAKsy`peS= z@t|r-{yFxD3LNO;4f8_>fZrFR?EO*=%mSXDDJ~$+T z()<+MH{ccw9kHRmz(dE;Z*l;>rtYIcM8jeT%76M1lGY${h#v(0ACjJTh2k{-(o@q8 zksjrS)Flhr+E8A?{r&M?7SKZ}9Pqzxp#%d@G305*-e;o}{p%JPKsM=4dSs*!thaPE z_x@0?38+lzU((CFd~$&kB>GEX5YgvozupQuhWHU6@z$iGpe88Xxc`^JPbwa6z*MaP zPYbFc;(wG4Y~3EaD+&wP8Xfwq#lIE~gKA)WVui}8>K^<5kETkWczASqF?z9+L9+g0 z0G3t^XwTpKVuwy(!2o0a0}GCh1tg3T!vJ1}@7{Q^`-|ME8-SXUXzXM7C3qMM&RvGf zLu$a#%8z%ucz=U{I-)N!L$K)MKxEsF;dJu*OAjm&!$4R9s8)Ga{QlM72QK-8K1J0> zeVJCIgfzx~oVsQY(3vAFv9$qZ`#03WoB*9EjgERDSbwYoc@pXmq|p9bh4tE87WeP_;4cqWK##VK<|58Na6lMW0DkLW)O!Q+k98o) z{{gUK3uD*-p(yEi5dL9#ofrzWS#V5HyM*4%0z>@N<|A4Fh@MZwhUAyr#%{yRfdJwE zkpj!#=X`YWSL@TaU=&!%$v`iUV^yzs{gw=py&t&AogdNPE?MW00p0y!7z>*B8HlHG zX87^jYyYDcs2nnq&x&)(Dtn;+d%^@6v9H!%0A5HiZW8_Z3I+v)!uVD%#rB9A)yd}< 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 Date: Tue, 1 Sep 2026 14:19:36 +0200 Subject: [PATCH 03/17] Enable incremental Rspack chunk graph builds --- packages/bundler/src/rspack-config.ts | 9 ++++++++- packages/bundler/src/test/override-order.test.ts | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/bundler/src/rspack-config.ts b/packages/bundler/src/rspack-config.ts index 15fc1fdd37f..70ae413daf1 100644 --- a/packages/bundler/src/rspack-config.ts +++ b/packages/bundler/src/rspack-config.ts @@ -79,8 +79,15 @@ export const rspackConfig = async ({ }, }; + const sharedBaseConfig = getBaseConfig(environment, poll); const baseConfig = { - ...getBaseConfig(environment, poll), + ...sharedBaseConfig, + experiments: { + ...sharedBaseConfig.experiments, + ...(environment === 'development' + ? {incremental: {buildChunkGraph: true}} + : {}), + }, // Remove once https://github.com/huggingface/transformers.js/issues/1759 is resolved. ignoreWarnings: [ { diff --git a/packages/bundler/src/test/override-order.test.ts b/packages/bundler/src/test/override-order.test.ts index 988b95af5b1..fb897ba2191 100644 --- a/packages/bundler/src/test/override-order.test.ts +++ b/packages/bundler/src/test/override-order.test.ts @@ -59,6 +59,17 @@ test('applies the shared override before the Rspack override', async () => { expect(config.name).toBe('shared-rspack-rspack'); }); +test('enables incremental chunk graphs for Rspack development builds', async () => { + const [, config] = await rspackConfig({ + ...baseOptions, + environment: 'development', + bundlerOverride: (configuration) => configuration, + rspackOverride: (configuration) => configuration, + }); + + expect(config.experiments?.incremental).toEqual({buildChunkGraph: true}); +}); + test('includes React Scan only in an explicitly enabled development bundle', async () => { const previousEndpoint = process.env.REMOTION_REACT_SCAN_ENDPOINT; const previousEntryPoint = process.env.REMOTION_REACT_SCAN_ENTRY_POINT; From 0bba04f686d65bb34802bdcc982caaebe8ff16cc Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 14:20:47 +0200 Subject: [PATCH 04/17] `@remotion/studio`: Fix timeline media jitter when trimming --- .../studio/src/components/AudioWaveform.tsx | 16 ++++--- .../components/Timeline/TimelineVideoInfo.tsx | 10 ++-- .../timeline-utils/src/render-frame-strip.ts | 5 +- .../src/test/render-frame-strip.test.ts | 47 +++++++++++++++++++ 4 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 packages/timeline-utils/src/test/render-frame-strip.test.ts diff --git a/packages/studio/src/components/AudioWaveform.tsx b/packages/studio/src/components/AudioWaveform.tsx index f62ae110b13..09b5b02697a 100644 --- a/packages/studio/src/components/AudioWaveform.tsx +++ b/packages/studio/src/components/AudioWaveform.tsx @@ -22,19 +22,17 @@ const getContainerStyle = (height: number): React.CSSProperties => { position: 'relative', width: '100%', height, + overflow: 'hidden', }; }; const waveformCanvasStyle: React.CSSProperties = { pointerEvents: 'none', - width: '100%', - height: '100%', + flexShrink: 0, }; const volumeCanvasStyle: React.CSSProperties = { position: 'absolute', - width: '100%', - height: '100%', }; const parseVolume = (volume: string | number): WaveformVolume => { @@ -161,16 +159,19 @@ const AudioWaveformInner: React.FC<{ const pixelRatio = window.devicePixelRatio; const h = Math.ceil(height * pixelRatio); const w = Math.ceil(visualizationWidth * pixelRatio); + const drawingWidth = visualizationWidth * pixelRatio; canvasElement.width = w; canvasElement.height = h; + canvasElement.style.width = w / pixelRatio + 'px'; + canvasElement.style.height = h / pixelRatio + 'px'; drawBars({ canvas: canvasElement, peaks: portionPeaks ?? EMPTY_PEAKS, color: WHITE_ALPHA_60, volume: visibleVolume, - width: w, + width: drawingWidth, }); }, [height, portionPeaks, visibleVolume, visualizationWidth]); @@ -187,6 +188,7 @@ const AudioWaveformInner: React.FC<{ const pixelRatio = window.devicePixelRatio; const h = Math.ceil(height * pixelRatio); const w = Math.ceil(visualizationWidth * pixelRatio); + const drawingWidth = visualizationWidth * pixelRatio; const context = volumeCanvasElement.getContext('2d'); if (!context) { return; @@ -194,6 +196,8 @@ const AudioWaveformInner: React.FC<{ volumeCanvasElement.width = w; volumeCanvasElement.height = h; + volumeCanvasElement.style.width = w / pixelRatio + 'px'; + volumeCanvasElement.style.height = h / pixelRatio + 'px'; context.clearRect(0, 0, w, h); if (!Array.isArray(visibleVolume)) { @@ -206,7 +210,7 @@ const AudioWaveformInner: React.FC<{ const x = visibleVolume.length <= 1 ? 0 - : (index / (visibleVolume.length - 1)) * w; + : (index / (visibleVolume.length - 1)) * drawingWidth; const y = (1 - v) * (h - TIMELINE_BORDER * 2 * pixelRatio) + pixelRatio; if (index === 0) { context.moveTo(x, y); diff --git a/packages/studio/src/components/Timeline/TimelineVideoInfo.tsx b/packages/studio/src/components/Timeline/TimelineVideoInfo.tsx index e611ad18635..a81d1e26f92 100644 --- a/packages/studio/src/components/Timeline/TimelineVideoInfo.tsx +++ b/packages/studio/src/components/Timeline/TimelineVideoInfo.tsx @@ -37,6 +37,7 @@ const filmstripContainerStyle: React.CSSProperties = { height: TIMELINE_LAYER_FILMSTRIP_HEIGHT, backgroundColor: BLACK_ALPHA_30, display: 'flex', + overflow: 'hidden', borderTopLeftRadius: 2, fontSize: 10, fontFamily: 'Arial, Helvetica', @@ -111,8 +112,8 @@ const TimelineVideoInfoSegment: React.FC<{ const canvas = document.createElement('canvas'); canvas.width = Math.ceil(visualizationWidth * pixelRatio); canvas.height = Math.ceil(TIMELINE_LAYER_FILMSTRIP_HEIGHT * pixelRatio); - canvas.style.width = visualizationWidth + 'px'; - canvas.style.height = TIMELINE_LAYER_FILMSTRIP_HEIGHT + 'px'; + canvas.style.width = canvas.width / pixelRatio + 'px'; + canvas.style.height = canvas.height / pixelRatio + 'px'; const ctx = canvas.getContext('2d'); if (!ctx) { return; @@ -251,7 +252,10 @@ const TimelineVideoInfoSegment: React.FC<{ const filledSlots = new Map(); const {fromSeconds, toSeconds} = times; - const targetWidth = targetCanvas.width; + // Keep the time-to-pixel scale independent of the integer canvas backing size. + const targetWidth = tiledLoop + ? tiledLoop.loopWidth * pixelRatio + : visualizationWidth * pixelRatio; const repeatTarget = () => { if (!tiledLoop) { return; diff --git a/packages/timeline-utils/src/render-frame-strip.ts b/packages/timeline-utils/src/render-frame-strip.ts index b71cd81f93b..b0cfd0dee31 100644 --- a/packages/timeline-utils/src/render-frame-strip.ts +++ b/packages/timeline-utils/src/render-frame-strip.ts @@ -132,14 +132,13 @@ export const drawSlot = ({ const relativeTimestamp = timestamp - fromSeconds * WEBCODECS_TIMESCALE; const frameIndex = relativeTimestamp / durationOfOneFrame; const thumbnailWidth = frame.displayWidth / devicePixelRatio; - const left = Math.floor(frameIndex * thumbnailWidth); - const right = Math.ceil((frameIndex + 1) * thumbnailWidth); + const left = frameIndex * thumbnailWidth; ctx.drawImage( frame, left, 0, - right - left, + thumbnailWidth, frame.displayHeight / devicePixelRatio, ); filledSlots.set(timestamp, frame.timestamp); diff --git a/packages/timeline-utils/src/test/render-frame-strip.test.ts b/packages/timeline-utils/src/test/render-frame-strip.test.ts new file mode 100644 index 00000000000..f7168083769 --- /dev/null +++ b/packages/timeline-utils/src/test/render-frame-strip.test.ts @@ -0,0 +1,47 @@ +import {expect, test} from 'bun:test'; +import {drawSlot, WEBCODECS_TIMESCALE} from '../render-frame-strip'; + +test('filmstrip thumbnails keep their global position after a split', () => { + const frame = { + displayHeight: 52, + displayWidth: 92, + timestamp: 2 * WEBCODECS_TIMESCALE, + } as VideoFrame; + const pixelsPerSecond = 133.8; + const splitAtSeconds = 1.1; + const drawPositions: number[] = []; + const ctx = { + drawImage: (...args: unknown[]) => { + drawPositions.push(args[1] as number); + }, + } as unknown as CanvasRenderingContext2D; + + drawSlot({ + ctx, + devicePixelRatio: 1, + filledSlots: new Map(), + frame, + frameHeight: frame.displayHeight, + fromSeconds: 0, + segmentDuration: 3, + timestamp: frame.timestamp, + visualizationWidth: pixelsPerSecond * 3, + }); + drawSlot({ + ctx, + devicePixelRatio: 1, + filledSlots: new Map(), + frame, + frameHeight: frame.displayHeight, + fromSeconds: splitAtSeconds, + segmentDuration: 3 - splitAtSeconds, + timestamp: frame.timestamp, + visualizationWidth: pixelsPerSecond * (3 - splitAtSeconds), + }); + + const [positionBeforeSplit, positionAfterSplit] = drawPositions; + expect(positionAfterSplit + splitAtSeconds * pixelsPerSecond).toBeCloseTo( + positionBeforeSplit, + 10, + ); +}); From c5a4e05b94af54cbfd15d6d374a09030aed8e6c9 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 14:22:58 +0200 Subject: [PATCH 05/17] Docs: Add Canvas Capture table of contents --- .../docs/docs/canvas-capture/TableOfContents.tsx | 16 ++++++++++++++++ packages/docs/docs/canvas-capture/index.mdx | 6 ++++++ 2 files changed, 22 insertions(+) create mode 100644 packages/docs/docs/canvas-capture/TableOfContents.tsx diff --git a/packages/docs/docs/canvas-capture/TableOfContents.tsx b/packages/docs/docs/canvas-capture/TableOfContents.tsx new file mode 100644 index 00000000000..e4de48813c9 --- /dev/null +++ b/packages/docs/docs/canvas-capture/TableOfContents.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import {Grid} from '../../components/TableOfContents/Grid'; +import {TOCItem} from '../../components/TableOfContents/TOCItem'; + +export const TableOfContents: React.FC = () => { + return ( +
+ + + Installation +
Install the browser and Canvas Capture extension
+
+
+
+ ); +}; diff --git a/packages/docs/docs/canvas-capture/index.mdx b/packages/docs/docs/canvas-capture/index.mdx index 0c0dd622f4a..1737938fbd0 100644 --- a/packages/docs/docs/canvas-capture/index.mdx +++ b/packages/docs/docs/canvas-capture/index.mdx @@ -3,6 +3,8 @@ image: /generated/articles-docs-canvas-capture-index.png title: Canvas Capture --- +import {TableOfContents} from './TableOfContents'; + # Canvas Capture Remotion Canvas Capture is a high-fidelity webpage recorder which leverages HTML-in-canvas. @@ -10,3 +12,7 @@ Remotion Canvas Capture is a high-fidelity webpage recorder which leverages HTML It can record content at a higher resolution than the native display resolution. Perfect for product demos showing closeups of UI. + +## Table of contents + + From acfa49ad9090d7909c12b92ba2208d229fec4ff7 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 14:23:43 +0200 Subject: [PATCH 06/17] Fix context menu actions on right-click --- packages/example/e2e/studio.test.mts | 16 +++++++++++++--- .../studio/src/components/Menu/MenuSubItem.tsx | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/example/e2e/studio.test.mts b/packages/example/e2e/studio.test.mts index 7651758513c..034598a82a1 100644 --- a/packages/example/e2e/studio.test.mts +++ b/packages/example/e2e/studio.test.mts @@ -1665,9 +1665,19 @@ test.describe('visual mode', () => { exact: true, }), ).toBeVisible(); - await contextMenu - .getByRole('button', {name: 'Delete selected', exact: true}) - .click(); + const deleteSelected = contextMenu.getByRole('button', { + name: 'Delete selected', + exact: true, + }); + await deleteSelected.dispatchEvent('pointerup', {button: 2}); + await expect(deleteSelected).toBeVisible(); + expect(fs.readFileSync(sequenceShiftFile, 'utf-8')).toContain( + 'name="Outer frame descendant"', + ); + expect(fs.readFileSync(sequenceShiftFile, 'utf-8')).toContain( + 'name="Local frame descendant"', + ); + await deleteSelected.click(); await expect .poll(() => { diff --git a/packages/studio/src/components/Menu/MenuSubItem.tsx b/packages/studio/src/components/Menu/MenuSubItem.tsx index aaff1913d9f..ffd4bebceec 100644 --- a/packages/studio/src/components/Menu/MenuSubItem.tsx +++ b/packages/studio/src/components/Menu/MenuSubItem.tsx @@ -107,7 +107,7 @@ export const MenuSubItem: React.FC<{ const onPointerUp = useCallback( (e: PointerEvent) => { - if (disabled) { + if (disabled || e.button !== 0) { return; } From 421962cabdcac1226a392fd1a7895105280fbac9 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 14:27:39 +0200 Subject: [PATCH 07/17] Publish Canvas Capture extension separately --- .../scripts/install-browser.sh | 2 +- .../canvas-capture-extension/README.public.md | 19 +++++ .../docs/docs/canvas-capture/installation.mdx | 29 +++---- packages/it-tests/src/templates/publish.ts | 80 ++++++++++++++++++- 4 files changed, 108 insertions(+), 22 deletions(-) create mode 100644 packages/canvas-capture-extension/README.public.md diff --git a/.agents/skills/install-canvas-capture-browser/scripts/install-browser.sh b/.agents/skills/install-canvas-capture-browser/scripts/install-browser.sh index e4c5ba8487e..cdd4340f07f 100755 --- a/.agents/skills/install-canvas-capture-browser/scripts/install-browser.sh +++ b/.agents/skills/install-canvas-capture-browser/scripts/install-browser.sh @@ -6,7 +6,7 @@ expected_version="150.0.7842.0" expected_revision="r1631007" expected_sha256="86de7ffdb70d3f41714bf5b2b6fe3ef23cbf6c924eb407343418f31b9c721f7f" download_url="https://storage.googleapis.com/chrome-for-testing-per-commit-public/mac-arm64/r1631007/chrome-mac-arm64.zip" -install_app="/Users/jonathanburger/Applications/Recorder Chrome.app" +install_app="${HOME:?}/Applications/Recorder Chrome.app" usage() { printf '%s\n' 'Usage: install-browser.sh [--install-app PATH]' diff --git a/packages/canvas-capture-extension/README.public.md b/packages/canvas-capture-extension/README.public.md new file mode 100644 index 00000000000..1220a3dd7ca --- /dev/null +++ b/packages/canvas-capture-extension/README.public.md @@ -0,0 +1,19 @@ +# Remotion Canvas Capture + +Remotion Canvas Capture records an area—or a whole webpage—as a +high-resolution H.264 MP4 or VP9 WebM using Chromium's experimental +HTML-in-canvas implementation. + +## Install + +Follow the [Canvas Capture installation guide](https://www.remotion.dev/docs/canvas-capture/installation). + +## Development + +This repository contains the built Chrome extension and is generated from the +[`packages/canvas-capture-extension`](https://github.com/remotion-dev/remotion/tree/main/packages/canvas-capture-extension) +package in the Remotion monorepo. Changes made directly in this repository will +be overwritten by the release pipeline. + +Please report issues and contribute changes in the +[`remotion-dev/remotion`](https://github.com/remotion-dev/remotion) repository. diff --git a/packages/docs/docs/canvas-capture/installation.mdx b/packages/docs/docs/canvas-capture/installation.mdx index 6c403f2a488..c5511cecd0e 100644 --- a/packages/docs/docs/canvas-capture/installation.mdx +++ b/packages/docs/docs/canvas-capture/installation.mdx @@ -8,42 +8,31 @@ sidebar_label: Installation > Tip: Copy the URL of this page into an agent to let it install it for you. -Canvas Capture currently requires an Apple Silicon Mac, [Bun](https://bun.com/) and a checkout of the Remotion repository. +Canvas Capture currently requires an Apple Silicon Mac. -## Install the dependencies +## Download the extension -From the root of the Remotion repository, install the dependencies: +Clone the built extension to a durable location: ```bash title="Terminal" -bun install +git clone --depth=1 https://github.com/remotion-dev/canvas-capture.git \ + "$HOME/Applications/Remotion Canvas Capture Extension" ``` +The repository contains a production build of Canvas Capture. You do not need to clone or build Remotion. + ## Install Chrome for Testing Canvas Capture uses Chrome for Testing `150.0.7842.0` at revision `r1631007`. This is the exact version known to support the required HTML-in-canvas API. -Install the browser using the bundled script: +Install the browser using the script included with Canvas Capture: ```bash title="Terminal" -.agents/skills/install-canvas-capture-browser/scripts/install-browser.sh \ - --install-app "$HOME/Applications/Recorder Chrome.app" +"$HOME/Applications/Remotion Canvas Capture Extension/install-browser.sh" ``` This browser does not receive security updates. Only use it with websites you trust. -## Build the extension - -Build Canvas Capture and copy it to a directory outside the repository: - -```bash title="Terminal" -.agents/skills/canvas-capture-extension/scripts/rebuild-extension.sh \ - --repo "$PWD" \ - --install-dir "$HOME/Applications/Remotion Canvas Capture Extension" \ - --browser-executable "$HOME/Applications/Recorder Chrome.app/Contents/MacOS/Google Chrome for Testing" -``` - -Keeping the extension outside the repository prevents it from breaking when the checkout or worktree is deleted. - ## Launch the browser Launch the pinned browser with the HTML-in-canvas API enabled: diff --git a/packages/it-tests/src/templates/publish.ts b/packages/it-tests/src/templates/publish.ts index 6a21ad27209..b5acd8a47cf 100644 --- a/packages/it-tests/src/templates/publish.ts +++ b/packages/it-tests/src/templates/publish.ts @@ -1,4 +1,11 @@ -import {cpSync, readFileSync, writeFileSync} from 'node:fs'; +import { + chmodSync, + copyFileSync, + cpSync, + readFileSync, + readdirSync, + writeFileSync, +} from 'node:fs'; import {tmpdir} from 'node:os'; import path from 'path'; import {$} from 'bun'; @@ -216,6 +223,76 @@ const publishAgentPlugins = async () => { ]); }; +const publishCanvasCapture = async () => { + const monorepoDir = path.join(__dirname, '..', '..', '..', '..'); + const packageDir = path.join( + monorepoDir, + 'packages', + 'canvas-capture-extension', + ); + const workingDir = path.join(tmpdir(), `canvas-capture-${Math.random()}`); + + await $`bun run make`.cwd(packageDir); + await $`git clone git@github.com:remotion-dev/canvas-capture.git ${workingDir} --depth 1`; + + const defaultBranch = await $`git branch --show-current` + .cwd(workingDir) + .text(); + const existingFilesInRepo = await $`git ls-files`.cwd(workingDir).quiet(); + for (const file of existingFilesInRepo.stdout + .toString('utf-8') + .trim() + .split('\n')) { + if (file === '') continue; + await $`rm ${file}`.cwd(workingDir).quiet(); + } + + const distDir = path.join(packageDir, 'dist'); + for (const entry of readdirSync(distDir)) { + cpSync(path.join(distDir, entry), path.join(workingDir, entry), { + recursive: true, + }); + } + copyFileSync( + path.join(packageDir, 'README.public.md'), + path.join(workingDir, 'README.md'), + ); + const installBrowserPath = path.join(workingDir, 'install-browser.sh'); + copyFileSync( + path.join( + monorepoDir, + '.agents', + 'skills', + 'install-canvas-capture-browser', + 'scripts', + 'install-browser.sh', + ), + installBrowserPath, + ); + chmodSync(installBrowserPath, 0o755); + copyFileSync( + path.join(monorepoDir, 'LICENSE.md'), + path.join(workingDir, 'LICENSE.md'), + ); + + await $`git add .`.cwd(workingDir).nothrow(); + const hasChanges = await $`git status --porcelain`.cwd(workingDir).text(); + if (!hasChanges) { + console.log('No changes in Canvas Capture'); + return; + } + + const packageJson = JSON.parse( + readFileSync(path.join(packageDir, 'package.json'), 'utf-8'), + ); + await $`git commit -m "Update Canvas Capture"`.cwd(workingDir); + const versionTag = `v${packageJson.version}`; + await $`git tag ${versionTag}`.cwd(workingDir); + await $`git push --atomic origin ${defaultBranch.trim()} ${versionTag}`.cwd( + workingDir, + ); +}; + const publishClaudeCodePlugin = async () => { const claudeCodePluginDir = path.join( __dirname, @@ -345,6 +422,7 @@ for (let i = 0; i < templates.length; i += CONCURRENCY) { results.push( ...(await Promise.allSettled([ publishAgentPlugins(), + publishCanvasCapture(), publishClaudeCodePlugin(), publishKimiCodePlugin(), ])), From 684b609d63b9958952d3eaea7fa59664150ad86d Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 15:21:51 +0200 Subject: [PATCH 08/17] Docs: Clarify Canvas Capture extension location --- packages/docs/docs/canvas-capture/installation.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/docs/canvas-capture/installation.mdx b/packages/docs/docs/canvas-capture/installation.mdx index c5511cecd0e..83758b76cd5 100644 --- a/packages/docs/docs/canvas-capture/installation.mdx +++ b/packages/docs/docs/canvas-capture/installation.mdx @@ -51,6 +51,6 @@ Launch the pinned browser with the HTML-in-canvas API enabled: Open `chrome://extensions`, enable Developer mode and select Load unpacked. -Choose `$HOME/Applications/Remotion Canvas Capture Extension`. +In the file picker, open your home folder, then choose Applications → Remotion Canvas Capture Extension. Open a webpage and click the Remotion Canvas Capture extension icon to start recording. From f741f2cdf08bb8352d342ec09f1e9cabcd95c5fa Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 15:24:02 +0200 Subject: [PATCH 09/17] Discard changes to packages/example/e2e/studio.test.mts --- packages/example/e2e/studio.test.mts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/example/e2e/studio.test.mts b/packages/example/e2e/studio.test.mts index 034598a82a1..7651758513c 100644 --- a/packages/example/e2e/studio.test.mts +++ b/packages/example/e2e/studio.test.mts @@ -1665,19 +1665,9 @@ test.describe('visual mode', () => { exact: true, }), ).toBeVisible(); - const deleteSelected = contextMenu.getByRole('button', { - name: 'Delete selected', - exact: true, - }); - await deleteSelected.dispatchEvent('pointerup', {button: 2}); - await expect(deleteSelected).toBeVisible(); - expect(fs.readFileSync(sequenceShiftFile, 'utf-8')).toContain( - 'name="Outer frame descendant"', - ); - expect(fs.readFileSync(sequenceShiftFile, 'utf-8')).toContain( - 'name="Local frame descendant"', - ); - await deleteSelected.click(); + await contextMenu + .getByRole('button', {name: 'Delete selected', exact: true}) + .click(); await expect .poll(() => { From a6392cc1b49fda98bac61e18d646bf6f5567f2e8 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 15:24:52 +0200 Subject: [PATCH 10/17] Docs: Launch Canvas Capture browser from Launchpad --- packages/docs/docs/canvas-capture/installation.mdx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/docs/docs/canvas-capture/installation.mdx b/packages/docs/docs/canvas-capture/installation.mdx index 83758b76cd5..2bfd8e1c73c 100644 --- a/packages/docs/docs/canvas-capture/installation.mdx +++ b/packages/docs/docs/canvas-capture/installation.mdx @@ -35,17 +35,9 @@ This browser does not receive security updates. Only use it with websites you tr ## Launch the browser -Launch the pinned browser with the HTML-in-canvas API enabled: +Open Google Chrome for Testing from Launchpad. -```bash title="Terminal" -"$HOME/Applications/Recorder Chrome.app/Contents/MacOS/Google Chrome for Testing" \ - --user-data-dir="$HOME/Library/Application Support/Chrome for Testing Canvas Capture r1631007" \ - --enable-features=CanvasDrawElement \ - --enable-blink-features=CanvasDrawElement \ - --disable-component-update \ - --no-first-run \ - --no-default-browser-check -``` +Open `chrome://flags`, search for Canvas Draw Element, set it to Enabled and select Relaunch. ## Load the extension From a3a43c2940c04c0c8c695bac94ba644d1582488a Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Tue, 1 Sep 2026 15:26:52 +0200 Subject: [PATCH 11/17] Merge pull request #11028 from remotion-dev/fix/render-modal-titles `@remotion/studio`: Distinguish browser and server render modals --- packages/browser-studio/e2e/browser-studio.test.ts | 2 +- .../studio/src/components/RenderModal/ServerRenderModal.tsx | 4 +++- packages/studio/src/components/RenderModal/WebRenderModal.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/browser-studio/e2e/browser-studio.test.ts b/packages/browser-studio/e2e/browser-studio.test.ts index b7121d0a970..3e425d73b00 100644 --- a/packages/browser-studio/e2e/browser-studio.test.ts +++ b/packages/browser-studio/e2e/browser-studio.test.ts @@ -345,7 +345,7 @@ test('loads Browser Studio, opens external links, and can add, delete, and dupli ); await studio.getByRole('button', {name: 'Render on web'}).click(); await expect( - studio.getByText('Render MyComp', {exact: true}), + studio.getByText('Render MyComp in the browser', {exact: true}), ).toBeVisible(); await studio.locator('body').press('Escape'); await expect diff --git a/packages/studio/src/components/RenderModal/ServerRenderModal.tsx b/packages/studio/src/components/RenderModal/ServerRenderModal.tsx index 71ea2c63ac9..90ca6e62466 100644 --- a/packages/studio/src/components/RenderModal/ServerRenderModal.tsx +++ b/packages/studio/src/components/RenderModal/ServerRenderModal.tsx @@ -1403,7 +1403,9 @@ const RenderModal: React.FC< }, [availablePixelFormats, pixelFormat]); return (
- +
diff --git a/packages/studio/src/components/RenderModal/WebRenderModal.tsx b/packages/studio/src/components/RenderModal/WebRenderModal.tsx index 767bead0bf1..d19b58bc8e9 100644 --- a/packages/studio/src/components/RenderModal/WebRenderModal.tsx +++ b/packages/studio/src/components/RenderModal/WebRenderModal.tsx @@ -632,7 +632,7 @@ const WebRenderModal: React.FC = ({ return (
- +
From 794ebf8ca32e398da5c8175baa932f1b2fe7e05c Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 16:03:22 +0200 Subject: [PATCH 12/17] Discard changes to packages/bundler/src/test/override-order.test.ts --- packages/bundler/src/test/override-order.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/bundler/src/test/override-order.test.ts b/packages/bundler/src/test/override-order.test.ts index fb897ba2191..988b95af5b1 100644 --- a/packages/bundler/src/test/override-order.test.ts +++ b/packages/bundler/src/test/override-order.test.ts @@ -59,17 +59,6 @@ test('applies the shared override before the Rspack override', async () => { expect(config.name).toBe('shared-rspack-rspack'); }); -test('enables incremental chunk graphs for Rspack development builds', async () => { - const [, config] = await rspackConfig({ - ...baseOptions, - environment: 'development', - bundlerOverride: (configuration) => configuration, - rspackOverride: (configuration) => configuration, - }); - - expect(config.experiments?.incremental).toEqual({buildChunkGraph: true}); -}); - test('includes React Scan only in an explicitly enabled development bundle', async () => { const previousEndpoint = process.env.REMOTION_REACT_SCAN_ENDPOINT; const previousEntryPoint = process.env.REMOTION_REACT_SCAN_ENTRY_POINT; From f18b63ea140ce89d235d59a5c6936f6cd7ee354d Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 16:05:00 +0200 Subject: [PATCH 13/17] Add composition and folder reordering --- .../src/browser-studio-operations.ts | 22 + packages/example/e2e/studio.test.mts | 236 +++++++ packages/studio-codemods/src/recast-mods.ts | 275 +++++++- .../preview-server/routes/apply-codemod.ts | 20 + .../src/preview-server/undo-stack.ts | 1 + .../src/test/apply-codemod.test.ts | 123 ++++ packages/studio-shared/src/codemods.ts | 26 + packages/studio-shared/src/index.ts | 6 +- .../src/components/CompositionSelector.tsx | 63 +- .../components/CompositionSelectorItem.tsx | 637 +++++++++++++----- .../composition-selector-drag-data.ts | 131 ++++ 11 files changed, 1351 insertions(+), 189 deletions(-) create mode 100644 packages/studio/src/components/composition-selector-drag-data.ts diff --git a/packages/browser-studio/src/browser-studio-operations.ts b/packages/browser-studio/src/browser-studio-operations.ts index 21e307f160b..0365bdfca00 100644 --- a/packages/browser-studio/src/browser-studio-operations.ts +++ b/packages/browser-studio/src/browser-studio-operations.ts @@ -235,6 +235,13 @@ const getCodemodTargetCompositionId = ( return codemod.idToMove; } + if ( + codemod.type === 'move-composition-or-folder' && + codemod.source.type === 'composition' + ) { + return codemod.source.compositionId; + } + return null; }; @@ -276,6 +283,21 @@ const resolveCodemodTargetFile = ({ return findProjectFile({filePath: folderFile, project}); } + if ( + codemod.type === 'move-composition-or-folder' && + codemod.source.type === 'folder' + ) { + const folderFile = getFolderFile({ + folderName: codemod.source.folderName, + project, + }); + if (folderFile === null) { + throw new Error(`Could not find folder "${codemod.source.folderName}"`); + } + + return findProjectFile({filePath: folderFile, project}); + } + const rootFile = getRootFileForProject({ entryPoint: project.entryPoint, project, diff --git a/packages/example/e2e/studio.test.mts b/packages/example/e2e/studio.test.mts index 7651758513c..d9d71e64e9f 100644 --- a/packages/example/e2e/studio.test.mts +++ b/packages/example/e2e/studio.test.mts @@ -7,6 +7,7 @@ import { effectKeyframeE2eFile, exampleDir, lostNodePathE2eFile, + rootFile, } from './constants.mts'; import { navigateToLostNodePathE2e, @@ -29,6 +30,81 @@ const outlineSelectionCasesFile = path.join( 'OutlineSelectionCases.tsx', ); +const dragCompositionSelectorItem = async ({ + page, + sourceTitle, + targetTitle, + position, + drop, +}: { + page: Page; + sourceTitle: string; + targetTitle: string; + position: 'before' | 'inside' | 'after'; + drop: boolean; +}) => { + return page.evaluate( + ({ + sourceTitle: source, + targetTitle: target, + position: dropPosition, + drop: shouldDrop, + }) => { + const items = Array.from( + document.querySelectorAll( + '.__remotion-composition-selector-item', + ), + ); + const sourceElement = items.find((element) => element.title === source); + const targetElement = items.find((element) => element.title === target); + if (!sourceElement || !targetElement) { + throw new Error( + `Could not find drag source ${source} or target ${target}`, + ); + } + + const dataTransfer = new DataTransfer(); + sourceElement.dispatchEvent( + new DragEvent('dragstart', { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + const rect = targetElement.getBoundingClientRect(); + const clientY = + dropPosition === 'before' + ? rect.top + 1 + : dropPosition === 'after' + ? rect.bottom - 1 + : rect.top + rect.height / 2; + const dragOver = new DragEvent('dragover', { + bubbles: true, + cancelable: true, + clientY, + dataTransfer, + }); + targetElement.dispatchEvent(dragOver); + if (shouldDrop) { + targetElement.dispatchEvent( + new DragEvent('drop', { + bubbles: true, + cancelable: true, + clientY, + dataTransfer, + }), + ); + sourceElement.dispatchEvent( + new DragEvent('dragend', {bubbles: true, dataTransfer}), + ); + } + + return dragOver.defaultPrevented; + }, + {sourceTitle, targetTitle, position, drop}, + ); +}; + const dropAssetOnCanvas = async ({ assetPath, durationInSeconds, @@ -1118,6 +1194,166 @@ test.describe('visual mode', () => { await expect(page).toHaveURL(/timeline-virtualization-testbed/); }); + test('should visually reorder and nest compositions and folders', async ({ + page, + }) => { + test.setTimeout(60_000); + const initialContents = fs.readFileSync(rootFile, 'utf8'); + await page.goto(STUDIO_URL); + await expect( + page.getByTitle('AnimatedBarChart', {exact: true}), + ).toBeVisible({ + timeout: 15_000, + }); + const firstItemTitle = await page + .locator('.__remotion-composition-selector-item') + .first() + .getAttribute('title'); + expect(firstItemTitle).not.toBeNull(); + const beforeNoOpDrags = fs.readFileSync(rootFile, 'utf8'); + expect( + await dragCompositionSelectorItem({ + page, + sourceTitle: firstItemTitle!, + targetTitle: firstItemTitle!, + position: 'before', + drop: true, + }), + ).toBe(false); + expect( + await dragCompositionSelectorItem({ + page, + sourceTitle: firstItemTitle!, + targetTitle: firstItemTitle!, + position: 'after', + drop: true, + }), + ).toBe(false); + await expect(page.locator('[data-composition-reorder-line]')).toHaveCount( + 0, + ); + await page.waitForTimeout(250); + expect(fs.readFileSync(rootFile, 'utf8')).toBe(beforeNoOpDrags); + + expect( + await dragCompositionSelectorItem({ + page, + sourceTitle: 'AnimatedBarChart', + targetTitle: 'Schema', + position: 'before', + drop: false, + }), + ).toBe(true); + const reorderLine = page.locator('[data-composition-reorder-line]'); + await expect(reorderLine).toBeVisible(); + await expect(reorderLine).toHaveCSS('height', '2px'); + await expect(reorderLine).toHaveCSS( + 'background-color', + 'rgb(11, 132, 255)', + ); + const lineBox = await reorderLine.boundingBox(); + const targetBox = await page + .getByTitle('Schema', {exact: true}) + .boundingBox(); + expect(lineBox).not.toBeNull(); + expect(targetBox).not.toBeNull(); + expect(Math.abs(lineBox!.y - targetBox!.y)).toBeLessThanOrEqual(2); + expect(lineBox!.width).toBeGreaterThan(targetBox!.width); + + await dragCompositionSelectorItem({ + page, + sourceTitle: 'AnimatedBarChart', + targetTitle: 'Schema', + position: 'before', + drop: true, + }); + await expect + .poll(() => { + const contents = fs.readFileSync(rootFile, 'utf8'); + return ( + contents.indexOf('id="AnimatedBarChart"') < + contents.indexOf('') + ); + }) + .toBe(true); + const afterCompositionReorder = fs.readFileSync(rootFile, 'utf8'); + await expect + .poll(async () => { + const compositionBox = await page + .getByTitle('AnimatedBarChart', {exact: true}) + .boundingBox(); + const folderBox = await page + .getByTitle('Schema', {exact: true}) + .boundingBox(); + return ( + compositionBox !== null && + folderBox !== null && + compositionBox.y < folderBox.y + ); + }) + .toBe(true); + expect( + await dragCompositionSelectorItem({ + page, + sourceTitle: 'AnimatedBarChart', + targetTitle: 'Schema', + position: 'before', + drop: true, + }), + ).toBe(false); + await page.waitForTimeout(250); + expect(fs.readFileSync(rootFile, 'utf8')).toBe(afterCompositionReorder); + + await dragCompositionSelectorItem({ + page, + sourceTitle: 'Schema', + targetTitle: 'use-current-scale-on-load', + position: 'before', + drop: true, + }); + await expect + .poll(() => { + const contents = fs.readFileSync(rootFile, 'utf8'); + return ( + contents.indexOf('') < + contents.indexOf('id="use-current-scale-on-load"') + ); + }) + .toBe(true); + const afterFolderReorder = fs.readFileSync(rootFile, 'utf8'); + + await dragCompositionSelectorItem({ + page, + sourceTitle: 'AnimatedBarChart', + targetTitle: 'Schema', + position: 'inside', + drop: true, + }); + await expect + .poll(() => { + const contents = fs.readFileSync(rootFile, 'utf8'); + const folderStart = contents.indexOf(''); + const folderEnd = contents.indexOf('', folderStart); + const composition = contents.indexOf('id="AnimatedBarChart"'); + return composition > folderStart && composition < folderEnd; + }) + .toBe(true); + + const undoButton = page.getByRole('button', {name: /^Undo/}); + await undoButton.click(); + await expect + .poll(() => fs.readFileSync(rootFile, 'utf8')) + .toBe(afterFolderReorder); + await undoButton.click(); + await expect + .poll(() => fs.readFileSync(rootFile, 'utf8')) + .toBe(afterCompositionReorder); + await undoButton.click(); + await expect + .poll(() => fs.readFileSync(rootFile, 'utf8')) + .toBe(initialContents); + }); + test('should play when a composition in the sidebar is focused', async ({ page, }) => { diff --git a/packages/studio-codemods/src/recast-mods.ts b/packages/studio-codemods/src/recast-mods.ts index f5bedadaed5..8c971a8045d 100644 --- a/packages/studio-codemods/src/recast-mods.ts +++ b/packages/studio-codemods/src/recast-mods.ts @@ -18,7 +18,7 @@ import type { VariableDeclaration, VariableDeclarator, } from '@babel/types'; -import type {RecastCodemod} from '@remotion/studio-shared'; +import type {CompositionOrFolder, RecastCodemod} from '@remotion/studio-shared'; import * as recast from 'recast'; import {applyVisualControl} from './apply-visual-control'; import {deleteJsxElementAtPath} from './delete-jsx-node'; @@ -53,6 +53,14 @@ export const applyCodemod = ({ }); } + if (codeMod.type === 'move-composition-or-folder') { + return moveCompositionOrFolder({ + file, + transformation: codeMod, + changesMade, + }); + } + const body = file.program.body.map((node) => { return mapAll(node, codeMod, changesMade, null); }); @@ -436,11 +444,11 @@ const getChildFolderParentName = ({ return [parentFolderName, folderName].filter(Boolean).join('/'); }; -const appendCompositionToFolder = ({ - compositionElement, +const appendElementToFolder = ({ + element, folderElement, }: { - compositionElement: JSXElement; + element: JSXElement; folderElement: JSXElement; }) => { folderElement.openingElement.selfClosing = false; @@ -448,14 +456,14 @@ const appendCompositionToFolder = ({ type: 'JSXClosingElement', name: folderElement.openingElement.name, }; - folderElement.children.push(stripParenthesizedExtra(compositionElement)); + folderElement.children.push(stripParenthesizedExtra(element)); }; -const appendCompositionToRoot = ({ - compositionElement, +const appendElementToRoot = ({ + element, file, }: { - compositionElement: JSXElement; + element: JSXElement; file: File; }) => { let appended = false; @@ -474,12 +482,12 @@ const appendCompositionToRoot = ({ if (argument.type === 'JSXFragment') { (argument.children as JSXFragment['children']).push( - stripParenthesizedExtra(compositionElement), + stripParenthesizedExtra(element), ); } else { astPath.node.argument = wrapInJsxFragment([ argument as unknown as JSXElement, - compositionElement, + element, ]) as never; } @@ -493,6 +501,247 @@ const appendCompositionToRoot = ({ } }; +const getCompositionOrFolderLabel = (item: CompositionOrFolder) => { + return item.type === 'composition' + ? `composition "${item.compositionId}"` + : `folder "${[item.parentName, item.folderName].filter(Boolean).join('/')}"`; +}; + +const matchesCompositionOrFolder = ({ + item, + node, + parentFolderName, +}: { + item: CompositionOrFolder; + node: JSXElement; + parentFolderName: string | null; +}) => { + if (item.type === 'composition') { + return getCompositionIdFromJSXElement(node) === item.compositionId; + } + + return ( + getFolderNameFromJSXElement(node) === item.folderName && + parentFolderName === item.parentName + ); +}; + +const moveCompositionOrFolder = ({ + file, + transformation, + changesMade, +}: { + file: File; + transformation: Extract; + changesMade: Change[]; +}): ApplyCodeModReturnType => { + type LocatedItem = { + node: JSXElement; + parent: JSXElement | JSXFragment; + parentFolderName: string | null; + path: recast.types.NodePath; + }; + + let source: LocatedItem | null = null; + let target: LocatedItem | null = null; + let destinationFolder: LocatedItem | null = null; + const folders: { + node: JSXElement; + name: string; + parentFolderName: string | null; + }[] = []; + const folderStack: string[] = []; + + const visitJsxElement = (astPath: recast.types.NodePath) => { + const node = astPath.node as JSXElement; + const parent = astPath.parentPath?.node; + const parentFolderName = folderStack.join('/') || null; + const isDirectJsxChild = + (parent?.type === 'JSXElement' || parent?.type === 'JSXFragment') && + parent.children.includes(node); + + if ( + isDirectJsxChild && + matchesCompositionOrFolder({ + item: transformation.source, + node, + parentFolderName, + }) + ) { + source = { + node, + parent, + parentFolderName, + path: astPath, + }; + } + + if ( + isDirectJsxChild && + (transformation.destination.type === 'before' || + transformation.destination.type === 'after') && + matchesCompositionOrFolder({ + item: transformation.destination.target, + node, + parentFolderName, + }) + ) { + target = { + node, + parent, + parentFolderName, + path: astPath, + }; + } + + const folderName = getFolderNameFromJSXElement(node); + if (folderName !== null) { + folders.push({node, name: folderName, parentFolderName}); + } + + if ( + isDirectJsxChild && + transformation.destination.type === 'folder' && + folderName === transformation.destination.folderName && + parentFolderName === transformation.destination.parentName + ) { + destinationFolder = { + node, + parent, + parentFolderName, + path: astPath, + }; + } + + if (folderName !== null) { + folderStack.push(folderName); + } + + for (let index = 0; index < node.children.length; index++) { + if (node.children[index].type === 'JSXElement') { + visitJsxElement( + astPath.get('children', index) as recast.types.NodePath, + ); + } + } + + if (folderName !== null) { + folderStack.pop(); + } + }; + + recast.types.visit(file, { + visitJSXElement(astPath) { + visitJsxElement(astPath as unknown as recast.types.NodePath); + return false; + }, + }); + + if (source === null) { + throw new Error( + `Could not find ${getCompositionOrFolderLabel(transformation.source)} as a direct JSX child`, + ); + } + + const sourceItem = source as LocatedItem; + if ( + (transformation.destination.type === 'before' || + transformation.destination.type === 'after') && + target === null + ) { + throw new Error( + `Could not find ${getCompositionOrFolderLabel(transformation.destination.target)} as a reorder target`, + ); + } + + if ( + transformation.destination.type === 'folder' && + destinationFolder === null + ) { + const folderPath = [ + transformation.destination.parentName, + transformation.destination.folderName, + ] + .filter(Boolean) + .join('/'); + throw new Error(`Could not find folder "${folderPath}"`); + } + + if (target !== null && (target as LocatedItem).node === sourceItem.node) { + return {newAst: file, changesMade}; + } + + const sourceFolderPath = + transformation.source.type === 'folder' + ? [transformation.source.parentName, transformation.source.folderName] + .filter(Boolean) + .join('/') + : null; + const destinationParentFolderName = + transformation.destination.type === 'root' + ? null + : transformation.destination.type === 'folder' + ? [ + transformation.destination.parentName, + transformation.destination.folderName, + ] + .filter(Boolean) + .join('/') + : (target as unknown as LocatedItem).parentFolderName; + if ( + sourceFolderPath !== null && + destinationParentFolderName !== null && + (destinationParentFolderName === sourceFolderPath || + destinationParentFolderName.startsWith(`${sourceFolderPath}/`)) + ) { + throw new Error('A folder cannot be moved inside itself'); + } + + if (transformation.source.type === 'folder') { + const sourceFolderName = transformation.source.folderName; + if ( + folders.some( + (folder) => + folder.node !== sourceItem.node && + folder.name === sourceFolderName && + folder.parentFolderName === destinationParentFolderName, + ) + ) { + throw new Error( + `A folder named "${sourceFolderName}" already exists in the destination`, + ); + } + } + + deleteJsxElementAtPath(sourceItem.path); + const element = stripParenthesizedExtra(sourceItem.node); + if (transformation.destination.type === 'root') { + appendElementToRoot({element, file}); + } else if (transformation.destination.type === 'folder') { + appendElementToFolder({ + element, + folderElement: (destinationFolder as unknown as LocatedItem).node, + }); + } else { + const targetItem = target as unknown as LocatedItem; + const targetIndex = targetItem.parent.children.indexOf(targetItem.node); + if (targetIndex === -1) { + throw new Error('The reorder target is no longer available'); + } + + targetItem.parent.children.splice( + transformation.destination.type === 'before' + ? targetIndex + : targetIndex + 1, + 0, + element, + ); + } + + changesMade.push({description: 'Moved composition or folder'}); + return {newAst: file, changesMade}; +}; + const moveCompositionToFolder = ({ file, transformation, @@ -586,15 +835,15 @@ const moveCompositionToFolder = ({ .node as JSXElement; deleteJsxElementAtPath(sourcePath); if (transformation.folderName === null) { - appendCompositionToRoot({compositionElement, file}); + appendElementToRoot({element: compositionElement, file}); changesMade.push({description: 'Moved composition to root'}); } else { if (targetFolder === null) { throw new Error('Could not find target folder'); } - appendCompositionToFolder({ - compositionElement, + appendElementToFolder({ + element: compositionElement, folderElement: targetFolder, }); changesMade.push({description: 'Moved composition into folder'}); diff --git a/packages/studio-server/src/preview-server/routes/apply-codemod.ts b/packages/studio-server/src/preview-server/routes/apply-codemod.ts index e684532c232..39f384e9620 100644 --- a/packages/studio-server/src/preview-server/routes/apply-codemod.ts +++ b/packages/studio-server/src/preview-server/routes/apply-codemod.ts @@ -92,6 +92,14 @@ export const getCodemodLogMessage = ( return `Moved composition "${codemod.idToMove}" ${destination}`; } + if (codemod.type === 'move-composition-or-folder') { + const source = + codemod.source.type === 'composition' + ? `composition "${codemod.source.compositionId}"` + : `folder "${getFolderPath(codemod.source.parentName, codemod.source.folderName)}"`; + return `Moved ${source}`; + } + if (codemod.type === 'rename-folder') { const oldName = getFolderPath(codemod.parentName, codemod.folderName); const newName = getFolderPath(codemod.parentName, codemod.newName); @@ -164,6 +172,18 @@ const getCodemodUndoDescription = (codemod: ApplyCodemodRequest['codemod']) => { }; } + if (codemod.type === 'move-composition-or-folder') { + const label = + codemod.source.type === 'composition' + ? `composition "${codemod.source.compositionId}"` + : `folder "${getFolderPath(codemod.source.parentName, codemod.source.folderName)}"`; + return { + undoMessage: `↩️ Move of ${label}`, + redoMessage: `↪️ Move of ${label}`, + entryType: codemod.type, + }; + } + if (codemod.type === 'new-composition') { return { undoMessage: `↩️ Creation of composition "${codemod.newId}"`, diff --git a/packages/studio-server/src/preview-server/undo-stack.ts b/packages/studio-server/src/preview-server/undo-stack.ts index f61068045ae..144c8cf9784 100644 --- a/packages/studio-server/src/preview-server/undo-stack.ts +++ b/packages/studio-server/src/preview-server/undo-stack.ts @@ -48,6 +48,7 @@ type UndoEntryType = | 'new-composition' | 'duplicate-composition' | 'move-composition-to-folder' + | 'move-composition-or-folder' | 'new-folder' | 'delete-folder' | 'rename-folder'; diff --git a/packages/studio-server/src/test/apply-codemod.test.ts b/packages/studio-server/src/test/apply-codemod.test.ts index 8dc0e4ec9f4..669d2b6f53a 100644 --- a/packages/studio-server/src/test/apply-codemod.test.ts +++ b/packages/studio-server/src/test/apply-codemod.test.ts @@ -232,6 +232,29 @@ test('formats precise log messages for all codemods', () => { }, expected: 'Moved composition "MoveMe" to root', }, + { + codemod: { + type: 'move-composition-or-folder', + source: {type: 'composition', compositionId: 'MoveMe'}, + destination: { + type: 'before', + target: {type: 'folder', folderName: 'Shared', parentName: null}, + }, + }, + expected: 'Moved composition "MoveMe"', + }, + { + codemod: { + type: 'move-composition-or-folder', + source: { + type: 'folder', + folderName: 'Shared', + parentName: 'Parent', + }, + destination: {type: 'root'}, + }, + expected: 'Moved folder "Parent/Shared"', + }, { codemod: { type: 'rename-folder', @@ -960,6 +983,106 @@ test('moves a composition to root', () => { expect(newContents.match(/id="NestedA"/g)?.length).toBe(1); }); +test('visually reorders compositions and folders', () => { + const compositionBeforeFolder = parseAndApplyCodemod({ + input: selfClosingFolderRootContents, + codeMod: { + type: 'move-composition-or-folder', + source: {type: 'composition', compositionId: 'KeepMe'}, + destination: { + type: 'before', + target: {type: 'folder', folderName: 'Empty', parentName: null}, + }, + }, + }); + + expect(compositionBeforeFolder.changesMade).toHaveLength(1); + expect( + compositionBeforeFolder.newContents.indexOf('id="KeepMe"'), + ).toBeLessThan( + compositionBeforeFolder.newContents.indexOf(' { + const {changesMade, newContents} = parseAndApplyCodemod({ + input: folderRootContents, + codeMod: { + type: 'move-composition-or-folder', + source: {type: 'folder', folderName: 'Parent', parentName: null}, + destination: { + type: 'folder', + folderName: 'Shared', + parentName: 'Other', + }, + }, + }); + + expect(changesMade).toHaveLength(1); + expect(newContents.match(//g)).toHaveLength(1); + expect(newContents.match(/id="NestedA"/g)).toHaveLength(1); + expect(newContents.indexOf('id="NestedB"')).toBeLessThan( + newContents.indexOf(''), + ); +}); + +test('rejects moving a folder into its descendant', () => { + expect(() => + parseAndApplyCodemod({ + input: folderRootContents, + codeMod: { + type: 'move-composition-or-folder', + source: {type: 'folder', folderName: 'Parent', parentName: null}, + destination: { + type: 'folder', + folderName: 'Shared', + parentName: 'Parent', + }, + }, + }), + ).toThrow('A folder cannot be moved inside itself'); +}); + +test('rejects duplicate folder names at the destination', () => { + expect(() => + parseAndApplyCodemod({ + input: folderRootContents, + codeMod: { + type: 'move-composition-or-folder', + source: { + type: 'folder', + folderName: 'Shared', + parentName: 'Parent', + }, + destination: { + type: 'folder', + folderName: 'Other', + parentName: null, + }, + }, + }), + ).toThrow('A folder named "Shared" already exists in the destination'); +}); + test('does not use folders inside JSX attributes as move targets', () => { expect(() => parseAndApplyCodemod({ diff --git a/packages/studio-shared/src/codemods.ts b/packages/studio-shared/src/codemods.ts index b67485616c1..8688273d901 100644 --- a/packages/studio-shared/src/codemods.ts +++ b/packages/studio-shared/src/codemods.ts @@ -13,6 +13,17 @@ export type ApplyVisualControlCodemod = { changes: VisualControlChange[]; }; +export type CompositionOrFolder = + | { + type: 'composition'; + compositionId: string; + } + | { + type: 'folder'; + folderName: string; + parentName: string | null; + }; + export type RecastCodemod = | { type: 'new-composition'; @@ -66,6 +77,21 @@ export type RecastCodemod = folderName: string | null; parentName: string | null; } + | { + type: 'move-composition-or-folder'; + source: CompositionOrFolder; + destination: + | {type: 'root'} + | { + type: 'folder'; + folderName: string; + parentName: string | null; + } + | { + type: 'before' | 'after'; + target: CompositionOrFolder; + }; + } | { type: 'rename-folder'; folderName: string; diff --git a/packages/studio-shared/src/index.ts b/packages/studio-shared/src/index.ts index a7f05eddf80..7bc7216fa9b 100644 --- a/packages/studio-shared/src/index.ts +++ b/packages/studio-shared/src/index.ts @@ -184,7 +184,11 @@ export type { SequenceNodePathMutation, SequenceNodePathRemapping, } from './sequence-node-path-mutation'; -export type {ApplyVisualControlCodemod, RecastCodemod} from './codemods'; +export type { + ApplyVisualControlCodemod, + CompositionOrFolder, + RecastCodemod, +} from './codemods'; export {REACT_REFRESH_FINISHED_EVENT} from './react-refresh-event'; export {hasSequenceTimingTraits} from './has-sequence-timing-traits'; export { diff --git a/packages/studio/src/components/CompositionSelector.tsx b/packages/studio/src/components/CompositionSelector.tsx index e559e9978e2..49d090cbe4b 100644 --- a/packages/studio/src/components/CompositionSelector.tsx +++ b/packages/studio/src/components/CompositionSelector.tsx @@ -18,10 +18,11 @@ import {FolderContext} from '../state/folders'; import {SetSelectedModalContext} from '../state/modals'; import {useZIndex} from '../state/z-index'; import { - compositionDragDataToSymbolicatedStack, - getCompositionDragPreviewMetadata, - parseCompositionDragData, -} from './composition-drag-data'; + type CompositionSelectorActiveDrag, + compositionSelectorDragDataToSymbolicatedStack, + hasCompositionSelectorDragData, + parseCompositionSelectorDragData, +} from './composition-selector-drag-data'; import {CompositionSelectorItem} from './CompositionSelectorItem'; import {ContextMenuForTarget} from './ContextMenu'; import {useSelectComposition} from './InitialCompositionLoader'; @@ -153,6 +154,7 @@ export const CompositionSelector: React.FC = () => { setSelectedModal, ]); const [rootDragHovered, setRootDragHovered] = useState(false); + const activeDragRef = useRef(null); const listRef = useRef(null); const autoScrollAnimation = useRef(null); const autoScrollSpeed = useRef(0); @@ -243,7 +245,7 @@ export const CompositionSelector: React.FC = () => { (event: React.DragEvent) => { if ( window.remotion_isReadOnlyStudio || - getCompositionDragPreviewMetadata(event.dataTransfer.types) === null + !hasCompositionSelectorDragData(event.dataTransfer.types) ) { stopCompositionListAutoScroll(); return; @@ -269,11 +271,19 @@ export const CompositionSelector: React.FC = () => { const onRootDragOver = useCallback((event: React.DragEvent) => { if ( window.remotion_isReadOnlyStudio || - getCompositionDragPreviewMetadata(event.dataTransfer.types) === null + !hasCompositionSelectorDragData(event.dataTransfer.types) ) { return; } + if ( + activeDragRef.current === null || + activeDragRef.current.parentFolderPath === null + ) { + setRootDragHovered(false); + return; + } + event.preventDefault(); event.dataTransfer.dropEffect = 'move'; setRootDragHovered(true); @@ -301,8 +311,17 @@ export const CompositionSelector: React.FC = () => { return; } - const compositionDragData = parseCompositionDragData(event.dataTransfer); - if (compositionDragData === null) { + const dragData = parseCompositionSelectorDragData(event.dataTransfer); + if (dragData === null) { + return; + } + + if ( + activeDragRef.current === null || + activeDragRef.current.parentFolderPath === null + ) { + event.stopPropagation(); + setRootDragHovered(false); return; } @@ -310,16 +329,8 @@ export const CompositionSelector: React.FC = () => { event.stopPropagation(); stopCompositionListAutoScroll(); setRootDragHovered(false); - - const composition = compositions.find( - (c) => c.id === compositionDragData.compositionId, - ); - if (!composition || composition.folderName === null) { - return; - } - const notification = showNotification( - `Moving ${compositionDragData.compositionId}...`, + `Moving ${dragData.item.type === 'composition' ? dragData.item.compositionId : dragData.item.folderName}...`, null, ); const controller = new AbortController(); @@ -327,15 +338,14 @@ export const CompositionSelector: React.FC = () => { try { const result = await applyCodemod({ codemod: { - type: 'move-composition-to-folder', - idToMove: compositionDragData.compositionId, - folderName: null, - parentName: null, + type: 'move-composition-or-folder', + source: dragData.item, + destination: {type: 'root'}, }, dryRun: false, signal: controller.signal, symbolicatedStack: - compositionDragDataToSymbolicatedStack(compositionDragData), + compositionSelectorDragDataToSymbolicatedStack(dragData), }); if (result.success) { @@ -350,7 +360,7 @@ export const CompositionSelector: React.FC = () => { ); } }, - [compositions, stopCompositionListAutoScroll], + [stopCompositionListAutoScroll], ); return ( @@ -376,7 +386,7 @@ export const CompositionSelector: React.FC = () => { onDropCapture={stopCompositionListAutoScroll} onDrop={onRootDrop} > - {items.map((c) => { + {items.map((c, index) => { return ( { selectComposition={selectComposition} toggleFolder={toggleFolder} clearRootDragHover={clearRootDragHover} + canReorder={compositionSortOrder === 'registration'} tabIndex={tabIndex} item={c} + activeDragRef={activeDragRef} + parentFolderPath={null} + previousSibling={index === 0 ? null : items[index - 1]} + nextSibling={index === items.length - 1 ? null : items[index + 1]} /> ); })} diff --git a/packages/studio/src/components/CompositionSelectorItem.tsx b/packages/studio/src/components/CompositionSelectorItem.tsx index cfa071d9f28..3334877f94d 100644 --- a/packages/studio/src/components/CompositionSelectorItem.tsx +++ b/packages/studio/src/components/CompositionSelectorItem.tsx @@ -1,4 +1,10 @@ -import type {DragEvent, KeyboardEvent, MouseEvent} from 'react'; +import type {CompositionOrFolder, RecastCodemod} from '@remotion/studio-shared'; +import type { + DragEvent, + KeyboardEvent, + MouseEvent, + MutableRefObject, +} from 'react'; import React, { useCallback, useContext, @@ -12,6 +18,7 @@ import {StudioServerConnectionCtx} from '../helpers/client-id'; import { CURRENT_COLOR, LIGHT_TEXT, + TIMELINE_BLUE, TRANSPARENT, WHITE, WHITE_ALPHA_06, @@ -29,13 +36,16 @@ import { } from '../helpers/sidebar-scroll-into-view'; import {CollapsedFolderIcon, ExpandedFolderIcon} from '../icons/folder'; import {SetSelectedModalContext} from '../state/modals'; -import { - compositionDragDataToSymbolicatedStack, - getCompositionDragPreviewMetadata, - makeCompositionDragData, - parseCompositionDragData, -} from './composition-drag-data'; +import {makeCompositionDragData} from './composition-drag-data'; import {getCompositionContextMenuItems} from './composition-menu-items'; +import { + type CompositionSelectorActiveDrag, + compositionSelectorDragDataToSymbolicatedStack, + hasCompositionSelectorDragData, + makeCompositionSelectorDragData, + parseCompositionSelectorDragData, + type CompositionSelectorDragData, +} from './composition-selector-drag-data'; import {CompositionContextButton} from './CompositionContextButton'; import {CompositionOrStillIcon} from './CompositionOrStillIcon'; import {ContextMenu} from './ContextMenu'; @@ -85,6 +95,98 @@ const iconStyle: React.CSSProperties = { flexShrink: 0, }; +const reorderWrapper: React.CSSProperties = { + position: 'relative', +}; + +const reorderLineBase: React.CSSProperties = { + backgroundColor: TIMELINE_BLUE, + height: 2, + left: 0, + pointerEvents: 'none', + position: 'absolute', + right: 0, + zIndex: 1, +}; + +type DropPosition = 'before' | 'inside' | 'after'; + +const itemToDescriptor = ( + item: CompositionSelectorItemType, +): CompositionOrFolder => { + return item.type === 'composition' + ? {type: 'composition', compositionId: item.composition.id} + : { + type: 'folder', + folderName: item.folderName, + parentName: item.parentName, + }; +}; + +const itemsAreEqual = ( + left: CompositionOrFolder, + right: CompositionOrFolder, +) => { + if (left.type === 'composition') { + return ( + right.type === 'composition' && left.compositionId === right.compositionId + ); + } + + return ( + right.type === 'folder' && + left.folderName === right.folderName && + left.parentName === right.parentName + ); +}; + +const getFolderPath = ({ + folderName, + parentName, +}: { + readonly folderName: string; + readonly parentName: string | null; +}) => { + return [parentName, folderName].filter(Boolean).join('/'); +}; + +const wouldMoveFolderIntoItself = ({ + activeDrag, + destinationParentFolderPath, +}: { + readonly activeDrag: CompositionSelectorActiveDrag; + readonly destinationParentFolderPath: string | null; +}) => { + if ( + activeDrag.item.type !== 'folder' || + destinationParentFolderPath === null + ) { + return false; + } + + const sourceFolderPath = getFolderPath(activeDrag.item); + return ( + destinationParentFolderPath === sourceFolderPath || + destinationParentFolderPath.startsWith(`${sourceFolderPath}/`) + ); +}; + +const canMoveIntoFolder = ({ + activeDrag, + destinationFolderPath, +}: { + readonly activeDrag: CompositionSelectorActiveDrag; + readonly destinationFolderPath: string; +}) => { + return ( + activeDrag.parentFolderPath !== destinationFolderPath && + !wouldMoveFolderIntoItself({ + activeDrag, + destinationParentFolderPath: destinationFolderPath, + }) + ); +}; + export type CompositionSelectorItemType = | { key: string; @@ -114,7 +216,12 @@ export const CompositionSelectorItem: React.FC<{ parentName: string | null, ) => void; readonly clearRootDragHover: () => void; + readonly canReorder: boolean; readonly level: number; + readonly activeDragRef: MutableRefObject; + readonly parentFolderPath: string | null; + readonly previousSibling: CompositionSelectorItemType | null; + readonly nextSibling: CompositionSelectorItemType | null; }> = ({ item, level, @@ -123,6 +230,11 @@ export const CompositionSelectorItem: React.FC<{ selectComposition, toggleFolder, clearRootDragHover, + canReorder, + activeDragRef, + parentFolderPath, + previousSibling, + nextSibling, }) => { const selected = useMemo(() => { if (item.type === 'composition') { @@ -132,7 +244,7 @@ export const CompositionSelectorItem: React.FC<{ return false; }, [item, currentComposition]); const [isDragging, setIsDragging] = useState(false); - const [dragHovered, setDragHovered] = useState(false); + const [dropPosition, setDropPosition] = useState(null); const compositionRowRef = useRef(null); const compositionId = @@ -150,22 +262,35 @@ export const CompositionSelectorItem: React.FC<{ }, [compositionId, selected]); const style: React.CSSProperties = useMemo(() => { - const idleBackground = dragHovered - ? WHITE_ALPHA_12 - : selected - ? WHITE_ALPHA_06 - : TRANSPARENT; + const idleBackground = + dropPosition === 'inside' + ? WHITE_ALPHA_12 + : selected + ? WHITE_ALPHA_06 + : TRANSPARENT; return { ...itemStyle, ...hoverableStyle({ idleBackground, - hoverBackground: dragHovered ? WHITE_ALPHA_12 : WHITE_ALPHA_06, + hoverBackground: + dropPosition === 'inside' ? WHITE_ALPHA_12 : WHITE_ALPHA_06, idleColor: selected ? WHITE : LIGHT_TEXT, hoverColor: WHITE, }), paddingLeft: 12 + level * 8, }; - }, [dragHovered, level, selected]); + }, [dropPosition, level, selected]); + + const reorderLineStyle = useMemo((): React.CSSProperties | null => { + if (dropPosition !== 'before' && dropPosition !== 'after') { + return null; + } + + return { + ...reorderLineBase, + ...(dropPosition === 'before' ? {top: -1} : {bottom: -1}), + }; + }, [dropPosition]); const onClick = useCallback( (evt: MouseEvent | KeyboardEvent) => { @@ -233,115 +358,185 @@ export const CompositionSelectorItem: React.FC<{ setSelectedModal, ]); - const onCompositionDragStart = useCallback( + const onItemDragStart = useCallback( (event: DragEvent) => { - if (item.type !== 'composition' || window.remotion_isReadOnlyStudio) { + if (window.remotion_isReadOnlyStudio) { event.preventDefault(); return; } + const itemDescriptor = itemToDescriptor(item); + activeDragRef.current = { + item: itemDescriptor, + parentFolderPath, + }; setIsDragging(true); event.dataTransfer.effectAllowed = 'copyMove'; - const dragData = makeCompositionDragData({ - compositionFile: resolvedLocation?.source ?? null, - compositionId: item.composition.id, - width: item.composition.width ?? null, - height: item.composition.height ?? null, - durationInFrames: item.composition.durationInFrames ?? null, + const selectorDragData = makeCompositionSelectorDragData({ + item: itemDescriptor, + sourceFile: resolvedLocation?.source ?? null, }); - event.dataTransfer.setData(dragData.mimeType, dragData.payload); + event.dataTransfer.setData( + selectorDragData.mimeType, + selectorDragData.payload, + ); + + if (item.type === 'composition') { + const compositionDragData = makeCompositionDragData({ + compositionFile: resolvedLocation?.source ?? null, + compositionId: item.composition.id, + width: item.composition.width ?? null, + height: item.composition.height ?? null, + durationInFrames: item.composition.durationInFrames ?? null, + }); + event.dataTransfer.setData( + compositionDragData.mimeType, + compositionDragData.payload, + ); + } }, - [item, resolvedLocation?.source], + [activeDragRef, item, parentFolderPath, resolvedLocation?.source], ); - const onCompositionDragEnd = useCallback(() => { + const onItemDragEnd = useCallback(() => { + activeDragRef.current = null; setIsDragging(false); - }, []); + setDropPosition(null); + }, [activeDragRef]); - const onFolderDragOver = useCallback( - (event: DragEvent) => { - if ( - item.type !== 'folder' || - window.remotion_isReadOnlyStudio || - getCompositionDragPreviewMetadata(event.dataTransfer.types) === null - ) { - return; + const getDropPosition = useCallback( + (event: DragEvent): DropPosition | null => { + const activeDrag = activeDragRef.current; + if (activeDrag === null) { + return null; } - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer.dropEffect = 'move'; - clearRootDragHover(); - setDragHovered(true); - }, - [clearRootDragHover, item], - ); + if (!canReorder) { + if (item.type !== 'folder') { + return null; + } - const onFolderDragLeave = useCallback(() => { - setDragHovered(false); - }, []); + return canMoveIntoFolder({ + activeDrag, + destinationFolderPath: getFolderPath(item), + }) + ? 'inside' + : null; + } + + const {top, height} = event.currentTarget.getBoundingClientRect(); + const progress = height === 0 ? 0.5 : (event.clientY - top) / height; + const position = + item.type === 'composition' + ? progress < 0.5 + ? 'before' + : 'after' + : progress < 0.25 + ? 'before' + : progress > 0.75 + ? 'after' + : 'inside'; + const target = itemToDescriptor(item); + if (itemsAreEqual(activeDrag.item, target)) { + return null; + } - const onFolderChildListDragOver = useCallback( - (event: DragEvent) => { if ( - item.type !== 'folder' || - window.remotion_isReadOnlyStudio || - getCompositionDragPreviewMetadata(event.dataTransfer.types) === null + position === 'before' && + previousSibling !== null && + itemsAreEqual(activeDrag.item, itemToDescriptor(previousSibling)) ) { - return; + return null; } - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer.dropEffect = 'move'; - clearRootDragHover(); + if ( + position === 'after' && + nextSibling !== null && + itemsAreEqual(activeDrag.item, itemToDescriptor(nextSibling)) + ) { + return null; + } + + if (position === 'inside' && item.type === 'folder') { + return canMoveIntoFolder({ + activeDrag, + destinationFolderPath: getFolderPath(item), + }) + ? position + : null; + } + + return wouldMoveFolderIntoItself({ + activeDrag, + destinationParentFolderPath: parentFolderPath, + }) + ? null + : position; }, - [clearRootDragHover, item], + [ + activeDragRef, + canReorder, + item, + nextSibling, + parentFolderPath, + previousSibling, + ], ); - const onFolderDrop = useCallback( - async (event: DragEvent) => { - if (item.type !== 'folder' || window.remotion_isReadOnlyStudio) { + const onRowDragOver = useCallback( + (event: DragEvent) => { + if ( + window.remotion_isReadOnlyStudio || + !hasCompositionSelectorDragData(event.dataTransfer.types) + ) { return; } - const compositionDragData = parseCompositionDragData(event.dataTransfer); - if (compositionDragData === null) { + const position = getDropPosition(event); + if (position === null) { + event.stopPropagation(); return; } event.preventDefault(); event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; clearRootDragHover(); - setDragHovered(false); - - const isAlreadyDirectChild = item.items.some((child) => { - return ( - child.type === 'composition' && - child.composition.id === compositionDragData.compositionId - ); - }); - if (isAlreadyDirectChild) { - return; - } + setDropPosition(position); + }, + [clearRootDragHover, getDropPosition], + ); - const notification = showNotification( - `Moving ${compositionDragData.compositionId}...`, - null, - ); - const controller = new AbortController(); + const onRowDragLeave = useCallback(() => { + setDropPosition(null); + }, []); + const moveItem = useCallback( + async ({ + destination, + dragData, + }: { + destination: Extract< + RecastCodemod, + {type: 'move-composition-or-folder'} + >['destination']; + dragData: CompositionSelectorDragData; + }) => { + const label = + dragData.item.type === 'composition' + ? dragData.item.compositionId + : dragData.item.folderName; + const notification = showNotification(`Moving ${label}...`, null); try { const result = await applyCodemod({ codemod: { - type: 'move-composition-to-folder', - idToMove: compositionDragData.compositionId, - folderName: item.folderName, - parentName: item.parentName, + type: 'move-composition-or-folder', + source: dragData.item, + destination, }, dryRun: false, - signal: controller.signal, + signal: new AbortController().signal, symbolicatedStack: - compositionDragDataToSymbolicatedStack(compositionDragData), + compositionSelectorDragDataToSymbolicatedStack(dragData), }); if (result.success) { @@ -350,7 +545,12 @@ export const CompositionSelectorItem: React.FC<{ notification.replaceContent(result.reason, 4000); } - if (result.success && !item.expanded) { + if ( + result.success && + destination.type === 'folder' && + item.type === 'folder' && + !item.expanded + ) { toggleFolder(item.folderName, item.parentName); } } catch (err) { @@ -360,45 +560,163 @@ export const CompositionSelectorItem: React.FC<{ ); } }, - [clearRootDragHover, item, toggleFolder], + [item, toggleFolder], + ); + + const onRowDrop = useCallback( + async (event: DragEvent) => { + const dragData = parseCompositionSelectorDragData(event.dataTransfer); + const position = getDropPosition(event); + if (dragData === null) { + return; + } + + if (position === null) { + event.stopPropagation(); + return; + } + + event.preventDefault(); + event.stopPropagation(); + clearRootDragHover(); + setDropPosition(null); + await moveItem({ + dragData, + destination: + position === 'inside' && item.type === 'folder' + ? { + type: 'folder', + folderName: item.folderName, + parentName: item.parentName, + } + : { + type: position === 'before' ? 'before' : 'after', + target: itemToDescriptor(item), + }, + }); + }, + [clearRootDragHover, getDropPosition, item, moveItem], + ); + + const onFolderChildListDragOver = useCallback( + (event: DragEvent) => { + if ( + item.type !== 'folder' || + window.remotion_isReadOnlyStudio || + !hasCompositionSelectorDragData(event.dataTransfer.types) + ) { + return; + } + + const activeDrag = activeDragRef.current; + if ( + activeDrag === null || + !canMoveIntoFolder({ + activeDrag, + destinationFolderPath: getFolderPath(item), + }) + ) { + event.stopPropagation(); + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + clearRootDragHover(); + }, + [activeDragRef, clearRootDragHover, item], + ); + + const onFolderChildListDrop = useCallback( + async (event: DragEvent) => { + if (item.type !== 'folder' || window.remotion_isReadOnlyStudio) { + return; + } + + const dragData = parseCompositionSelectorDragData(event.dataTransfer); + if (dragData === null) { + return; + } + + const activeDrag = activeDragRef.current; + if ( + activeDrag === null || + !canMoveIntoFolder({ + activeDrag, + destinationFolderPath: getFolderPath(item), + }) + ) { + event.stopPropagation(); + return; + } + + event.preventDefault(); + event.stopPropagation(); + clearRootDragHover(); + await moveItem({ + dragData, + destination: { + type: 'folder', + folderName: item.folderName, + parentName: item.parentName, + }, + }); + }, + [activeDragRef, clearRootDragHover, item, moveItem], ); if (item.type === 'folder') { + const folderPath = getFolderPath(item); return ( <> - -
- {item.expanded ? ( - - ) : ( - - )} - -
{item.folderName}
- - -
-
+
+ {reorderLineStyle ? ( +
+ ) : null} + +
+ {item.expanded ? ( + + ) : ( + + )} + +
{item.folderName}
+ + +
+
+
{item.expanded ? ( -
- {item.items.map((childItem) => { +
+ {item.items.map((childItem, index) => { return ( ); })} @@ -420,41 +747,49 @@ export const CompositionSelectorItem: React.FC<{ return ( - - - - -
{item.composition.id}
- - - -
-
+
+ {reorderLineStyle ? ( + ); }; diff --git a/packages/studio/src/components/composition-selector-drag-data.ts b/packages/studio/src/components/composition-selector-drag-data.ts new file mode 100644 index 00000000000..b2cbe4d06d8 --- /dev/null +++ b/packages/studio/src/components/composition-selector-drag-data.ts @@ -0,0 +1,131 @@ +import type { + CompositionOrFolder, + SymbolicatedStackFrame, +} from '@remotion/studio-shared'; + +const MIME_TYPE = 'application/remotion-composition-selector-reorder'; + +export type CompositionSelectorDragData = { + readonly type: 'remotion-composition-selector'; + readonly version: 1; + readonly item: CompositionOrFolder; + readonly sourceFile: string | null; +}; + +export type CompositionSelectorActiveDrag = { + readonly item: CompositionOrFolder; + readonly parentFolderPath: string | null; +}; + +type DragDataTransfer = { + readonly types: ArrayLike; + readonly getData: (mimeType: string) => string; +}; + +const isSafeString = (value: unknown, maxLength: number): value is string => { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= maxLength && + !value.includes('\0') + ); +}; + +const isCompositionOrFolder = ( + value: unknown, +): value is CompositionOrFolder => { + if (typeof value !== 'object' || value === null) { + return false; + } + + const item = value as Record; + if (item.type === 'composition') { + return isSafeString(item.compositionId, 500); + } + + return ( + item.type === 'folder' && + isSafeString(item.folderName, 500) && + (item.parentName === null || isSafeString(item.parentName, 2000)) + ); +}; + +const isSafeSourceFile = (value: unknown): value is string | null => { + return ( + value === null || + (isSafeString(value, 2000) && + !value.includes('\\') && + !value.startsWith('/') && + !value.split('/').includes('..')) + ); +}; + +export const makeCompositionSelectorDragData = ({ + item, + sourceFile, +}: { + readonly item: CompositionOrFolder; + readonly sourceFile: string | null; +}) => { + const data: CompositionSelectorDragData = { + type: 'remotion-composition-selector', + version: 1, + item, + sourceFile, + }; + return {mimeType: MIME_TYPE, payload: JSON.stringify(data), data}; +}; + +export const hasCompositionSelectorDragData = ( + mimeTypes: ArrayLike, +) => { + return Array.from(mimeTypes).includes(MIME_TYPE); +}; + +export const parseCompositionSelectorDragData = ( + dataTransfer: DragDataTransfer, +): CompositionSelectorDragData | null => { + if (!hasCompositionSelectorDragData(dataTransfer.types)) { + return null; + } + + try { + const parsed = JSON.parse(dataTransfer.getData(MIME_TYPE)) as Record< + string, + unknown + >; + if ( + parsed.type !== 'remotion-composition-selector' || + parsed.version !== 1 || + !isCompositionOrFolder(parsed.item) || + !isSafeSourceFile(parsed.sourceFile) + ) { + return null; + } + + return { + type: 'remotion-composition-selector', + version: 1, + item: parsed.item, + sourceFile: parsed.sourceFile, + }; + } catch { + return null; + } +}; + +export const compositionSelectorDragDataToSymbolicatedStack = ( + dragData: CompositionSelectorDragData, +): SymbolicatedStackFrame | null => { + if (dragData.sourceFile === null) { + return null; + } + + return { + originalColumnNumber: null, + originalFileName: dragData.sourceFile, + originalFunctionName: null, + originalLineNumber: null, + originalScriptCode: null, + }; +}; From d245b5563cb01054ed5d074ca76d4e461edea6b2 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 16:05:15 +0200 Subject: [PATCH 14/17] Update rspack-config.ts --- packages/bundler/src/rspack-config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bundler/src/rspack-config.ts b/packages/bundler/src/rspack-config.ts index 70ae413daf1..781a7c27cb1 100644 --- a/packages/bundler/src/rspack-config.ts +++ b/packages/bundler/src/rspack-config.ts @@ -85,6 +85,7 @@ export const rspackConfig = async ({ experiments: { ...sharedBaseConfig.experiments, ...(environment === 'development' + // Makes the first HMR event faster. ? {incremental: {buildChunkGraph: true}} : {}), }, From 229468c1d4857320f27aaccceeb8560bd88b6be6 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 16:34:42 +0200 Subject: [PATCH 15/17] Expand folders while dragging --- packages/example/e2e/studio.test.mts | 21 +++++ .../components/CompositionSelectorItem.tsx | 94 ++++++++++++++++++- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/packages/example/e2e/studio.test.mts b/packages/example/e2e/studio.test.mts index d9d71e64e9f..145b8bac6b6 100644 --- a/packages/example/e2e/studio.test.mts +++ b/packages/example/e2e/studio.test.mts @@ -1234,6 +1234,27 @@ test.describe('visual mode', () => { ); await page.waitForTimeout(250); expect(fs.readFileSync(rootFile, 'utf8')).toBe(beforeNoOpDrags); + const schemaFolder = page.getByTitle('Schema', {exact: true}); + const schemaComposition = page.getByTitle('schema-test', {exact: true}); + if ((await schemaFolder.getAttribute('aria-expanded')) === 'true') { + await schemaFolder.click(); + } + + await expect(schemaFolder).toHaveAttribute('aria-expanded', 'false'); + await expect(schemaComposition).not.toBeVisible(); + expect( + await dragCompositionSelectorItem({ + page, + sourceTitle: 'AnimatedBarChart', + targetTitle: 'Schema', + position: 'inside', + drop: false, + }), + ).toBe(true); + await page.waitForTimeout(500); + await expect(schemaFolder).toHaveAttribute('aria-expanded', 'false'); + await expect(schemaComposition).toBeVisible({timeout: 500}); + await expect(schemaFolder).toHaveAttribute('aria-expanded', 'true'); expect( await dragCompositionSelectorItem({ diff --git a/packages/studio/src/components/CompositionSelectorItem.tsx b/packages/studio/src/components/CompositionSelectorItem.tsx index 3334877f94d..e4332300885 100644 --- a/packages/studio/src/components/CompositionSelectorItem.tsx +++ b/packages/studio/src/components/CompositionSelectorItem.tsx @@ -8,6 +8,7 @@ import type { import React, { useCallback, useContext, + useEffect, useLayoutEffect, useMemo, useRef, @@ -109,6 +110,8 @@ const reorderLineBase: React.CSSProperties = { zIndex: 1, }; +const folderAutoExpansionDelay = 700; + type DropPosition = 'before' | 'inside' | 'after'; const itemToDescriptor = ( @@ -245,6 +248,27 @@ export const CompositionSelectorItem: React.FC<{ }, [item, currentComposition]); const [isDragging, setIsDragging] = useState(false); const [dropPosition, setDropPosition] = useState(null); + const folderExpansionTimer = useRef(null); + const folderExpansionRequested = useRef(false); + + const cancelFolderExpansion = useCallback(() => { + if (folderExpansionTimer.current !== null) { + window.clearTimeout(folderExpansionTimer.current); + folderExpansionTimer.current = null; + } + }, []); + + useEffect(() => { + return cancelFolderExpansion; + }, [cancelFolderExpansion]); + + const folderIsExpanded = item.type === 'folder' && item.expanded; + useEffect(() => { + if (folderIsExpanded) { + folderExpansionRequested.current = false; + cancelFolderExpansion(); + } + }, [cancelFolderExpansion, folderIsExpanded]); const compositionRowRef = useRef(null); const compositionId = @@ -399,9 +423,10 @@ export const CompositionSelectorItem: React.FC<{ ); const onItemDragEnd = useCallback(() => { activeDragRef.current = null; + cancelFolderExpansion(); setIsDragging(false); setDropPosition(null); - }, [activeDragRef]); + }, [activeDragRef, cancelFolderExpansion]); const getDropPosition = useCallback( (event: DragEvent): DropPosition | null => { @@ -482,6 +507,45 @@ export const CompositionSelectorItem: React.FC<{ ], ); + const scheduleFolderExpansion = useCallback(() => { + if ( + item.type !== 'folder' || + item.expanded || + folderExpansionTimer.current !== null || + folderExpansionRequested.current + ) { + return; + } + + const activeDrag = activeDragRef.current; + if ( + activeDrag === null || + !canMoveIntoFolder({ + activeDrag, + destinationFolderPath: getFolderPath(item), + }) + ) { + return; + } + + folderExpansionTimer.current = window.setTimeout(() => { + folderExpansionTimer.current = null; + const currentActiveDrag = activeDragRef.current; + if ( + currentActiveDrag === null || + !canMoveIntoFolder({ + activeDrag: currentActiveDrag, + destinationFolderPath: getFolderPath(item), + }) + ) { + return; + } + + folderExpansionRequested.current = true; + toggleFolder(item.folderName, item.parentName); + }, folderAutoExpansionDelay); + }, [activeDragRef, item, toggleFolder]); + const onRowDragOver = useCallback( (event: DragEvent) => { if ( @@ -493,22 +557,36 @@ export const CompositionSelectorItem: React.FC<{ const position = getDropPosition(event); if (position === null) { + cancelFolderExpansion(); + setDropPosition(null); event.stopPropagation(); return; } + if (position === 'inside') { + scheduleFolderExpansion(); + } else { + cancelFolderExpansion(); + } + event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = 'move'; clearRootDragHover(); setDropPosition(position); }, - [clearRootDragHover, getDropPosition], + [ + cancelFolderExpansion, + clearRootDragHover, + getDropPosition, + scheduleFolderExpansion, + ], ); const onRowDragLeave = useCallback(() => { + cancelFolderExpansion(); setDropPosition(null); - }, []); + }, [cancelFolderExpansion]); const moveItem = useCallback( async ({ @@ -565,6 +643,7 @@ export const CompositionSelectorItem: React.FC<{ const onRowDrop = useCallback( async (event: DragEvent) => { + cancelFolderExpansion(); const dragData = parseCompositionSelectorDragData(event.dataTransfer); const position = getDropPosition(event); if (dragData === null) { @@ -595,7 +674,13 @@ export const CompositionSelectorItem: React.FC<{ }, }); }, - [clearRootDragHover, getDropPosition, item, moveItem], + [ + cancelFolderExpansion, + clearRootDragHover, + getDropPosition, + item, + moveItem, + ], ); const onFolderChildListDragOver = useCallback( @@ -690,6 +775,7 @@ export const CompositionSelectorItem: React.FC<{ onDrop={onRowDrop} title={item.folderName} role="button" + aria-expanded={item.expanded} > {item.expanded ? ( From 2b489189abb56b53130ade0b8d4a369075989017 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 16:51:53 +0200 Subject: [PATCH 16/17] Address composition reorder review feedback --- packages/example/e2e/studio.test.mts | 148 +++++++++++++++++- packages/studio-codemods/src/recast-mods.ts | 69 ++++---- .../src/test/apply-codemod.test.ts | 45 ++++++ .../src/components/CompositionSelector.tsx | 30 ++-- .../components/CompositionSelectorItem.tsx | 24 +-- .../composition-selector-drag-data.test.ts | 134 ++++++++++++++++ 6 files changed, 393 insertions(+), 57 deletions(-) create mode 100644 packages/studio/src/test/composition-selector-drag-data.test.ts diff --git a/packages/example/e2e/studio.test.mts b/packages/example/e2e/studio.test.mts index 145b8bac6b6..a503312e235 100644 --- a/packages/example/e2e/studio.test.mts +++ b/packages/example/e2e/studio.test.mts @@ -35,12 +35,14 @@ const dragCompositionSelectorItem = async ({ sourceTitle, targetTitle, position, + dropAtPosition, drop, }: { page: Page; sourceTitle: string; targetTitle: string; position: 'before' | 'inside' | 'after'; + dropAtPosition: 'before' | 'inside' | 'after' | null; drop: boolean; }) => { return page.evaluate( @@ -48,6 +50,7 @@ const dragCompositionSelectorItem = async ({ sourceTitle: source, targetTitle: target, position: dropPosition, + dropAtPosition: finalDropPosition, drop: shouldDrop, }) => { const items = Array.from( @@ -72,12 +75,13 @@ const dragCompositionSelectorItem = async ({ }), ); const rect = targetElement.getBoundingClientRect(); - const clientY = - dropPosition === 'before' + const getClientY = (position: 'before' | 'inside' | 'after') => + position === 'before' ? rect.top + 1 - : dropPosition === 'after' + : position === 'after' ? rect.bottom - 1 : rect.top + rect.height / 2; + const clientY = getClientY(dropPosition); const dragOver = new DragEvent('dragover', { bubbles: true, cancelable: true, @@ -86,7 +90,73 @@ const dragCompositionSelectorItem = async ({ }); targetElement.dispatchEvent(dragOver); if (shouldDrop) { + const dropClientY = getClientY(finalDropPosition ?? dropPosition); targetElement.dispatchEvent( + new DragEvent('drop', { + bubbles: true, + cancelable: true, + clientY: dropClientY, + dataTransfer, + }), + ); + sourceElement.dispatchEvent( + new DragEvent('dragend', {bubbles: true, dataTransfer}), + ); + } + + return dragOver.defaultPrevented; + }, + {sourceTitle, targetTitle, position, dropAtPosition, drop}, + ); +}; + +const dragCompositionSelectorItemToRoot = async ({ + page, + sourceTitle, + drop, +}: { + page: Page; + sourceTitle: string; + drop: boolean; +}) => { + return page.evaluate( + ({sourceTitle: source, drop: shouldDrop}) => { + const sourceElement = Array.from( + document.querySelectorAll( + '.__remotion-composition-selector-item', + ), + ).find((element) => element.title === source); + const root = Array.from( + document.querySelectorAll( + '.__remotion-vertical-scrollbar', + ), + ).find((element) => + element.querySelector('.__remotion-composition-selector-item'), + ); + if (!sourceElement || !root) { + throw new Error(`Could not find drag source ${source} or root list`); + } + + const dataTransfer = new DataTransfer(); + sourceElement.dispatchEvent( + new DragEvent('dragstart', { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + const rect = root.getBoundingClientRect(); + root.scrollTop = root.scrollHeight; + const clientY = rect.bottom - 1; + const dragOver = new DragEvent('dragover', { + bubbles: true, + cancelable: true, + clientY, + dataTransfer, + }); + root.dispatchEvent(dragOver); + if (shouldDrop) { + root.dispatchEvent( new DragEvent('drop', { bubbles: true, cancelable: true, @@ -101,7 +171,7 @@ const dragCompositionSelectorItem = async ({ return dragOver.defaultPrevented; }, - {sourceTitle, targetTitle, position, drop}, + {sourceTitle, drop}, ); }; @@ -1217,6 +1287,7 @@ test.describe('visual mode', () => { sourceTitle: firstItemTitle!, targetTitle: firstItemTitle!, position: 'before', + dropAtPosition: null, drop: true, }), ).toBe(false); @@ -1226,6 +1297,7 @@ test.describe('visual mode', () => { sourceTitle: firstItemTitle!, targetTitle: firstItemTitle!, position: 'after', + dropAtPosition: null, drop: true, }), ).toBe(false); @@ -1248,6 +1320,7 @@ test.describe('visual mode', () => { sourceTitle: 'AnimatedBarChart', targetTitle: 'Schema', position: 'inside', + dropAtPosition: null, drop: false, }), ).toBe(true); @@ -1262,6 +1335,7 @@ test.describe('visual mode', () => { sourceTitle: 'AnimatedBarChart', targetTitle: 'Schema', position: 'before', + dropAtPosition: null, drop: false, }), ).toBe(true); @@ -1286,6 +1360,7 @@ test.describe('visual mode', () => { sourceTitle: 'AnimatedBarChart', targetTitle: 'Schema', position: 'before', + dropAtPosition: 'inside', drop: true, }); await expect @@ -1319,6 +1394,7 @@ test.describe('visual mode', () => { sourceTitle: 'AnimatedBarChart', targetTitle: 'Schema', position: 'before', + dropAtPosition: null, drop: true, }), ).toBe(false); @@ -1330,6 +1406,7 @@ test.describe('visual mode', () => { sourceTitle: 'Schema', targetTitle: 'use-current-scale-on-load', position: 'before', + dropAtPosition: null, drop: true, }); await expect @@ -1348,6 +1425,7 @@ test.describe('visual mode', () => { sourceTitle: 'AnimatedBarChart', targetTitle: 'Schema', position: 'inside', + dropAtPosition: null, drop: true, }); await expect @@ -1359,9 +1437,71 @@ test.describe('visual mode', () => { return composition > folderStart && composition < folderEnd; }) .toBe(true); + const afterCompositionNested = fs.readFileSync(rootFile, 'utf8'); + await expect + .poll(async () => { + const nestedCompositionBox = await page + .getByTitle('AnimatedBarChart', {exact: true}) + .boundingBox(); + const schemaCompositionBox = await schemaComposition.boundingBox(); + return ( + nestedCompositionBox !== null && + schemaCompositionBox !== null && + nestedCompositionBox.y > schemaCompositionBox.y + ); + }) + .toBe(true); + + expect( + await dragCompositionSelectorItemToRoot({ + page, + sourceTitle: 'AnimatedBarChart', + drop: false, + }), + ).toBe(true); + const rootReorderLine = page.locator( + '[data-composition-root-reorder-line]', + ); + await expect(rootReorderLine).toBeVisible(); + await expect(rootReorderLine).toHaveCSS('height', '2px'); + await expect(rootReorderLine).toHaveCSS( + 'background-color', + 'rgb(11, 132, 255)', + ); + const rootLineBox = await rootReorderLine.boundingBox(); + const lastItemBox = await page + .locator('.__remotion-composition-selector-item') + .last() + .boundingBox(); + expect(rootLineBox).not.toBeNull(); + expect(lastItemBox).not.toBeNull(); + expect(rootLineBox!.y).toBeGreaterThanOrEqual( + lastItemBox!.y + lastItemBox!.height - 2, + ); + + expect( + await dragCompositionSelectorItemToRoot({ + page, + sourceTitle: 'AnimatedBarChart', + drop: true, + }), + ).toBe(true); + await expect + .poll(() => { + const contents = fs.readFileSync(rootFile, 'utf8'); + const folderStart = contents.indexOf(''); + const folderEnd = contents.indexOf('', folderStart); + const composition = contents.indexOf('id="AnimatedBarChart"'); + return composition > folderEnd; + }) + .toBe(true); const undoButton = page.getByRole('button', {name: /^Undo/}); await undoButton.click(); + await expect + .poll(() => fs.readFileSync(rootFile, 'utf8')) + .toBe(afterCompositionNested); + await undoButton.click(); await expect .poll(() => fs.readFileSync(rootFile, 'utf8')) .toBe(afterFolderReorder); diff --git a/packages/studio-codemods/src/recast-mods.ts b/packages/studio-codemods/src/recast-mods.ts index 8c971a8045d..8d0a9b81216 100644 --- a/packages/studio-codemods/src/recast-mods.ts +++ b/packages/studio-codemods/src/recast-mods.ts @@ -461,44 +461,42 @@ const appendElementToFolder = ({ const appendElementToRoot = ({ element, - file, + returnStatement, }: { element: JSXElement; - file: File; + returnStatement: ReturnStatement; }) => { - let appended = false; - - recast.types.visit(file, { - visitReturnStatement(astPath) { - if (appended) { - return false; - } - - const {argument} = astPath.node; - if (argument?.type !== 'JSXFragment' && argument?.type !== 'JSXElement') { - this.traverse(astPath); - return undefined; - } + const {argument} = returnStatement; + if (argument?.type !== 'JSXFragment' && argument?.type !== 'JSXElement') { + throw new Error('Could not find a root JSX element'); + } - if (argument.type === 'JSXFragment') { - (argument.children as JSXFragment['children']).push( - stripParenthesizedExtra(element), - ); - } else { - astPath.node.argument = wrapInJsxFragment([ - argument as unknown as JSXElement, - element, - ]) as never; - } + if (argument.type === 'JSXFragment') { + (argument.children as JSXFragment['children']).push( + stripParenthesizedExtra(element), + ); + } else { + returnStatement.argument = wrapInJsxFragment([ + argument as unknown as JSXElement, + element, + ]) as never; + } +}; - appended = true; - return false; - }, - }); +const getEnclosingReturnStatement = (path: recast.types.NodePath) => { + let currentPath: recast.types.NodePath | null = path; + while (currentPath !== null) { + if ( + (currentPath.node as unknown as {type?: string} | null)?.type === + 'ReturnStatement' + ) { + return currentPath.node as unknown as ReturnStatement; + } - if (!appended) { - throw new Error('Could not find a root JSX element'); + currentPath = currentPath.parentPath ?? null; } + + throw new Error('Could not find a root JSX element'); }; const getCompositionOrFolderLabel = (item: CompositionOrFolder) => { @@ -713,10 +711,11 @@ const moveCompositionOrFolder = ({ } } + const sourceReturnStatement = getEnclosingReturnStatement(sourceItem.path); deleteJsxElementAtPath(sourceItem.path); const element = stripParenthesizedExtra(sourceItem.node); if (transformation.destination.type === 'root') { - appendElementToRoot({element, file}); + appendElementToRoot({element, returnStatement: sourceReturnStatement}); } else if (transformation.destination.type === 'folder') { appendElementToFolder({ element, @@ -833,9 +832,13 @@ const moveCompositionToFolder = ({ const compositionElement = (sourcePath as recast.types.NodePath) .node as JSXElement; + const sourceReturnStatement = getEnclosingReturnStatement(sourcePath); deleteJsxElementAtPath(sourcePath); if (transformation.folderName === null) { - appendElementToRoot({element: compositionElement, file}); + appendElementToRoot({ + element: compositionElement, + returnStatement: sourceReturnStatement, + }); changesMade.push({description: 'Moved composition to root'}); } else { if (targetFolder === null) { diff --git a/packages/studio-server/src/test/apply-codemod.test.ts b/packages/studio-server/src/test/apply-codemod.test.ts index 669d2b6f53a..4706e0db550 100644 --- a/packages/studio-server/src/test/apply-codemod.test.ts +++ b/packages/studio-server/src/test/apply-codemod.test.ts @@ -155,6 +155,31 @@ export const RemotionRoot: React.FC = () => { }; `; +const folderRootWithEarlierComponentContents = `import React from 'react'; +import {Composition, Folder} from 'remotion'; + +const Component = () => { + return <>
Content
; +}; + +export const RemotionRoot: React.FC = () => { + return ( + <> + + + + + ); +}; +`; + const clearUndoRedoStacks = () => { (getUndoStack() as unknown as unknown[]).length = 0; (getRedoStack() as unknown as unknown[]).length = 0; @@ -983,6 +1008,26 @@ test('moves a composition to root', () => { expect(newContents.match(/id="NestedA"/g)?.length).toBe(1); }); +test('moves a composition to its registration root when another component appears first', () => { + const {changesMade, newContents} = parseAndApplyCodemod({ + input: folderRootWithEarlierComponentContents, + codeMod: { + type: 'move-composition-or-folder', + source: {type: 'composition', compositionId: 'Nested'}, + destination: {type: 'root'}, + }, + }); + + expect(changesMade).toHaveLength(1); + expect(newContents.match(/id="Nested"/g)).toHaveLength(1); + expect(newContents.indexOf('id="Nested"')).toBeGreaterThan( + newContents.indexOf('export const RemotionRoot'), + ); + expect(newContents.indexOf('id="Nested"')).toBeGreaterThan( + newContents.indexOf(''), + ); +}); + test('visually reorders compositions and folders', () => { const compositionBeforeFolder = parseAndApplyCodemod({ input: selfClosingFolderRootContents, diff --git a/packages/studio/src/components/CompositionSelector.tsx b/packages/studio/src/components/CompositionSelector.tsx index 49d090cbe4b..c2a5b5a3aaf 100644 --- a/packages/studio/src/components/CompositionSelector.tsx +++ b/packages/studio/src/components/CompositionSelector.tsx @@ -8,7 +8,7 @@ import React, { } from 'react'; import {Internals} from 'remotion'; import {StudioServerConnectionCtx} from '../helpers/client-id'; -import {BACKGROUND, WHITE_ALPHA_12} from '../helpers/colors'; +import {BACKGROUND, TIMELINE_BLUE} from '../helpers/colors'; import { createFolderTree, sortFolderTreeAlphabetically, @@ -97,6 +97,21 @@ const container: React.CSSProperties = { backgroundColor: BACKGROUND, }; +const list: React.CSSProperties = { + flex: 1, + overflowY: 'auto', + paddingTop: 4, + paddingBottom: 4, + backgroundColor: BACKGROUND, +}; + +const rootReorderLine: React.CSSProperties = { + backgroundColor: TIMELINE_BLUE, + height: 2, + pointerEvents: 'none', + width: '100%', +}; + const autoScrollThreshold = 70; const maxAutoScrollSpeed = 18; @@ -170,16 +185,6 @@ export const CompositionSelector: React.FC = () => { : tree; }, [compositionSortOrder, compositions, folders, foldersExpanded]); - const list: React.CSSProperties = useMemo(() => { - return { - flex: 1, - overflowY: 'auto', - paddingTop: 4, - paddingBottom: 4, - backgroundColor: rootDragHovered ? WHITE_ALPHA_12 : BACKGROUND, - }; - }, [rootDragHovered]); - const toggleFolder = useCallback( (folderName: string, parentName: string | null) => { Internals.compositionSelectorRef.current?.toggleFolder( @@ -409,6 +414,9 @@ export const CompositionSelector: React.FC = () => { /> ); })} + {rootDragHovered ? ( +
+ ) : null}
); diff --git a/packages/studio/src/components/CompositionSelectorItem.tsx b/packages/studio/src/components/CompositionSelectorItem.tsx index e4332300885..914761f4b9d 100644 --- a/packages/studio/src/components/CompositionSelectorItem.tsx +++ b/packages/studio/src/components/CompositionSelectorItem.tsx @@ -248,6 +248,7 @@ export const CompositionSelectorItem: React.FC<{ }, [item, currentComposition]); const [isDragging, setIsDragging] = useState(false); const [dropPosition, setDropPosition] = useState(null); + const dropPositionRef = useRef(null); const folderExpansionTimer = useRef(null); const folderExpansionRequested = useRef(false); @@ -257,6 +258,10 @@ export const CompositionSelectorItem: React.FC<{ folderExpansionTimer.current = null; } }, []); + const updateDropPosition = useCallback((position: DropPosition | null) => { + dropPositionRef.current = position; + setDropPosition(position); + }, []); useEffect(() => { return cancelFolderExpansion; @@ -425,8 +430,8 @@ export const CompositionSelectorItem: React.FC<{ activeDragRef.current = null; cancelFolderExpansion(); setIsDragging(false); - setDropPosition(null); - }, [activeDragRef, cancelFolderExpansion]); + updateDropPosition(null); + }, [activeDragRef, cancelFolderExpansion, updateDropPosition]); const getDropPosition = useCallback( (event: DragEvent): DropPosition | null => { @@ -558,7 +563,7 @@ export const CompositionSelectorItem: React.FC<{ const position = getDropPosition(event); if (position === null) { cancelFolderExpansion(); - setDropPosition(null); + updateDropPosition(null); event.stopPropagation(); return; } @@ -573,20 +578,21 @@ export const CompositionSelectorItem: React.FC<{ event.stopPropagation(); event.dataTransfer.dropEffect = 'move'; clearRootDragHover(); - setDropPosition(position); + updateDropPosition(position); }, [ cancelFolderExpansion, clearRootDragHover, getDropPosition, scheduleFolderExpansion, + updateDropPosition, ], ); const onRowDragLeave = useCallback(() => { cancelFolderExpansion(); - setDropPosition(null); - }, [cancelFolderExpansion]); + updateDropPosition(null); + }, [cancelFolderExpansion, updateDropPosition]); const moveItem = useCallback( async ({ @@ -645,7 +651,7 @@ export const CompositionSelectorItem: React.FC<{ async (event: DragEvent) => { cancelFolderExpansion(); const dragData = parseCompositionSelectorDragData(event.dataTransfer); - const position = getDropPosition(event); + const position = dropPositionRef.current; if (dragData === null) { return; } @@ -658,7 +664,7 @@ export const CompositionSelectorItem: React.FC<{ event.preventDefault(); event.stopPropagation(); clearRootDragHover(); - setDropPosition(null); + updateDropPosition(null); await moveItem({ dragData, destination: @@ -677,9 +683,9 @@ export const CompositionSelectorItem: React.FC<{ [ cancelFolderExpansion, clearRootDragHover, - getDropPosition, item, moveItem, + updateDropPosition, ], ); diff --git a/packages/studio/src/test/composition-selector-drag-data.test.ts b/packages/studio/src/test/composition-selector-drag-data.test.ts new file mode 100644 index 00000000000..bb23a331413 --- /dev/null +++ b/packages/studio/src/test/composition-selector-drag-data.test.ts @@ -0,0 +1,134 @@ +import {expect, test} from 'bun:test'; +import { + compositionSelectorDragDataToSymbolicatedStack, + hasCompositionSelectorDragData, + makeCompositionSelectorDragData, + parseCompositionSelectorDragData, +} from '../components/composition-selector-drag-data'; + +const composition = makeCompositionSelectorDragData({ + item: {type: 'composition', compositionId: 'MyVideo'}, + sourceFile: 'src/Root.tsx', +}); + +const folder = makeCompositionSelectorDragData({ + item: {type: 'folder', folderName: 'Nested', parentName: 'Parent'}, + sourceFile: null, +}); + +const makeDataTransfer = ({ + mimeType, + payload, +}: { + readonly mimeType: string; + readonly payload: string; +}) => ({ + types: ['text/plain', mimeType], + getData: (requestedMimeType: string) => + requestedMimeType === mimeType ? payload : '', +}); + +test('constructs and parses composition selector drag data', () => { + expect(composition.mimeType).toBe( + 'application/remotion-composition-selector-reorder', + ); + expect(hasCompositionSelectorDragData(['text/plain'])).toBe(false); + expect( + hasCompositionSelectorDragData(['text/plain', composition.mimeType]), + ).toBe(true); + expect( + parseCompositionSelectorDragData(makeDataTransfer(composition)), + ).toEqual(composition.data); + expect(parseCompositionSelectorDragData(makeDataTransfer(folder))).toEqual( + folder.data, + ); +}); + +test('rejects malformed composition selector drag data', () => { + expect( + parseCompositionSelectorDragData({ + types: ['text/plain'], + getData: () => composition.payload, + }), + ).toBe(null); + + const validComposition = { + type: 'remotion-composition-selector', + version: 1, + item: {type: 'composition', compositionId: 'MyVideo'}, + sourceFile: 'src/Root.tsx', + }; + const validFolder = { + ...validComposition, + item: {type: 'folder', folderName: 'Nested', parentName: 'Parent'}, + }; + + for (const payload of [ + '', + '{', + JSON.stringify({...validComposition, type: 'other'}), + JSON.stringify({...validComposition, version: 2}), + JSON.stringify({...validComposition, item: null}), + JSON.stringify({ + ...validComposition, + item: {type: 'other', compositionId: 'MyVideo'}, + }), + JSON.stringify({ + ...validComposition, + item: {type: 'composition', compositionId: ''}, + }), + JSON.stringify({ + ...validComposition, + item: {type: 'composition', compositionId: 'a'.repeat(501)}, + }), + JSON.stringify({ + ...validComposition, + item: {type: 'composition', compositionId: 'Invalid\0id'}, + }), + JSON.stringify({ + ...validFolder, + item: {...validFolder.item, folderName: ''}, + }), + JSON.stringify({ + ...validFolder, + item: {...validFolder.item, folderName: 'a'.repeat(501)}, + }), + JSON.stringify({ + ...validFolder, + item: {...validFolder.item, parentName: 'a'.repeat(2001)}, + }), + JSON.stringify({ + ...validFolder, + item: {type: 'folder', folderName: 'Nested'}, + }), + JSON.stringify({...validComposition, sourceFile: undefined}), + JSON.stringify({...validComposition, sourceFile: ''}), + JSON.stringify({...validComposition, sourceFile: 'a'.repeat(2001)}), + JSON.stringify({...validComposition, sourceFile: '/src/Root.tsx'}), + JSON.stringify({...validComposition, sourceFile: '../Root.tsx'}), + JSON.stringify({...validComposition, sourceFile: 'src/../Root.tsx'}), + JSON.stringify({...validComposition, sourceFile: 'src\\Root.tsx'}), + JSON.stringify({...validComposition, sourceFile: 'src/Root\0.tsx'}), + ]) { + expect( + parseCompositionSelectorDragData( + makeDataTransfer({mimeType: composition.mimeType, payload}), + ), + ).toBe(null); + } +}); + +test('converts composition selector source locations to symbolicated stacks', () => { + expect( + compositionSelectorDragDataToSymbolicatedStack(composition.data), + ).toEqual({ + originalColumnNumber: null, + originalFileName: 'src/Root.tsx', + originalFunctionName: null, + originalLineNumber: null, + originalScriptCode: null, + }); + expect(compositionSelectorDragDataToSymbolicatedStack(folder.data)).toBe( + null, + ); +}); From 415e034e20f5b6fe440983cdb42f6a9aa02205fa Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Tue, 1 Sep 2026 17:00:01 +0200 Subject: [PATCH 17/17] Fix Rspack config formatting --- packages/bundler/src/rspack-config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bundler/src/rspack-config.ts b/packages/bundler/src/rspack-config.ts index 781a7c27cb1..a7ead49ec83 100644 --- a/packages/bundler/src/rspack-config.ts +++ b/packages/bundler/src/rspack-config.ts @@ -85,8 +85,8 @@ export const rspackConfig = async ({ experiments: { ...sharedBaseConfig.experiments, ...(environment === 'development' - // Makes the first HMR event faster. - ? {incremental: {buildChunkGraph: true}} + ? // Makes the first HMR event faster. + {incremental: {buildChunkGraph: true}} : {}), }, // Remove once https://github.com/huggingface/transformers.js/issues/1759 is resolved.