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}
- - - -
-
+
+ {reorderLineStyle ? ( + ); }; 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, + ); +});