diff --git a/packages/docs/docs/studio-protocol/install-in-studio.mdx b/packages/docs/docs/studio-protocol/install-in-studio.mdx index b7b1d51f25b..9e254f53af9 100644 --- a/packages/docs/docs/studio-protocol/install-in-studio.mdx +++ b/packages/docs/docs/studio-protocol/install-in-studio.mdx @@ -6,7 +6,7 @@ crumb: '@remotion/studio-protocol' # installInStudio() -Discovers a recently focused Remotion Studio and sends it an Element payload. +Sends an Element payload to the containing Remotion Studio, or discovers a recently focused Studio when called outside one. ## Example @@ -43,7 +43,7 @@ if (!result.success) { An installation request provides a one-click flow and shows the requesting website in Studio. On macOS, Studio attempts to bring the selected Studio tab to the foreground after delivering the request. -The target is selected automatically: local Studio ports are probed and the most recently focused compatible Studio is used. Also offer drag-and-drop with [`setStudioDragData()`](/docs/studio-protocol/set-studio-drag-data) when possible so the user can choose a specific Studio tab, timeline position, and canvas position. +When [the library](/docs/studio-protocol/component-library-integration) is embedded in Studio, the containing Studio is selected. Otherwise, local Studio ports are probed and the most recently focused compatible Studio is used. Also offer drag-and-drop with [`setStudioDragData()`](/docs/studio-protocol/set-studio-drag-data) when possible so the user can choose a specific Studio tab, timeline position, and canvas position. ## Arguments @@ -88,7 +88,7 @@ A human-readable failure message. Use `code` for application logic. ## Discovery -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. +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. diff --git a/packages/example/e2e/studio-protocol.test.mts b/packages/example/e2e/studio-protocol.test.mts index 517d9c3c5c4..91362523d2d 100644 --- a/packages/example/e2e/studio-protocol.test.mts +++ b/packages/example/e2e/studio-protocol.test.mts @@ -214,7 +214,13 @@ const CloseupPlaceholder = () => { const senderUrl = `http://127.0.0.1:${address.port}`; const officialLibraryRequests: string[] = []; const externalLibraryRequests: string[] = []; + const studioProtocolRequests: string[] = []; const context = await browser.newContext(); + context.on('request', (request) => { + if (new URL(request.url()).pathname.startsWith('/api/studio-protocol')) { + studioProtocolRequests.push(request.url()); + } + }); await context.route( 'https://www.remotion.dev/elements?remotion-studio=true', async (route) => { @@ -346,6 +352,7 @@ const CloseupPlaceholder = () => { name: 'Install in Studio', }); await expect(installInStudio).toBeVisible(); + studioProtocolRequests.length = 0; await installInStudio.click(); const dialog = studioPage.getByRole('dialog'); @@ -354,6 +361,7 @@ const CloseupPlaceholder = () => { await expect(dialog.getByText(senderUrl, {exact: true})).toBeVisible(); await expect(decoyStudioPage.getByText('Install Element')).toHaveCount(0); await expect(elementsIframe).toHaveCount(0); + expect(studioProtocolRequests).toEqual([]); await dialog.getByRole('button', {name: /Install/}).click(); const elementFile = path.join( diff --git a/packages/studio-protocol/src/index.ts b/packages/studio-protocol/src/index.ts index d34d60832d8..d5269f041a7 100644 --- a/packages/studio-protocol/src/index.ts +++ b/packages/studio-protocol/src/index.ts @@ -21,6 +21,8 @@ import { import {parseStudioElementPayload} from './element-payload'; import { installInStudioWithDependencies, + isAllowedStudioProtocolPageOrigin, + parseStudioProtocolIframeInstallRequest, parseStudioProtocolInstallRequest, } from './install-in-studio'; import {isValidPublicLicenseKey} from './license-key'; @@ -103,6 +105,7 @@ export const StudioProtocolInternals = { isComponentIdentifier, isComponentImportPath, installInStudioWithDependencies, + isAllowedStudioProtocolPageOrigin, isValidPublicLicenseKey, makeBrowserStudioUrl, makeDragData, @@ -112,6 +115,7 @@ export const StudioProtocolInternals = { parseDragData, parseStudioElementPayload, parseStudioProtocolAddElementLibraryRequest, + parseStudioProtocolIframeInstallRequest, parseStudioProtocolDescriptor, parseStudioProtocolInstallRequest, parseStudioProtocolSetLicenseKeyRequest, diff --git a/packages/studio-protocol/src/install-in-studio.ts b/packages/studio-protocol/src/install-in-studio.ts index 1a63de5d0bd..0ff04f6ab0b 100644 --- a/packages/studio-protocol/src/install-in-studio.ts +++ b/packages/studio-protocol/src/install-in-studio.ts @@ -70,6 +70,40 @@ const studioProtocolInstallRequestEnvelopeSchema = z.object({ payload: z.unknown(), }); +const studioProtocolIframeInstallRequestSchema = z.object({ + operation: z.literal('install-element'), + protocol: z.literal('remotion-studio-protocol'), + protocolVersion: z.literal(1), + payload: z.unknown(), +}); + +const installInStudioResultSchema = z.union([ + z.object({ + success: z.literal(true), + status: z.literal('awaiting-confirmation'), + target: z.object({ + projectName: z.nullable(z.string()), + compositionId: z.string(), + studioOrigin: z.string(), + studioVersion: z.string(), + }), + }), + z.object({ + success: z.literal(false), + code: z.literal('no-installable-target'), + message: z.string(), + }), +]); + +export const parseStudioProtocolIframeInstallRequest = ( + value: unknown, +): StudioElementPayload | null => { + const envelope = z.safeParse(studioProtocolIframeInstallRequestSchema, value); + return envelope.success + ? parseStudioElementPayload(envelope.data.payload) + : null; +}; + export const parseStudioProtocolInstallRequest = ( value: unknown, ): @@ -269,11 +303,59 @@ export const installInStudioWithDependencies = async ( ); }; -export const installInStudio = ({ +const installInParentStudio = ( + payload: StudioElementPayload, +): Promise => { + if ( + typeof window === 'undefined' || + window.parent === window || + typeof MessageChannel === 'undefined' + ) { + return Promise.resolve(null); + } + + return new Promise((resolve) => { + const channel = new MessageChannel(); + const timeout = setTimeout(() => { + channel.port1.close(); + resolve(null); + }, 500); + + channel.port1.onmessage = (event) => { + const response = z.safeParse(installInStudioResultSchema, event.data); + if (!response.success) { + return; + } + + clearTimeout(timeout); + channel.port1.postMessage(null); + channel.port1.onmessage = null; + resolve(response.data); + }; + + window.parent.postMessage( + { + operation: 'install-element', + protocol: 'remotion-studio-protocol', + protocolVersion: 1, + payload, + }, + '*', + [channel.port2], + ); + }); +}; + +export const installInStudio = async ({ payload, }: { readonly payload: StudioElementPayload; }): Promise => { + const parentResult = await installInParentStudio(payload); + if (parentResult !== null) { + return parentResult; + } + return installInStudioWithDependencies(payload, { fetchFn: fetch, now: Date.now, diff --git a/packages/studio/src/components/Canvas.tsx b/packages/studio/src/components/Canvas.tsx index 57895202053..9d03ebb6695 100644 --- a/packages/studio/src/components/Canvas.tsx +++ b/packages/studio/src/components/Canvas.tsx @@ -1,5 +1,8 @@ import type {Size} from '@remotion/player'; -import {StudioProtocolInternals} from '@remotion/studio-protocol'; +import { + StudioProtocolInternals, + type InstallInStudioResult, +} from '@remotion/studio-protocol'; import type {ElementInstallRequest} from '@remotion/studio-shared'; import React, { useCallback, @@ -840,6 +843,88 @@ export const Canvas: React.FC<{ }); }, [previewServerClientId, subscribeToEvent]); + useEffect(() => { + const onMessage = (event: MessageEvent) => { + const elementLibrary = document.querySelector( + 'iframe[data-remotion-element-library]', + ); + if ( + event.source !== elementLibrary?.contentWindow || + !StudioProtocolInternals.isAllowedStudioProtocolPageOrigin(event.origin) + ) { + return; + } + + const payload = + StudioProtocolInternals.parseStudioProtocolIframeInstallRequest( + event.data, + ); + const responsePort = event.ports[0]; + if (payload === null || responsePort === undefined) { + return; + } + + const canInstall = + canReceiveElementInstallRequest && + compositionFile !== null && + currentCompositionId !== null && + previewServerClientId !== null; + const request: ElementInstallRequest | null = canInstall + ? { + clientId: previewServerClientId, + compositionFile, + compositionId: currentCompositionId, + createdAt: Date.now(), + element: { + ...payload.element, + durationInFrames: payload.element.durationInFrames ?? null, + installationMode: payload.element.installationMode ?? null, + }, + from: null, + id: crypto.randomUUID(), + position: null, + source: {origin: event.origin, type: 'studio-protocol'}, + } + : null; + const result: InstallInStudioResult = canInstall + ? { + success: true, + status: 'awaiting-confirmation', + target: { + compositionId: currentCompositionId, + projectName: window.remotion_projectName, + studioOrigin: window.location.origin, + studioVersion: window.remotion_version, + }, + } + : { + success: false, + code: 'no-installable-target', + message: + 'Focus a composition in a Remotion Studio that is not read-only, then try again.', + }; + + const timeout = window.setTimeout(() => responsePort.close(), 1000); + responsePort.onmessage = () => { + window.clearTimeout(timeout); + responsePort.close(); + if (request !== null) { + enqueueElementInstallRequest(request); + } + }; + + responsePort.postMessage(result); + }; + + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, [ + canReceiveElementInstallRequest, + compositionFile, + currentCompositionId, + previewServerClientId, + ]); + useEffect(() => { return subscribeToElementInstallRequests((request) => { const requestWithFrom = diff --git a/packages/studio/src/components/ElementLibraryModal.tsx b/packages/studio/src/components/ElementLibraryModal.tsx index d58c77b53b3..6fc9969e09f 100644 --- a/packages/studio/src/components/ElementLibraryModal.tsx +++ b/packages/studio/src/components/ElementLibraryModal.tsx @@ -44,6 +44,7 @@ export const ElementLibraryModal: React.FC<{