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/browser-studio/e2e/browser-studio.test.ts b/packages/browser-studio/e2e/browser-studio.test.ts
index b7121d0a970..b59f9273b70 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,9 +343,9 @@ 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}),
+ studio.getByText('Render MyComp in the browser', {exact: true}),
).toBeVisible();
await studio.locator('body').press('Escape');
await expect
diff --git a/packages/browser-studio/src/browser-studio-operations.ts b/packages/browser-studio/src/browser-studio-operations.ts
index 37ac4df0a6d..e261a8c8105 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/bundler/src/rspack-config.ts b/packages/bundler/src/rspack-config.ts
index 15fc1fdd37f..a7ead49ec83 100644
--- a/packages/bundler/src/rspack-config.ts
+++ b/packages/bundler/src/rspack-config.ts
@@ -79,8 +79,16 @@ export const rspackConfig = async ({
},
};
+ const sharedBaseConfig = getBaseConfig(environment, poll);
const baseConfig = {
- ...getBaseConfig(environment, poll),
+ ...sharedBaseConfig,
+ experiments: {
+ ...sharedBaseConfig.experiments,
+ ...(environment === 'development'
+ ? // Makes the first HMR event faster.
+ {incremental: {buildChunkGraph: true}}
+ : {}),
+ },
// Remove once https://github.com/huggingface/transformers.js/issues/1759 is resolved.
ignoreWarnings: [
{
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/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
new file mode 100644
index 00000000000..1737938fbd0
--- /dev/null
+++ b/packages/docs/docs/canvas-capture/index.mdx
@@ -0,0 +1,18 @@
+---
+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.
+
+It can record content at a higher resolution than the native display resolution.
+
+Perfect for product demos showing closeups of UI.
+
+## Table of contents
+
+
diff --git a/packages/docs/docs/canvas-capture/installation.mdx b/packages/docs/docs/canvas-capture/installation.mdx
new file mode 100644
index 00000000000..2bfd8e1c73c
--- /dev/null
+++ b/packages/docs/docs/canvas-capture/installation.mdx
@@ -0,0 +1,48 @@
+---
+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.
+
+## Download the extension
+
+Clone the built extension to a durable location:
+
+```bash title="Terminal"
+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 script included with Canvas Capture:
+
+```bash title="Terminal"
+"$HOME/Applications/Remotion Canvas Capture Extension/install-browser.sh"
+```
+
+This browser does not receive security updates. Only use it with websites you trust.
+
+## Launch the browser
+
+Open Google Chrome for Testing from Launchpad.
+
+Open `chrome://flags`, search for Canvas Draw Element, set it to Enabled and select Relaunch.
+
+## Load the extension
+
+Open `chrome://extensions`, enable Developer mode and select Load unpacked.
+
+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.
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/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 00000000000..c72b8b7a649
Binary files /dev/null and b/packages/docs/static/generated/articles-docs-canvas-capture-index.png differ
diff --git a/packages/docs/static/generated/articles-docs-canvas-capture-installation.png b/packages/docs/static/generated/articles-docs-canvas-capture-installation.png
new file mode 100644
index 00000000000..0e9dbed2195
Binary files /dev/null and b/packages/docs/static/generated/articles-docs-canvas-capture-installation.png differ
diff --git a/packages/docs/vercel.ts b/packages/docs/vercel.ts
index 9420bafd18a..e707c8ad116 100644
--- a/packages/docs/vercel.ts
+++ b/packages/docs/vercel.ts
@@ -377,6 +377,9 @@ export const config: VercelConfig = {
permanent: false,
}),
routes.redirect('/recorder', '/docs/recorder', {permanent: false}),
+ routes.redirect('/canvas-capture', '/docs/canvas-capture', {
+ permanent: false,
+ }),
routes.redirect('/install-whisper-cpp', '/docs/install-whisper-cpp', {
permanent: false,
}),
diff --git a/packages/example/e2e/studio.test.mts b/packages/example/e2e/studio.test.mts
index 7651758513c..a503312e235 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,151 @@ const outlineSelectionCasesFile = path.join(
'OutlineSelectionCases.tsx',
);
+const dragCompositionSelectorItem = async ({
+ page,
+ 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(
+ ({
+ sourceTitle: source,
+ targetTitle: target,
+ position: dropPosition,
+ dropAtPosition: finalDropPosition,
+ 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 getClientY = (position: 'before' | 'inside' | 'after') =>
+ position === 'before'
+ ? rect.top + 1
+ : position === 'after'
+ ? rect.bottom - 1
+ : rect.top + rect.height / 2;
+ const clientY = getClientY(dropPosition);
+ const dragOver = new DragEvent('dragover', {
+ bubbles: true,
+ cancelable: true,
+ clientY,
+ dataTransfer,
+ });
+ 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,
+ clientY,
+ dataTransfer,
+ }),
+ );
+ sourceElement.dispatchEvent(
+ new DragEvent('dragend', {bubbles: true, dataTransfer}),
+ );
+ }
+
+ return dragOver.defaultPrevented;
+ },
+ {sourceTitle, drop},
+ );
+};
+
const dropAssetOnCanvas = async ({
assetPath,
durationInSeconds,
@@ -1118,6 +1264,257 @@ 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',
+ dropAtPosition: null,
+ drop: true,
+ }),
+ ).toBe(false);
+ expect(
+ await dragCompositionSelectorItem({
+ page,
+ sourceTitle: firstItemTitle!,
+ targetTitle: firstItemTitle!,
+ position: 'after',
+ dropAtPosition: null,
+ 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);
+ 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',
+ dropAtPosition: null,
+ 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({
+ page,
+ sourceTitle: 'AnimatedBarChart',
+ targetTitle: 'Schema',
+ position: 'before',
+ dropAtPosition: null,
+ 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',
+ dropAtPosition: 'inside',
+ 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',
+ dropAtPosition: null,
+ 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',
+ dropAtPosition: null,
+ 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',
+ dropAtPosition: null,
+ 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 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);
+ 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/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(),
])),
diff --git a/packages/studio-codemods/src/recast-mods.ts b/packages/studio-codemods/src/recast-mods.ts
index f5bedadaed5..8d0a9b81216 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,49 +456,289 @@ const appendCompositionToFolder = ({
type: 'JSXClosingElement',
name: folderElement.openingElement.name,
};
- folderElement.children.push(stripParenthesizedExtra(compositionElement));
+ folderElement.children.push(stripParenthesizedExtra(element));
+};
+
+const appendElementToRoot = ({
+ element,
+ returnStatement,
+}: {
+ element: JSXElement;
+ returnStatement: ReturnStatement;
+}) => {
+ 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 {
+ returnStatement.argument = wrapInJsxFragment([
+ argument as unknown as JSXElement,
+ element,
+ ]) as never;
+ }
+};
+
+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;
+ }
+
+ currentPath = currentPath.parentPath ?? null;
+ }
+
+ throw new Error('Could not find a root JSX element');
+};
+
+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 appendCompositionToRoot = ({
- compositionElement,
+const moveCompositionOrFolder = ({
file,
+ transformation,
+ changesMade,
}: {
- compositionElement: JSXElement;
file: File;
-}) => {
- let appended = false;
+ transformation: Extract;
+ changesMade: Change[];
+}): ApplyCodeModReturnType => {
+ type LocatedItem = {
+ node: JSXElement;
+ parent: JSXElement | JSXFragment;
+ parentFolderName: string | null;
+ path: recast.types.NodePath;
+ };
- recast.types.visit(file, {
- visitReturnStatement(astPath) {
- if (appended) {
- return false;
- }
+ 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 {argument} = astPath.node;
- if (argument?.type !== 'JSXFragment' && argument?.type !== 'JSXElement') {
- this.traverse(astPath);
- return undefined;
- }
+ 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);
+ }
- if (argument.type === 'JSXFragment') {
- (argument.children as JSXFragment['children']).push(
- stripParenthesizedExtra(compositionElement),
+ for (let index = 0; index < node.children.length; index++) {
+ if (node.children[index].type === 'JSXElement') {
+ visitJsxElement(
+ astPath.get('children', index) as recast.types.NodePath,
);
- } else {
- astPath.node.argument = wrapInJsxFragment([
- argument as unknown as JSXElement,
- compositionElement,
- ]) as never;
}
+ }
+
+ if (folderName !== null) {
+ folderStack.pop();
+ }
+ };
- appended = true;
+ recast.types.visit(file, {
+ visitJSXElement(astPath) {
+ visitJsxElement(astPath as unknown as recast.types.NodePath);
return false;
},
});
- if (!appended) {
- throw new Error('Could not find a root JSX element');
+ 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`,
+ );
+ }
}
+
+ const sourceReturnStatement = getEnclosingReturnStatement(sourceItem.path);
+ deleteJsxElementAtPath(sourceItem.path);
+ const element = stripParenthesizedExtra(sourceItem.node);
+ if (transformation.destination.type === 'root') {
+ appendElementToRoot({element, returnStatement: sourceReturnStatement});
+ } 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 = ({
@@ -584,17 +832,21 @@ const moveCompositionToFolder = ({
const compositionElement = (sourcePath as recast.types.NodePath)
.node as JSXElement;
+ const sourceReturnStatement = getEnclosingReturnStatement(sourcePath);
deleteJsxElementAtPath(sourcePath);
if (transformation.folderName === null) {
- appendCompositionToRoot({compositionElement, file});
+ appendElementToRoot({
+ element: compositionElement,
+ returnStatement: sourceReturnStatement,
+ });
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..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;
@@ -232,6 +257,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 +1008,126 @@ 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,
+ 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/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/CompositionSelector.tsx b/packages/studio/src/components/CompositionSelector.tsx
index e559e9978e2..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,
@@ -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';
@@ -96,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;
@@ -153,6 +169,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);
@@ -168,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(
@@ -243,7 +250,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,8 +276,16 @@ 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;
}
@@ -301,8 +316,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 +334,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 +343,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 +365,7 @@ export const CompositionSelector: React.FC = () => {
);
}
},
- [compositions, stopCompositionListAutoScroll],
+ [stopCompositionListAutoScroll],
);
return (
@@ -376,7 +391,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]}
/>
);
})}
+ {rootDragHovered ? (
+
+ ) : null}
);
diff --git a/packages/studio/src/components/CompositionSelectorItem.tsx b/packages/studio/src/components/CompositionSelectorItem.tsx
index cfa071d9f28..914761f4b9d 100644
--- a/packages/studio/src/components/CompositionSelectorItem.tsx
+++ b/packages/studio/src/components/CompositionSelectorItem.tsx
@@ -1,7 +1,14 @@
-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,
+ useEffect,
useLayoutEffect,
useMemo,
useRef,
@@ -12,6 +19,7 @@ import {StudioServerConnectionCtx} from '../helpers/client-id';
import {
CURRENT_COLOR,
LIGHT_TEXT,
+ TIMELINE_BLUE,
TRANSPARENT,
WHITE,
WHITE_ALPHA_06,
@@ -29,13 +37,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 +96,100 @@ 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,
+};
+
+const folderAutoExpansionDelay = 700;
+
+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 +219,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 +233,11 @@ export const CompositionSelectorItem: React.FC<{
selectComposition,
toggleFolder,
clearRootDragHover,
+ canReorder,
+ activeDragRef,
+ parentFolderPath,
+ previousSibling,
+ nextSibling,
}) => {
const selected = useMemo(() => {
if (item.type === 'composition') {
@@ -132,7 +247,33 @@ 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 dropPositionRef = useRef(null);
+ const folderExpansionTimer = useRef(null);
+ const folderExpansionRequested = useRef(false);
+
+ const cancelFolderExpansion = useCallback(() => {
+ if (folderExpansionTimer.current !== null) {
+ window.clearTimeout(folderExpansionTimer.current);
+ folderExpansionTimer.current = null;
+ }
+ }, []);
+ const updateDropPosition = useCallback((position: DropPosition | null) => {
+ dropPositionRef.current = position;
+ setDropPosition(position);
+ }, []);
+
+ 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 =
@@ -150,22 +291,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 +387,240 @@ 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;
+ cancelFolderExpansion();
setIsDragging(false);
- }, []);
+ updateDropPosition(null);
+ }, [activeDragRef, cancelFolderExpansion, updateDropPosition]);
+
+ const getDropPosition = useCallback(
+ (event: DragEvent): DropPosition | null => {
+ const activeDrag = activeDragRef.current;
+ if (activeDrag === null) {
+ return null;
+ }
+
+ if (!canReorder) {
+ if (item.type !== 'folder') {
+ return null;
+ }
+
+ 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 onFolderDragOver = 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();
- setDragHovered(true);
+ 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 onFolderDragLeave = useCallback(() => {
- setDragHovered(false);
- }, []);
+ const scheduleFolderExpansion = useCallback(() => {
+ if (
+ item.type !== 'folder' ||
+ item.expanded ||
+ folderExpansionTimer.current !== null ||
+ folderExpansionRequested.current
+ ) {
+ return;
+ }
- const onFolderChildListDragOver = useCallback(
- (event: DragEvent) => {
+ 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 (
- item.type !== 'folder' ||
- window.remotion_isReadOnlyStudio ||
- getCompositionDragPreviewMetadata(event.dataTransfer.types) === null
+ currentActiveDrag === null ||
+ !canMoveIntoFolder({
+ activeDrag: currentActiveDrag,
+ destinationFolderPath: getFolderPath(item),
+ })
) {
return;
}
- event.preventDefault();
- event.stopPropagation();
- event.dataTransfer.dropEffect = 'move';
- clearRootDragHover();
- },
- [clearRootDragHover, item],
- );
+ folderExpansionRequested.current = true;
+ toggleFolder(item.folderName, item.parentName);
+ }, folderAutoExpansionDelay);
+ }, [activeDragRef, item, toggleFolder]);
- 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) {
+ cancelFolderExpansion();
+ updateDropPosition(null);
+ event.stopPropagation();
return;
}
+ if (position === 'inside') {
+ scheduleFolderExpansion();
+ } else {
+ cancelFolderExpansion();
+ }
+
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;
- }
+ updateDropPosition(position);
+ },
+ [
+ cancelFolderExpansion,
+ clearRootDragHover,
+ getDropPosition,
+ scheduleFolderExpansion,
+ updateDropPosition,
+ ],
+ );
- const notification = showNotification(
- `Moving ${compositionDragData.compositionId}...`,
- null,
- );
- const controller = new AbortController();
+ const onRowDragLeave = useCallback(() => {
+ cancelFolderExpansion();
+ updateDropPosition(null);
+ }, [cancelFolderExpansion, updateDropPosition]);
+ 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 +629,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 +644,171 @@ export const CompositionSelectorItem: React.FC<{
);
}
},
- [clearRootDragHover, item, toggleFolder],
+ [item, toggleFolder],
+ );
+
+ const onRowDrop = useCallback(
+ async (event: DragEvent) => {
+ cancelFolderExpansion();
+ const dragData = parseCompositionSelectorDragData(event.dataTransfer);
+ const position = dropPositionRef.current;
+ if (dragData === null) {
+ return;
+ }
+
+ if (position === null) {
+ event.stopPropagation();
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ clearRootDragHover();
+ updateDropPosition(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),
+ },
+ });
+ },
+ [
+ cancelFolderExpansion,
+ clearRootDragHover,
+ item,
+ moveItem,
+ updateDropPosition,
+ ],
+ );
+
+ 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 +839,49 @@ export const CompositionSelectorItem: React.FC<{
return (
-
-
-
-
- {item.composition.id}
-
-
-
-
-
+
);
};
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;
}
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/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 (
-
+
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/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,
+ };
+};
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,
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,
+ );
+});
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,
+ );
+});