From 7158f9b746037b7632c363e7cf93e75bfa35d8b5 Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Wed, 2 Sep 2026 14:51:22 +0200 Subject: [PATCH 01/13] Simplify Element install confirmation dialog --- .../browser-studio/e2e/browser-studio.test.ts | 13 +- .../docs/docs/studio-protocol/security.mdx | 2 +- packages/example/e2e/studio-protocol.test.mts | 14 +- packages/studio/src/components/Canvas.tsx | 14 +- .../components/ElementInstallConfirmation.tsx | 154 +++++++----------- .../studio/src/components/ModalHeader.tsx | 6 +- packages/studio/src/state/modals.ts | 2 - 7 files changed, 81 insertions(+), 124 deletions(-) diff --git a/packages/browser-studio/e2e/browser-studio.test.ts b/packages/browser-studio/e2e/browser-studio.test.ts index b59f9273b70..5bf8f58be39 100644 --- a/packages/browser-studio/e2e/browser-studio.test.ts +++ b/packages/browser-studio/e2e/browser-studio.test.ts @@ -1017,16 +1017,17 @@ export const BrowserElement = () => await canvas.dispatchEvent('drop', coordinates); await expect( - studio.getByText('Install Element', {exact: true}), + studio.getByText('Install Browser Element', {exact: true}), ).toBeVisible(); await expect( studio.getByText('Unverified drag-and-drop payload'), ).toBeVisible(); await expect( - studio.getByText( - 'Dependencies are resolved in the browser; package lifecycle scripts do not run.', - ), - ).toHaveCount(0); + studio.getByText('Packages to install', {exact: true}), + ).toBeVisible(); + await expect( + studio.getByText('@remotion/shapes', {exact: true}), + ).toBeVisible(); await studio.getByRole('button', {name: /^Install/}).click(); await expect @@ -1099,7 +1100,7 @@ export const LinkedElement = () => ; await expect(studio.getByTitle('/project').getByText('MyComp')).toBeVisible(); await expect( - studio.getByText('Install Element', {exact: true}), + studio.getByText('Install Linked Element', {exact: true}), ).toBeVisible(); await expect(page).toHaveTitle( '📦 Install Linked Element - Remotion Studio', diff --git a/packages/docs/docs/studio-protocol/security.mdx b/packages/docs/docs/studio-protocol/security.mdx index f3f817331ee..4df83b735dc 100644 --- a/packages/docs/docs/studio-protocol/security.mdx +++ b/packages/docs/docs/studio-protocol/security.mdx @@ -19,7 +19,7 @@ Studio reflects only the requesting allowed origin in CORS. It does not use wild Elements delivered using [`setStudioDragData()`](/docs/studio-protocol/set-studio-drag-data) or [`installInStudio()`](/docs/studio-protocol/install-in-studio) require confirmation in Studio. A successful `installInStudio()` result only means the request reached Studio and is awaiting confirmation. -Before confirming, Studio shows the target composition and destination file, source code, and every declared dependency with its installation status. For an installation request, Studio also shows the requesting website. Drag-and-drop data has no reliable website provenance and is labeled as unverified. Source code and package lifecycle scripts run with the project's file and network access. +Before confirming, Studio shows the requesting source, destination choice, source code, packages that will be installed, and whether an existing Element source file will be replaced. Drag-and-drop data has no reliable website provenance and is labeled as unverified. Installed source code runs with the project's file and network access. Package lifecycle scripts may also run when packages are installed. Declining the confirmation does not write source files or install packages. diff --git a/packages/example/e2e/studio-protocol.test.mts b/packages/example/e2e/studio-protocol.test.mts index 91362523d2d..fafd07ad1ba 100644 --- a/packages/example/e2e/studio-protocol.test.mts +++ b/packages/example/e2e/studio-protocol.test.mts @@ -356,10 +356,16 @@ const CloseupPlaceholder = () => { await installInStudio.click(); const dialog = studioPage.getByRole('dialog'); - await expect(dialog.getByText('Install Element')).toBeVisible(); - await expect(dialog.getByText(/Protocol Element.*MyComp/)).toBeVisible(); + await expect( + dialog.getByText('Install Protocol Element', {exact: true}), + ).toBeVisible(); + await expect( + dialog.getByRole('button', {name: 'Current composition'}), + ).toHaveAttribute('aria-pressed', 'true'); await expect(dialog.getByText(senderUrl, {exact: true})).toBeVisible(); - await expect(decoyStudioPage.getByText('Install Element')).toHaveCount(0); + await expect( + decoyStudioPage.getByText('Install Protocol Element', {exact: true}), + ).toHaveCount(0); await expect(elementsIframe).toHaveCount(0); expect(studioProtocolRequests).toEqual([]); await dialog.getByRole('button', {name: /Install/}).click(); @@ -391,7 +397,7 @@ const CloseupPlaceholder = () => { await studioPage.bringToFront(); const newCompositionDialog = studioPage.getByRole('dialog'); await expect( - newCompositionDialog.getByText('Install Element'), + newCompositionDialog.getByText('Install Protocol Element', {exact: true}), ).toBeVisible(); await newCompositionDialog .getByRole('button', {name: 'New composition'}) diff --git a/packages/studio/src/components/Canvas.tsx b/packages/studio/src/components/Canvas.tsx index 9d03ebb6695..f41e4819bfe 100644 --- a/packages/studio/src/components/Canvas.tsx +++ b/packages/studio/src/components/Canvas.tsx @@ -89,8 +89,6 @@ import {useSvgImportDialog} from './SvgImportDialog'; import {getCurrentFrame} from './Timeline/imperative-state'; import {useResolvedStack} from './Timeline/use-resolved-stack'; -const elementInstallDependencyIgnoreList = ['react', 'react-dom', 'remotion']; - const getCanvasDragPreviewMetadata = (mimeTypes: ArrayLike) => { const composition = getCompositionDragPreviewMetadata(mimeTypes); if (composition !== null) { @@ -1044,20 +1042,11 @@ export const Canvas: React.FC<{ ).values(), ); const missingPackages = getMissingPackages(declaredDependencies).map( - (dependency) => dependency.name, - ); - const ignoredDependencies = declaredDependencies.filter( (dependency) => - elementInstallDependencyIgnoreList.includes(dependency.name) && - !missingPackages.includes(dependency.name), - ); - const dependenciesToReview = declaredDependencies - .filter((dependency) => !ignoredDependencies.includes(dependency)) - .map((dependency) => dependency.version === null ? dependency.name : `${dependency.name}@${dependency.version}`, - ); + ); const {source} = activeElementInstallRequest; const sourceLabel = source.type === 'studio-protocol' @@ -1074,7 +1063,6 @@ export const Canvas: React.FC<{ setSelectedModal({ type: 'element-install', currentPlan, - dependenciesToReview, missingPackages, newPlan: newPreflight.plan, onClose: closeElementInstallDialog, diff --git a/packages/studio/src/components/ElementInstallConfirmation.tsx b/packages/studio/src/components/ElementInstallConfirmation.tsx index 10d9957aa69..9f140175c2e 100644 --- a/packages/studio/src/components/ElementInstallConfirmation.tsx +++ b/packages/studio/src/components/ElementInstallConfirmation.tsx @@ -68,7 +68,7 @@ const container: React.CSSProperties = { const dialogContent: React.CSSProperties = { ...container, padding: 16, - width: 'min(640px, calc(100vw - 40px))', + width: 'min(600px, calc(100vw - 40px))', maxHeight: 'min(720px, calc(100vh - 140px))', overflowY: 'auto', }; @@ -121,8 +121,20 @@ const metadataDescriptionStyle: React.CSSProperties = { overflowWrap: 'anywhere', }; -const unverifiedSourceStyle: React.CSSProperties = { +const requestSourceStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + gap: 16, + margin: 0, +}; + +const requestSourceDescriptionStyle: React.CSSProperties = { ...metadataDescriptionStyle, + textAlign: 'right', +}; + +const unverifiedRequestSourceStyle: React.CSSProperties = { + ...requestSourceDescriptionStyle, color: WARNING_COLOR, }; @@ -151,35 +163,13 @@ const dependencyListStyle: React.CSSProperties = { listStyleType: 'none', }; -const dependencyRowStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'baseline', - justifyContent: 'space-between', - gap: 16, - minWidth: 0, -}; - const dependencyNameStyle: React.CSSProperties = { minWidth: 0, color: WHITE, - fontFamily: 'monospace', - fontSize: 13, - lineHeight: 1.5, - overflowWrap: 'anywhere', -}; - -const dependencyInstallStatusStyle: React.CSSProperties = { - flexShrink: 0, - color: WARNING_COLOR, fontFamily: 'sans-serif', fontSize: 13, - fontWeight: 500, lineHeight: 1.5, -}; - -const dependencyInstalledStatusStyle: React.CSSProperties = { - ...dependencyInstallStatusStyle, - color: LIGHT_TEXT, + overflowWrap: 'anywhere', }; const warningStyle: React.CSSProperties = { @@ -207,6 +197,11 @@ const warningDescriptionStyle: React.CSSProperties = { lineHeight: 1.5, }; +const installWarningDescriptionStyle: React.CSSProperties = { + ...warningDescriptionStyle, + color: WHITE, +}; + const browseElementsStyle: React.CSSProperties = { color: 'inherit', fontFamily: 'inherit', @@ -223,7 +218,7 @@ const sourceDetailsStyle: React.CSSProperties = { }; const sourceSummaryStyle: React.CSSProperties = { - cursor: 'pointer', + cursor: 'default', color: WHITE, fontFamily: 'sans-serif', fontSize: 13, @@ -373,7 +368,6 @@ export const ElementInstallConfirmation: React.FC<{ }> = ({state}) => { const { currentPlan, - dependenciesToReview, missingPackages, newPlan, onClose, @@ -646,11 +640,27 @@ export const ElementInstallConfirmation: React.FC<{ return createPortal( - +
+
+
From
+
+ {sourceLabel} +
+
+
-
Add to
+
Destination
-

+

Studio could not find a safe place in “{request.compositionId}” to insert the Element. Install it into a new composition instead. @@ -721,88 +731,38 @@ export const ElementInstallConfirmation: React.FC<{ ) : null} -

-
-
Element
-
- {request.element.displayName} -
-
-
-
Request source
-
- {sourceLabel} -
-
-
-
Composition
-
- - {mode === 'current-composition' - ? request.compositionId - : newCompositionValues.id} - -
-
-
-
Destination
-
- - {selectedPlan?.filePath ?? 'Reviewing destination…'} - -
-
- {selectedPlan?.expectedFileState.exists ? ( -
-
File change
-
Replace existing source file
-
- ) : null} -
+ {selectedPlan?.expectedFileState.exists ? ( +

+ This will replace the existing Element source file. +

+ ) : null} - {dependenciesToReview.length > 0 ? ( + {missingPackages.length > 0 ? (

- Dependencies + Packages to install

    - {dependenciesToReview.map((packageName) => { - const willInstall = missingPackages.includes(packageName); - return ( -
  • -
    {packageName}
    -
    - {willInstall ? 'Will be installed' : 'Installed'} -
    -
  • - ); - })} + {missingPackages.map((packageName) => ( +
  • + {packageName} +
  • + ))}
) : null}
-

- This adds executable source code to your project. - {usesBrowserDependencyResolution +

+ This adds executable source code to your project, with access to + your files and the network. + {usesBrowserDependencyResolution || missingPackages.length === 0 ? null - : ' Package lifecycle scripts may also run during installation, with access to your files and the network.'} + : ' Package lifecycle scripts may also run during installation.'}

diff --git a/packages/studio/src/components/ModalHeader.tsx b/packages/studio/src/components/ModalHeader.tsx index b048c1de07e..5a89176fc5c 100644 --- a/packages/studio/src/components/ModalHeader.tsx +++ b/packages/studio/src/components/ModalHeader.tsx @@ -14,8 +14,12 @@ const container: React.CSSProperties = { }; const titleStyle: React.CSSProperties = { - fontSize: 14, color: WHITE, + fontSize: 14, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', }; const icon: React.CSSProperties = { diff --git a/packages/studio/src/state/modals.ts b/packages/studio/src/state/modals.ts index 3ed3f2eb570..c25afedc95f 100644 --- a/packages/studio/src/state/modals.ts +++ b/packages/studio/src/state/modals.ts @@ -150,14 +150,12 @@ export type CanvasCaptureImport = { export type ElementInstallPlan = { readonly compositionFile: string; - readonly filePath: string; readonly expectedFileState: ElementInstallExpectedFileState; }; export type ElementInstallModalState = { readonly type: 'element-install'; readonly currentPlan: ElementInstallPlan | null; - readonly dependenciesToReview: string[]; readonly missingPackages: string[]; readonly newPlan: ElementInstallPlan; readonly onClose: () => void; From 0bcbdc2f91a2b1b3030e53fd54f6318eb361830b Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Wed, 2 Sep 2026 15:36:07 +0200 Subject: [PATCH 02/13] Polish Element install dialog controls --- packages/example/e2e/studio-protocol.test.mts | 32 +++++--- .../components/ElementInstallConfirmation.tsx | 77 +++++++++++++++---- .../studio/src/components/ModalContainer.tsx | 15 +++- 3 files changed, 96 insertions(+), 28 deletions(-) diff --git a/packages/example/e2e/studio-protocol.test.mts b/packages/example/e2e/studio-protocol.test.mts index fafd07ad1ba..00c1db8d86a 100644 --- a/packages/example/e2e/studio-protocol.test.mts +++ b/packages/example/e2e/studio-protocol.test.mts @@ -355,13 +355,21 @@ const CloseupPlaceholder = () => { studioProtocolRequests.length = 0; await installInStudio.click(); - const dialog = studioPage.getByRole('dialog'); - await expect( - dialog.getByText('Install Protocol Element', {exact: true}), - ).toBeVisible(); - await expect( - dialog.getByRole('button', {name: 'Current composition'}), - ).toHaveAttribute('aria-pressed', 'true'); + const dialog = studioPage.getByRole('dialog', { + name: 'Install Protocol Element', + }); + await expect(dialog).toBeVisible(); + const currentDestination = dialog.getByRole('radio', { + name: 'Current composition', + }); + const newDestination = dialog.getByRole('radio', { + name: 'New composition', + }); + await expect(currentDestination).toBeChecked(); + await currentDestination.press('ArrowRight'); + await expect(newDestination).toBeChecked(); + await newDestination.press('ArrowLeft'); + await expect(currentDestination).toBeChecked(); await expect(dialog.getByText(senderUrl, {exact: true})).toBeVisible(); await expect( decoyStudioPage.getByText('Install Protocol Element', {exact: true}), @@ -395,12 +403,12 @@ const CloseupPlaceholder = () => { await senderPage.goto(senderUrl); await senderPage.getByRole('button', {name: 'Install in Studio'}).click(); await studioPage.bringToFront(); - const newCompositionDialog = studioPage.getByRole('dialog'); - await expect( - newCompositionDialog.getByText('Install Protocol Element', {exact: true}), - ).toBeVisible(); + const newCompositionDialog = studioPage.getByRole('dialog', { + name: 'Install Protocol Element', + }); + await expect(newCompositionDialog).toBeVisible(); await newCompositionDialog - .getByRole('button', {name: 'New composition'}) + .getByRole('radio', {name: 'New composition'}) .click(); await expect( newCompositionDialog.getByPlaceholder('Composition ID'), diff --git a/packages/studio/src/components/ElementInstallConfirmation.tsx b/packages/studio/src/components/ElementInstallConfirmation.tsx index 9f140175c2e..7f3a6e5b33f 100644 --- a/packages/studio/src/components/ElementInstallConfirmation.tsx +++ b/packages/studio/src/components/ElementInstallConfirmation.tsx @@ -68,7 +68,7 @@ const container: React.CSSProperties = { const dialogContent: React.CSSProperties = { ...container, padding: 16, - width: 'min(600px, calc(100vw - 40px))', + width: 'min(520px, calc(100vw - 40px))', maxHeight: 'min(720px, calc(100vh - 140px))', overflowY: 'auto', }; @@ -130,6 +130,7 @@ const requestSourceStyle: React.CSSProperties = { const requestSourceDescriptionStyle: React.CSSProperties = { ...metadataDescriptionStyle, + color: LIGHT_TEXT, textAlign: 'right', }; @@ -253,25 +254,31 @@ const destinationControlStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', - gap: 16, + columnGap: 16, + rowGap: 10, + flexWrap: 'wrap', }; const destinationOptionsStyle: React.CSSProperties = { display: 'flex', overflow: 'hidden', + maxWidth: '100%', + marginLeft: 'auto', border: `1px solid ${WHITE_ALPHA_12}`, borderRadius: 4, }; const destinationOptionStyle: React.CSSProperties = { appearance: 'none', + minHeight: 26, border: 0, cursor: 'default', fontFamily: 'sans-serif', - fontSize: 11, + fontSize: 12, fontWeight: 400, - lineHeight: 1.5, - padding: '2px 7px', + lineHeight: '18px', + padding: '4px 8px', + whiteSpace: 'nowrap', }; const getDestinationOptionStyle = ({ @@ -299,6 +306,8 @@ const cancelStyle: React.CSSProperties = { minWidth: 90, }; +const elementInstallTitleId = 'remotion-element-install-title'; + const makeSourceControlsVisible = (sourceCode: string) => { return sourceCode.replace( /[\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, @@ -397,6 +406,8 @@ export const ElementInstallConfirmation: React.FC<{ ); const [submitting, setSubmitting] = useState(false); const inputRef = useRef(null); + const currentDestinationRef = useRef(null); + const newDestinationRef = useRef(null); const [newCompositionValues, setNewCompositionValues] = useState(() => { const elementComponentName = @@ -549,6 +560,7 @@ export const ElementInstallConfirmation: React.FC<{ const folderTargetIsReady = selectedFolderStack === null || (hasResolvedStack(selectedFolderStack) && folderCompositionFile !== null); + const title = `Install ${request.element.displayName}${request.element.displayName.endsWith(' Element') ? '' : ' Element'}`; const canSubmit = !submitting && (mode === 'current-composition' @@ -630,6 +642,35 @@ export const ElementInstallConfirmation: React.FC<{ } }, [onClose, submitting]); + const onDestinationKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if ( + !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key) + ) { + return; + } + + event.preventDefault(); + if (currentPlan === null) { + return; + } + + const nextMode = + mode === 'current-composition' + ? 'new-composition' + : 'current-composition'; + setMode(nextMode); + requestAnimationFrame(() => { + if (nextMode === 'current-composition') { + currentDestinationRef.current?.focus(); + } else { + newDestinationRef.current?.focus(); + } + }); + }, + [currentPlan, mode], + ); + const onSubmit: React.FormEventHandler = useCallback( (event) => { event.preventDefault(); @@ -639,11 +680,14 @@ export const ElementInstallConfirmation: React.FC<{ ); return createPortal( - - + +
+ +
@@ -663,29 +707,35 @@ export const ElementInstallConfirmation: React.FC<{
Destination
) : null} - {installStatus.type === 'success' || - installStatus.type === 'error' ? ( + {installStatus.type !== 'idle' && + (installStatus.type !== 'installing' || isInstallHintVisible) ? (

- {installStatus.message} + {installStatus.type === 'installing' + ? 'If your browser prompts you, allow local network access so this page can find Remotion Studio.' + : installStatus.message}

) : null} From f5f57de06087dddd1b71722d3fdd0eaac558c762 Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Wed, 2 Sep 2026 16:41:49 +0200 Subject: [PATCH 05/13] Elements: Add unavailable Studio guidance --- .../src/components/Elements/ElementPage.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/docs/src/components/Elements/ElementPage.tsx b/packages/docs/src/components/Elements/ElementPage.tsx index 144cec0eeae..be1d4f62e95 100644 --- a/packages/docs/src/components/Elements/ElementPage.tsx +++ b/packages/docs/src/components/Elements/ElementPage.tsx @@ -1,6 +1,7 @@ import Head from '@docusaurus/Head'; import { installInStudio, + type InstallInStudioErrorCode, isInsideStudio, setStudioDragData, StudioProtocolInternals, @@ -40,7 +41,7 @@ type InstallStatus = | {type: 'idle'} | {type: 'installing'} | {type: 'success'; message: string} - | {type: 'error'; message: string}; + | {type: 'error'; code: InstallInStudioErrorCode; message: string}; export const ElementPage: React.FC = ({ children, @@ -114,6 +115,7 @@ export const ElementPage: React.FC = ({ if (!result.success) { setInstallStatus({ type: 'error', + code: result.code, message: result.message, }); return; @@ -269,7 +271,18 @@ export const ElementPage: React.FC = ({ : styles.errorStatus } > - {installStatus.message} + {installStatus.type === 'error' && + installStatus.code === 'no-compatible-studio' ? ( + <> + Remotion Studio needs to be installed and running.{' '} + + Follow the getting-started instructions + + , then try again. + + ) : ( + installStatus.message + )}

) : null} From 784e9b08f07ee0da6b0a2333830a0843a34ea9b3 Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Wed, 2 Sep 2026 16:59:28 +0200 Subject: [PATCH 06/13] Wait for local network permission during Studio discovery --- .../studio-protocol/src/studio-discovery.ts | 6 +- .../src/test/install-in-studio.test.ts | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/studio-protocol/src/studio-discovery.ts b/packages/studio-protocol/src/studio-discovery.ts index a0f2cb372d5..ed3a01f69c6 100644 --- a/packages/studio-protocol/src/studio-discovery.ts +++ b/packages/studio-protocol/src/studio-discovery.ts @@ -211,10 +211,8 @@ export const discoverStudios = async ( const origin = `http://localhost:${port}`; let response: Response; try { - response = await fetchWithTimeout({ - fetchFn: dependencies.fetchFn, - options: {cache: 'no-store'}, - url: `${origin}/api/studio-protocol`, + response = await dependencies.fetchFn(`${origin}/api/studio-protocol`, { + cache: 'no-store', }); } catch { return null; diff --git a/packages/studio-protocol/src/test/install-in-studio.test.ts b/packages/studio-protocol/src/test/install-in-studio.test.ts index c8b53a59024..3a29e10002f 100644 --- a/packages/studio-protocol/src/test/install-in-studio.test.ts +++ b/packages/studio-protocol/src/test/install-in-studio.test.ts @@ -186,6 +186,65 @@ test('returns an actionable result when no Studio is running', async () => { }); }); +test('waits for Studio discovery while local network permission is pending', async () => { + const fetchFn = (input: string | URL | Request, options?: RequestInit) => { + const url = String(input); + if (url === 'http://localhost:3000/api/studio-protocol') { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + resolve( + jsonResponse( + descriptor({ + compositionId: 'Main', + lastFocusedAt: 950_000, + projectName: 'Project', + targetId: 'target', + }), + ), + ); + }, 2100); + options?.signal?.addEventListener( + 'abort', + () => { + clearTimeout(timeout); + reject(options.signal?.reason); + }, + {once: true}, + ); + }); + } + + if (url === 'http://localhost:3000/api/studio-protocol/install') { + return Promise.resolve( + jsonResponse({ + protocol: 'remotion-studio-protocol', + protocolVersion: 1, + status: 'awaiting-confirmation', + }), + ); + } + + return Promise.resolve(new Response(null, {status: 404})); + }; + + expect( + await installInStudioWithDependencies(elementPayload, { + ...dependencies, + ports: [3000], + fetchFn, + }), + ).toEqual({ + success: true, + status: 'awaiting-confirmation', + target: { + projectName: 'Project', + compositionId: 'Main', + studioOrigin: 'http://localhost:3000', + studioVersion: '4.0.502', + }, + }); +}); + test('reports malformed discovery JSON as an invalid response', async () => { const fetchFn = (input: string | URL | Request) => { if (String(input) === 'http://localhost:3000/api/studio-protocol') { From 17f06f4e3e1b1c99c1eb971cfb58d97d3794f889 Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Wed, 2 Sep 2026 17:34:23 +0200 Subject: [PATCH 07/13] Remove legacy Studio discovery probe --- .../studio-protocol/install-in-studio.mdx | 2 - .../src/add-element-library-to-studio.ts | 8 ---- .../studio-protocol/src/install-in-studio.ts | 8 ---- .../src/set-license-key-in-studio.ts | 10 ----- .../studio-protocol/src/studio-discovery.ts | 28 ------------- .../add-element-library-to-studio.test.ts | 24 +++++++++++ .../src/test/install-in-studio.test.ts | 40 +++++-------------- .../test/set-license-key-in-studio.test.ts | 21 ++++++++++ 8 files changed, 54 insertions(+), 87 deletions(-) diff --git a/packages/docs/docs/studio-protocol/install-in-studio.mdx b/packages/docs/docs/studio-protocol/install-in-studio.mdx index 9e254f53af9..07724879c3f 100644 --- a/packages/docs/docs/studio-protocol/install-in-studio.mdx +++ b/packages/docs/docs/studio-protocol/install-in-studio.mdx @@ -90,8 +90,6 @@ A human-readable failure message. Use `code` for application logic. When called outside Studio, ports 3000 through 3009 are probed in parallel. The most recently focused compatible target is selected. Discovery returns a short-lived, single-use token bound to that Studio tab and contextual composition. -Studios older than 4.0.502 are detected and return `studio-upgrade-required`. The Element is not sent through the legacy endpoint. - ## Supported origins The function supports any HTTPS website. HTTP is supported only on `localhost` and `127.0.0.1` for local development. diff --git a/packages/studio-protocol/src/add-element-library-to-studio.ts b/packages/studio-protocol/src/add-element-library-to-studio.ts index 7f8b3a41847..8c2718def8a 100644 --- a/packages/studio-protocol/src/add-element-library-to-studio.ts +++ b/packages/studio-protocol/src/add-element-library-to-studio.ts @@ -6,7 +6,6 @@ import { fetchWithTimeout, focusedStudioMaxAge, getAddElementLibraryCapability, - hasLegacyStudio, isAbortError, studioProtocolProbePorts, } from './studio-discovery'; @@ -154,13 +153,6 @@ export const addElementLibraryToStudioWithDependencies = async ( ); } - if (await hasLegacyStudio(dependencies)) { - return failure( - 'studio-upgrade-required', - 'This Remotion Studio cannot add an Element catalog through Studio Protocol. Upgrade Remotion to 4.0.518 or newer.', - ); - } - if (discovery.foundInvalidResponse) { return failure( 'invalid-response', diff --git a/packages/studio-protocol/src/install-in-studio.ts b/packages/studio-protocol/src/install-in-studio.ts index 0ff04f6ab0b..1f0edb86ad1 100644 --- a/packages/studio-protocol/src/install-in-studio.ts +++ b/packages/studio-protocol/src/install-in-studio.ts @@ -9,7 +9,6 @@ import { fetchWithTimeout, focusedStudioMaxAge, getInstallCapability, - hasLegacyStudio, isAbortError, studioProtocolProbePorts, } from './studio-discovery'; @@ -178,13 +177,6 @@ export const installInStudioWithDependencies = async ( ); } - if (await hasLegacyStudio(dependencies)) { - return failure( - 'studio-upgrade-required', - 'This Remotion Studio does not support the Remotion Studio Protocol. Upgrade Remotion to 4.0.502 or newer.', - ); - } - if (discovery.foundInvalidResponse) { return failure( 'invalid-response', diff --git a/packages/studio-protocol/src/set-license-key-in-studio.ts b/packages/studio-protocol/src/set-license-key-in-studio.ts index 54afb060538..221430aef83 100644 --- a/packages/studio-protocol/src/set-license-key-in-studio.ts +++ b/packages/studio-protocol/src/set-license-key-in-studio.ts @@ -6,7 +6,6 @@ import { fetchWithTimeout, focusedStudioMaxAge, getSetLicenseKeyCapability, - hasLegacyStudio, isAbortError, studioProtocolProbePorts, } from './studio-discovery'; @@ -96,15 +95,6 @@ export const setLicenseKeyInStudioWithDependencies = async ( }; } - if (await hasLegacyStudio(dependencies)) { - return { - success: false, - code: 'studio-upgrade-required', - message: - 'This Remotion Studio cannot set a license key through Studio Protocol. Upgrade Remotion to 4.0.504 or newer.', - }; - } - if (discovery.foundInvalidResponse) { return { success: false, diff --git a/packages/studio-protocol/src/studio-discovery.ts b/packages/studio-protocol/src/studio-discovery.ts index a0f2cb372d5..24fe631c3be 100644 --- a/packages/studio-protocol/src/studio-discovery.ts +++ b/packages/studio-protocol/src/studio-discovery.ts @@ -114,10 +114,6 @@ const protocolVersionEnvelopeSchema = z.looseObject({ protocol: z.literal('remotion-studio-protocol'), protocolVersion: z.unknown(), }); -const legacyStudioSchema = z.looseObject({ - type: z.literal('remotion-studio'), -}); - export const fetchWithTimeout = async ({ fetchFn, options, @@ -267,29 +263,5 @@ export const discoverStudios = async ( }; }; -export const hasLegacyStudio = async ( - dependencies: StudioProtocolDiscoveryDependencies, -): Promise => { - const results = await Promise.all( - dependencies.ports.map(async (port) => { - try { - const response = await fetchWithTimeout({ - fetchFn: dependencies.fetchFn, - options: {cache: 'no-store'}, - url: `http://localhost:${port}/api/element-install-target`, - }); - if (!response.ok) { - return false; - } - - return z.safeParse(legacyStudioSchema, await response.json()).success; - } catch { - return false; - } - }), - ); - return results.some(Boolean); -}; - export const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === 'AbortError'; diff --git a/packages/studio-protocol/src/test/add-element-library-to-studio.test.ts b/packages/studio-protocol/src/test/add-element-library-to-studio.test.ts index 7eaad20e2c9..849e0573d8c 100644 --- a/packages/studio-protocol/src/test/add-element-library-to-studio.test.ts +++ b/packages/studio-protocol/src/test/add-element-library-to-studio.test.ts @@ -125,6 +125,30 @@ test('requests confirmation in the most recently focused compatible Studio', asy }); }); +test('returns an actionable result when no Studio is running', async () => { + const requests: string[] = []; + const result = await addElementLibraryToStudioWithDependencies( + {url: 'https://catalog.example.com', displayName: null}, + { + ...dependencies, + fetchFn: (input) => { + requests.push(String(input)); + return Promise.resolve(new Response(null, {status: 404})); + }, + }, + ); + + expect(result).toEqual({ + success: false, + code: 'no-compatible-studio', + message: 'Start Remotion Studio, focus it, and try again.', + }); + expect(requests).toEqual([ + 'http://localhost:3000/api/studio-protocol', + 'http://localhost:3001/api/studio-protocol', + ]); +}); + test('validates the request before probing localhost', async () => { for (const request of [ {url: '/relative', displayName: null}, diff --git a/packages/studio-protocol/src/test/install-in-studio.test.ts b/packages/studio-protocol/src/test/install-in-studio.test.ts index c8b53a59024..5642c77bdf4 100644 --- a/packages/studio-protocol/src/test/install-in-studio.test.ts +++ b/packages/studio-protocol/src/test/install-in-studio.test.ts @@ -174,9 +174,13 @@ test('distinguishes a compatible Studio without an installable target', async () }); test('returns an actionable result when no Studio is running', async () => { + const requests: string[] = []; const result = await installInStudioWithDependencies(elementPayload, { ...dependencies, - fetchFn: () => Promise.resolve(new Response(null, {status: 404})), + fetchFn: (input) => { + requests.push(String(input)); + return Promise.resolve(new Response(null, {status: 404})); + }, }); expect(result).toEqual({ @@ -184,6 +188,10 @@ test('returns an actionable result when no Studio is running', async () => { code: 'no-compatible-studio', message: 'Start Remotion Studio and open a composition, then try again.', }); + expect(requests).toEqual([ + 'http://localhost:3000/api/studio-protocol', + 'http://localhost:3001/api/studio-protocol', + ]); }); test('reports malformed discovery JSON as an invalid response', async () => { @@ -212,36 +220,6 @@ test('reports malformed discovery JSON as an invalid response', async () => { }); }); -test('reports a legacy Studio as requiring an upgrade without sending a payload', async () => { - const requests: string[] = []; - const fetchFn = (input: string | URL | Request) => { - const url = String(input); - requests.push(url); - if (url === 'http://localhost:3000/api/element-install-target') { - return Promise.resolve( - jsonResponse({type: 'remotion-studio', canInstall: true}), - ); - } - - return Promise.resolve(new Response(null, {status: 404})); - }; - - expect( - await installInStudioWithDependencies(elementPayload, { - ...dependencies, - fetchFn, - }), - ).toEqual({ - success: false, - code: 'studio-upgrade-required', - message: - 'This Remotion Studio does not support the Remotion Studio Protocol. Upgrade Remotion to 4.0.502 or newer.', - }); - expect( - requests.some((url) => url.endsWith('/api/request-element-install')), - ).toBe(false); -}); - test('returns a structured error when the selected target expired', async () => { const fetchFn = (input: string | URL | Request) => { const url = String(input); diff --git a/packages/studio-protocol/src/test/set-license-key-in-studio.test.ts b/packages/studio-protocol/src/test/set-license-key-in-studio.test.ts index 075b502688e..8c2e926bc1c 100644 --- a/packages/studio-protocol/src/test/set-license-key-in-studio.test.ts +++ b/packages/studio-protocol/src/test/set-license-key-in-studio.test.ts @@ -121,6 +121,27 @@ test('requests confirmation in the most recently focused Studio project', async }); }); +test('returns an actionable result when no Studio is running', async () => { + const requests: string[] = []; + const result = await setLicenseKeyInStudioWithDependencies(licenseKey, { + ...dependencies, + fetchFn: (input) => { + requests.push(String(input)); + return Promise.resolve(new Response(null, {status: 404})); + }, + }); + + expect(result).toEqual({ + success: false, + code: 'no-compatible-studio', + message: 'Start Remotion Studio, focus it, and try again.', + }); + expect(requests).toEqual([ + 'http://localhost:3000/api/studio-protocol', + 'http://localhost:3001/api/studio-protocol', + ]); +}); + test('rejects malformed keys before discovery', async () => { let requests = 0; const fetchFn = () => { From 8b66aea21cdb129ac587e27b0893c8c60d5bc7e8 Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Wed, 2 Sep 2026 18:10:51 +0200 Subject: [PATCH 08/13] Fix Studio discovery fetch invocation --- packages/studio-protocol/src/studio-discovery.ts | 3 ++- .../studio-protocol/src/test/install-in-studio.test.ts | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/studio-protocol/src/studio-discovery.ts b/packages/studio-protocol/src/studio-discovery.ts index b033be6cbc1..d8d52423d11 100644 --- a/packages/studio-protocol/src/studio-discovery.ts +++ b/packages/studio-protocol/src/studio-discovery.ts @@ -202,12 +202,13 @@ export const discoverStudios = async ( }> => { let foundUnsupportedProtocol = false; let foundInvalidResponse = false; + const {fetchFn} = dependencies; const studios = await Promise.all( dependencies.ports.map(async (port): Promise => { const origin = `http://localhost:${port}`; let response: Response; try { - response = await dependencies.fetchFn(`${origin}/api/studio-protocol`, { + response = await fetchFn(`${origin}/api/studio-protocol`, { cache: 'no-store', }); } catch { diff --git a/packages/studio-protocol/src/test/install-in-studio.test.ts b/packages/studio-protocol/src/test/install-in-studio.test.ts index 72f0e7a675b..d2d421799bd 100644 --- a/packages/studio-protocol/src/test/install-in-studio.test.ts +++ b/packages/studio-protocol/src/test/install-in-studio.test.ts @@ -195,7 +195,12 @@ test('returns an actionable result when no Studio is running', async () => { }); test('waits for Studio discovery while local network permission is pending', async () => { - const fetchFn = (input: string | URL | Request, options?: RequestInit) => { + const fetchFn = function ( + this: void, + input: string | URL | Request, + options?: RequestInit, + ) { + expect(this).toBeUndefined(); const url = String(input); if (url === 'http://localhost:3000/api/studio-protocol') { return new Promise((resolve, reject) => { From 0ea100a06be1a7d0fe49883e05f25cb11cb01a65 Mon Sep 17 00:00:00 2001 From: Igor Samokhovets Date: Wed, 2 Sep 2026 18:19:30 +0200 Subject: [PATCH 09/13] Elements: Integrate drag handle into install button --- packages/docs/components/layout/Button.tsx | 18 ++++- .../Elements/ElementPage.module.css | 46 ++++++------- .../src/components/Elements/ElementPage.tsx | 68 +++++++++++-------- 3 files changed, 74 insertions(+), 58 deletions(-) diff --git a/packages/docs/components/layout/Button.tsx b/packages/docs/components/layout/Button.tsx index c56e9b86234..3614989ca1e 100644 --- a/packages/docs/components/layout/Button.tsx +++ b/packages/docs/components/layout/Button.tsx @@ -28,14 +28,26 @@ type PrestyledProps = DetailedHTMLProps< MandatoryProps; export const Button: React.FC = (props) => { - const {children, loading, hoverColor, fullWidth, color, size, ...other} = - props; + const { + children, + loading, + hoverColor, + fullWidth, + color, + size, + className, + ...other + } = props; const actualDisabled = other.disabled || loading; return (
) : null} - {installStatus.type === 'success' || - installStatus.type === 'error' ? ( + {installStatus.type === 'error' && + installStatus.code === 'no-compatible-studio' ? ( +
+

+ Connect to Remotion Studio +

+
    +
  1. + 1 + + Open your Remotion project, or{' '} + create a new one. + +
  2. +
  3. + 2 + Start Studio and open a composition. +
  4. +
  5. + 3 + + Return here and click Install in Studio{' '} + again. + +
  6. +
+
+ ) : installStatus.type === 'success' || + installStatus.type === 'error' ? (

= ({ : styles.errorStatus } > - {installStatus.type === 'error' && - installStatus.code === 'no-compatible-studio' ? ( - <> - Remotion Studio needs to be installed and running.{' '} - - Follow the getting-started instructions - - , then try again. - - ) : ( - installStatus.message - )} + {installStatus.message}

) : null}