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<{