Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/docs/docs/studio-protocol/install-in-studio.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ crumb: '@remotion/studio-protocol'

# installInStudio()<AvailableFrom v="4.0.502" />

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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions packages/example/e2e/studio-protocol.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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');
Expand All @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions packages/studio-protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
import {parseStudioElementPayload} from './element-payload';
import {
installInStudioWithDependencies,
isAllowedStudioProtocolPageOrigin,
parseStudioProtocolIframeInstallRequest,
parseStudioProtocolInstallRequest,
} from './install-in-studio';
import {isValidPublicLicenseKey} from './license-key';
Expand Down Expand Up @@ -103,6 +105,7 @@ export const StudioProtocolInternals = {
isComponentIdentifier,
isComponentImportPath,
installInStudioWithDependencies,
isAllowedStudioProtocolPageOrigin,
isValidPublicLicenseKey,
makeBrowserStudioUrl,
makeDragData,
Expand All @@ -112,6 +115,7 @@ export const StudioProtocolInternals = {
parseDragData,
parseStudioElementPayload,
parseStudioProtocolAddElementLibraryRequest,
parseStudioProtocolIframeInstallRequest,
parseStudioProtocolDescriptor,
parseStudioProtocolInstallRequest,
parseStudioProtocolSetLicenseKeyRequest,
Expand Down
84 changes: 83 additions & 1 deletion packages/studio-protocol/src/install-in-studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down Expand Up @@ -269,11 +303,59 @@ export const installInStudioWithDependencies = async (
);
};

export const installInStudio = ({
const installInParentStudio = (
payload: StudioElementPayload,
): Promise<InstallInStudioResult | null> => {
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<InstallInStudioResult> => {
const parentResult = await installInParentStudio(payload);
if (parentResult !== null) {
return parentResult;
}

return installInStudioWithDependencies(payload, {
fetchFn: fetch,
now: Date.now,
Expand Down
87 changes: 86 additions & 1 deletion packages/studio/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -840,6 +843,88 @@ export const Canvas: React.FC<{
});
}, [previewServerClientId, subscribeToEvent]);

useEffect(() => {
const onMessage = (event: MessageEvent) => {
const elementLibrary = document.querySelector<HTMLIFrameElement>(
'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 =
Expand Down
1 change: 1 addition & 0 deletions packages/studio/src/components/ElementLibraryModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const ElementLibraryModal: React.FC<{
<iframe
ref={iframeRef}
allow="local-network-access; loopback-network"
data-remotion-element-library=""
style={iframeStyle}
title={`${name} library`}
/>
Expand Down
Loading