diff --git a/bun.lock b/bun.lock index 18e175438ff..0f008ba620a 100644 --- a/bun.lock +++ b/bun.lock @@ -1856,6 +1856,9 @@ "packages/studio-protocol": { "name": "@remotion/studio-protocol", "version": "4.0.519", + "dependencies": { + "zod": "catalog:", + }, "devDependencies": { "@remotion/eslint-config-internal": "workspace:*", "@typescript/native-preview": "catalog:", @@ -1898,7 +1901,6 @@ "name": "@remotion/studio-shared", "version": "4.0.519", "dependencies": { - "@remotion/studio-protocol": "workspace:*", "remotion": "workspace:*", }, "devDependencies": { @@ -2119,7 +2121,7 @@ "@remotion/media": "workspace:*", "@remotion/media-utils": "workspace:*", "@remotion/zod-types": "workspace:*", - "mediabunny": "1.55.1", + "mediabunny": "1.55.4", "react": "19.2.3", "react-dom": "19.2.3", "remotion": "workspace:*", @@ -2441,7 +2443,7 @@ "connect": "3.7.0", "eslint": "9.19.0", "lucide-react": "0.516.0", - "mediabunny": "1.55.1", + "mediabunny": "1.55.4", "react": "19.2.3", "react-dom": "19.2.3", "remotion": "workspace:*", @@ -2577,7 +2579,7 @@ "@remotion/media": "workspace:*", "@remotion/three": "workspace:*", "@remotion/zod-types": "workspace:*", - "mediabunny": "1.55.1", + "mediabunny": "1.55.4", "react": "19.2.3", "react-dom": "19.2.3", "remotion": "workspace:*", @@ -2897,13 +2899,13 @@ "@aws-sdk/middleware-flexible-checksums": "3.972.5", "@aws-sdk/s3-request-presigner": "3.986.0", "@huggingface/transformers": "4.2.0", - "@mediabunny/aac-encoder": "1.55.1", - "@mediabunny/ac3": "1.55.1", - "@mediabunny/dts": "1.55.1", - "@mediabunny/flac-encoder": "1.55.1", - "@mediabunny/mp3-encoder": "1.55.1", - "@mediabunny/prores": "1.55.1", - "@mediabunny/server": "1.55.1", + "@mediabunny/aac-encoder": "1.55.4", + "@mediabunny/ac3": "1.55.4", + "@mediabunny/dts": "1.55.4", + "@mediabunny/flac-encoder": "1.55.4", + "@mediabunny/mp3-encoder": "1.55.4", + "@mediabunny/prores": "1.55.4", + "@mediabunny/server": "1.55.4", "@react-three/fiber": "9.2.0", "@types/bun": "1.3.3", "@types/dom-webcodecs": "0.1.11", @@ -2916,7 +2918,7 @@ "@vitejs/plugin-react": "4.3.4", "@vitest/browser-playwright": "4.0.9", "eslint": "9.19.0", - "mediabunny": "1.55.1", + "mediabunny": "1.55.4", "next": "16.2.11", "openai": "4.67.1", "playwright": "1.55.1", @@ -3889,19 +3891,19 @@ "@mdx-js/react": ["@mdx-js/react@2.3.0", "", { "dependencies": { "@types/mdx": "2.0.5", "@types/react": "19.0.0" }, "peerDependencies": { "react": "19.0.0" } }, "sha512-zQH//gdOmuu7nt2oJR29vFhDv88oGPmVw6BggmrHeMI+xgEkp1B2dX9/bMBSYtK0dyLX/aOmesKS09g222K1/g=="], - "@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.55.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-NkLRZzfZoCmMZpMosj+O9gpl1tgHgKR85G8QrtUSUGJO1HaI/NqZznwSEdGWZKT4rSiRRaYtYXecLCAIdKWDFQ=="], + "@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.55.4", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-wxwuXnH+q0TGwFwvK42Xbld13RvogwSDe0ipeshfRhCNb1xTmLgqo0yzipmnPSZXn8d02TCWGFe8XDyNCtTU1w=="], - "@mediabunny/ac3": ["@mediabunny/ac3@1.55.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-6gNlm1qffxQLnK94iYsDIK+SM2LEW5vFUmLFFm3Xgz4c+o3oJ0ZNVeWaZiVMj3/Gw5BrJ4GwOTpPLsjzi+uZHA=="], + "@mediabunny/ac3": ["@mediabunny/ac3@1.55.4", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-yhUUPA0NlOPp6vb6/rpu8wDlaUbgrjZ3KHmNIYMaeZzrck3FAFQ0+RCGG8yA9dtCX/VpLbVaqxhLi+ZSzubeZA=="], - "@mediabunny/dts": ["@mediabunny/dts@1.55.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JQlgcf3d6l1/CIfTreZeTGB+dnwRr/cOdJEkGmNLXEsZPx8TQ+F/TmDQkWBjr4sJqYZd/l36uF6T5ymgWRYa5A=="], + "@mediabunny/dts": ["@mediabunny/dts@1.55.4", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-dZsjHlhWs4q/MxVC4Iz2FP8fSq1jGoXdMRmZ/Mu8sSYf39NNBK6QyU2e5DnbupzqyKkTZ4QkWE/KS74G5Du8xw=="], - "@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.55.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-tWmL2Fv6MtAIkjSVebZ8YgSj7YR/xN6Jn37F+JpSA2WdZLDXLxxH0wyw/kqsKv9aWCCUcYhc/GpDo/KUr9SUHw=="], + "@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.55.4", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-Rm5xATKLG2onP4a0YJG8hP/2m2grUjqtCumZNs7EBEXfuIPbjsuJdsnXQ6yGlvTp6/B3gASZI+4eJsgO0xw/3g=="], - "@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.55.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-5l9o9R+Ptb0MzAdysZ7fG5GxCpq+MxtJxy+ywlm32HFhvifutKJTw1ORP6pz2Bu0m8aD5OPITgdANvmwkXYU/g=="], + "@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.55.4", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-Ox8WUlWH/MR2gcsVPvPzZwmJk2UkjrEBJQzUsyLJ27tEqjSnVO+BhEOol1y8UVkSoeGV7wpNrN7aznm91BTgoQ=="], - "@mediabunny/prores": ["@mediabunny/prores@1.55.1", "", { "dependencies": { "turbores": "^1.2.2" }, "peerDependencies": { "mediabunny": "^1.49.0" } }, "sha512-0/nmBgGHLr8uDvWvhcVGygQEN75MM18BpdJCZv9jDmw0xE+dWxDcgth8Gw/xxEsMs0xrtKrQhQU4U6ZV5rxx+w=="], + "@mediabunny/prores": ["@mediabunny/prores@1.55.4", "", { "dependencies": { "turbores": "^1.2.2" }, "peerDependencies": { "mediabunny": "^1.49.0" } }, "sha512-DOunATssb5NPAp44eNOsUAISuvMz2teuESpmtWhWxeYWvm99j+uLEWWpCkzcDTLKp/5mD18/6lJLiAHt4SppBA=="], - "@mediabunny/server": ["@mediabunny/server@1.55.1", "", { "dependencies": { "@mediabunny/prores": "^1.55.1", "node-av": "^6.0.0" }, "peerDependencies": { "mediabunny": "^1.45.0" } }, "sha512-S296TDzp2RaMu8o9I1v5TSbLCkbXj76RhTsNRWTClVOQkmkzj2QYTpddFbGb61jvB7JwfK1jyZ9DFBK+3cTRAA=="], + "@mediabunny/server": ["@mediabunny/server@1.55.4", "", { "dependencies": { "@mediabunny/prores": "^1.55.4", "node-av": "^6.0.0" }, "peerDependencies": { "mediabunny": "^1.45.0" } }, "sha512-PBzINwmRCOcKBDZ+hv5fYfdLB6NLg9BTdZkZGBMhcVr0nBTMLE8s+Erd5r00wK1JGMraXDXMem5++YGpwfOasg=="], "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="], @@ -7281,7 +7283,7 @@ "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], - "mediabunny": ["mediabunny@1.55.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-JDdTEUOw9g6Ey8eWc2Ei63GmRGtGTJoE56SE7CHikwzS8i9xpsWcve4bEiUw47d4j49L3r7MXDFsH/5oZIhjBw=="], + "mediabunny": ["mediabunny@1.55.4", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-dJDE7ScjKlt21ZA3wS0xWQtgXjoUBEkWzjbnTVX5/BdN88C4QMdQu1u+K13vPHbuEbvjKbsXdYy38M3JTtPRHw=="], "mem": ["mem@4.3.0", "", { "dependencies": { "map-age-cleaner": "^0.1.1", "mimic-fn": "^2.0.0", "p-is-promise": "^2.0.0" } }, "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w=="], diff --git a/package.json b/package.json index 3cb30513d6a..3fd92bb6615 100644 --- a/package.json +++ b/package.json @@ -132,14 +132,14 @@ "@types/node": "20.12.14", "@types/web": "0.0.166", "@types/bun": "1.3.3", - "mediabunny": "1.55.1", - "@mediabunny/server": "1.55.1", - "@mediabunny/mp3-encoder": "1.55.1", - "@mediabunny/aac-encoder": "1.55.1", - "@mediabunny/flac-encoder": "1.55.1", - "@mediabunny/prores": "1.55.1", - "@mediabunny/ac3": "1.55.1", - "@mediabunny/dts": "1.55.1", + "mediabunny": "1.55.4", + "@mediabunny/server": "1.55.4", + "@mediabunny/mp3-encoder": "1.55.4", + "@mediabunny/aac-encoder": "1.55.4", + "@mediabunny/flac-encoder": "1.55.4", + "@mediabunny/prores": "1.55.4", + "@mediabunny/ac3": "1.55.4", + "@mediabunny/dts": "1.55.4", "next": "16.2.11", "three": "0.178.0", "sharp": "0.34.5", diff --git a/packages/browser-studio/e2e/browser-studio.test.ts b/packages/browser-studio/e2e/browser-studio.test.ts index 3a3bcc77d29..7c8070bfe8d 100644 --- a/packages/browser-studio/e2e/browser-studio.test.ts +++ b/packages/browser-studio/e2e/browser-studio.test.ts @@ -195,6 +195,10 @@ test('loads Browser Studio, opens external links, and can add, delete, and dupli studio.locator('.remotion-studio-composition-container'), ).toBeVisible(); await expect.poll(() => new URL(page.url()).search).toBe('?/MyComp'); + await expect(page).toHaveTitle( + 'MyComp / template-blank - Remotion Studio', + {timeout: 5000}, + ); await studio.locator('button:has(svg[viewBox="0 0 415 426"])').click(); const popupPromise = page.waitForEvent('popup'); await studio.getByText('About Remotion', {exact: true}).click(); @@ -329,8 +333,16 @@ test('loads Browser Studio, opens external links, and can add, delete, and dupli studio.getByTitle('/project').getByText('MyComp1'), ).toBeVisible(); await expect.poll(() => new URL(page.url()).search).toBe('?/MyComp1'); + await expect(page).toHaveTitle( + 'MyComp1 / template-blank - Remotion Studio', + {timeout: 5000}, + ); await page.goBack(); await expect.poll(() => new URL(page.url()).search).toBe('?/MyComp'); + await expect(page).toHaveTitle( + 'MyComp / template-blank - Remotion Studio', + {timeout: 5000}, + ); await studio.getByRole('button', {name: 'Render on web'}).click(); await expect( studio.getByText('Render MyComp', {exact: true}), @@ -1089,6 +1101,10 @@ export const LinkedElement = () => ; await expect( studio.getByText('Install Element', {exact: true}), ).toBeVisible(); + await expect(page).toHaveTitle( + '📦 Install Linked Element - Remotion Studio', + {timeout: 5000}, + ); await expect( studio.getByText('Unverified Browser Studio link'), ).toBeVisible(); @@ -1146,6 +1162,9 @@ export const LinkedElement = () => ; ).__browserStudioInstallPreservedIframe, ), ).toBe(true); + await expect(page).toHaveTitle('MyComp / template-blank - Remotion Studio', { + timeout: 5000, + }); }); test('reports inline SVG imports as unsupported without changing the project', async ({ diff --git a/packages/browser-studio/src/browser-studio-operations.ts b/packages/browser-studio/src/browser-studio-operations.ts index f770bc031b1..21e307f160b 100644 --- a/packages/browser-studio/src/browser-studio-operations.ts +++ b/packages/browser-studio/src/browser-studio-operations.ts @@ -1952,7 +1952,11 @@ export const createBrowserStudioOperations = ({ return value === null ? null : { - element: value.payload.element, + element: { + ...value.payload.element, + durationInFrames: value.payload.element.durationInFrames ?? null, + installationMode: value.payload.element.installationMode ?? null, + }, sourceOrigin: value.sourceOrigin, }; }, diff --git a/packages/browser-studio/src/test/browser-studio-operations.test.ts b/packages/browser-studio/src/test/browser-studio-operations.test.ts index 7b6eac8ada6..562b3c159eb 100644 --- a/packages/browser-studio/src/test/browser-studio-operations.test.ts +++ b/packages/browser-studio/src/test/browser-studio-operations.test.ts @@ -52,7 +52,11 @@ test('consumes an initial Element payload only once', () => { }); expect(operations.consumeInitialElement()).toEqual({ - element: payload.element, + element: { + ...payload.element, + durationInFrames: 60, + installationMode: 'wrapped', + }, sourceOrigin: 'https://elements.example.test', }); expect(operations.consumeInitialElement()).toBe(null); @@ -179,7 +183,7 @@ export const MyComponent = () => Existing; ); }); -test('wraps a self-closing root, aliases bindings, and remaps the root', () => { +test('inserts beside a self-closing root, aliases bindings, and remaps the root', () => { const project: VirtualProject = { rootDir: '/project', entryPoint: '/project/src/index.tsx', @@ -212,11 +216,12 @@ registerRoot(Root); const output = updated.files['/project/src/index.tsx']; expect(output).toContain( - "import {AbsoluteFill, Composition, registerRoot, Solid as RemotionSolid, Sequence as RemotionSequence} from 'remotion';", + "import {AbsoluteFill, Composition, registerRoot, Solid as RemotionSolid} from 'remotion';", ); - expect(output).toContain(''); + expect(output).not.toContain(''); + expect(output).toContain(''); expect(output).toContain('( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => TSnapshot, + getServerSnapshot?: () => TSnapshot, +) => TSnapshot; + +export const {useSyncExternalStore} = Internals as typeof Internals & { + useSyncExternalStore: UseSyncExternalStore; +}; diff --git a/packages/cli/src/extra-packages.ts b/packages/cli/src/extra-packages.ts index abb0d4c43ef..d85c8ef4117 100644 --- a/packages/cli/src/extra-packages.ts +++ b/packages/cli/src/extra-packages.ts @@ -1,11 +1,11 @@ export const EXTRA_PACKAGES: Record = { - mediabunny: '1.55.1', - '@mediabunny/ac3': '1.55.1', - '@mediabunny/dts': '1.55.1', - '@mediabunny/mp3-encoder': '1.55.1', - '@mediabunny/aac-encoder': '1.55.1', - '@mediabunny/flac-encoder': '1.55.1', - '@mediabunny/prores': '1.55.1', + mediabunny: '1.55.4', + '@mediabunny/ac3': '1.55.4', + '@mediabunny/dts': '1.55.4', + '@mediabunny/mp3-encoder': '1.55.4', + '@mediabunny/aac-encoder': '1.55.4', + '@mediabunny/flac-encoder': '1.55.4', + '@mediabunny/prores': '1.55.4', zod: '4.4.3', }; diff --git a/packages/core/THIRD_PARTY_LICENSES.md b/packages/core/THIRD_PARTY_LICENSES.md new file mode 100644 index 00000000000..c5bac94fc3e --- /dev/null +++ b/packages/core/THIRD_PARTY_LICENSES.md @@ -0,0 +1,28 @@ +# Third-party licenses + +## use-sync-external-store + +The vendored `useSyncExternalStore` compatibility implementation is adapted +from `use-sync-external-store` 1.5.0. + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core/src/Sequence.tsx b/packages/core/src/Sequence.tsx index 24720ab5e6d..5f24fe06d04 100644 --- a/packages/core/src/Sequence.tsx +++ b/packages/core/src/Sequence.tsx @@ -3,7 +3,6 @@ import React, { forwardRef, useCallback, useContext, - useEffect, useMemo, useRef, useState, @@ -13,6 +12,7 @@ import type { LoopDisplay, SequenceControls, SequenceRegistrationControls, + TSequence, } from './CompositionManager.js'; import type {EffectDefinition} from './effects/effect-types.js'; import {getStackForControls} from './enable-sequence-stack-traces.js'; @@ -30,15 +30,13 @@ import { } from './sequence-crop.js'; import type {SequenceContextType} from './SequenceContext.js'; import {SequenceContext} from './SequenceContext.js'; -import { - SequenceManager, - SequenceRegistrationContext, -} from './SequenceManager.js'; +import {SequenceRegistrationContext} from './SequenceManager.js'; import {IsInsideSeriesContext} from './series/is-inside-series.js'; import {useTimelinePosition} from './timeline-position-state.js'; import type {BasicMediaInTimelineReturnType} from './use-media-in-timeline.js'; import {usePremounting} from './use-premounting.js'; import {useRemotionEnvironment} from './use-remotion-environment.js'; +import {useSequenceRegistration} from './use-sequence-registration.js'; import {useVideoConfig} from './use-video-config.js'; import {ENABLE_V5_BREAKING_CHANGES} from './v5-flag.js'; import {withInteractivitySchema} from './with-interactivity-schema.js'; @@ -295,7 +293,6 @@ const RegularSequenceRefForwardingFunction: React.ForwardRefRenderFunction< 0, Math.min(videoConfig.durationInFrames - from, parentSequenceDuration), ); - const {registerSequence, unregisterSequence} = useContext(SequenceManager); const sequenceRegistrationEnabled = useContext(SequenceRegistrationContext); const wrapperRefForOutline = useRef(null); const refForOutline = @@ -460,14 +457,10 @@ const RegularSequenceRefForwardingFunction: React.ForwardRefRenderFunction< controlsSupportsEffects, ]); - useEffect(() => { - if (!env.isStudio && !sequenceRegistrationEnabled) { - return; - } - + const getSequenceForRegistration = useCallback((): TSequence => { if (isMedia) { if (isMedia.type === 'image') { - registerSequence({ + return { type: 'image', controls: registrationControls, effects: _remotionInternalEffects ?? EMPTY_EFFECTS, @@ -490,47 +483,43 @@ const RegularSequenceRefForwardingFunction: React.ForwardRefRenderFunction< isInsideSeries, frozenFrame: registeredFrozenFrame, singleChildComponent: singleChildComponent ?? null, - }); - } else { - registerSequence({ - type: isMedia.type, - controls: registrationControls, - effects: _remotionInternalEffects ?? EMPTY_EFFECTS, - effectRuntimeValues, - displayName: timelineClipName, - documentationLink: resolvedDocumentationLink, - doesVolumeChange: isMedia.data.doesVolumeChange, - duration: actualDurationInFrames, - from, - trimBefore: registeredTrimBefore, - id, - loopDisplay, - nonce: nonce.get(), - parent: parentSequence?.id ?? null, - playbackRate: isMedia.data.playbackRate, - postmountDisplay: postmountDisplay ?? null, - premountDisplay: premountDisplay ?? null, - showInTimeline, - src: isMedia.data.src, - getStack: () => stackRef.current, - startMediaFrom: startMediaFrom ?? isMedia.data.startMediaFrom, - mediaFrameAtSequenceZero, - volume: isMedia.data.volumes, - muted: isMedia.data.muted, - refForOutline: refForOutline ?? null, - isInsideSeries, - frozenFrame: registeredFrozenFrame, - frozenMediaFrame, - singleChildComponent: singleChildComponent ?? null, - }); + }; } - return () => { - unregisterSequence(id); + return { + type: isMedia.type, + controls: registrationControls, + effects: _remotionInternalEffects ?? EMPTY_EFFECTS, + effectRuntimeValues, + displayName: timelineClipName, + documentationLink: resolvedDocumentationLink, + doesVolumeChange: isMedia.data.doesVolumeChange, + duration: actualDurationInFrames, + from, + trimBefore: registeredTrimBefore, + id, + loopDisplay, + nonce: nonce.get(), + parent: parentSequence?.id ?? null, + playbackRate: isMedia.data.playbackRate, + postmountDisplay: postmountDisplay ?? null, + premountDisplay: premountDisplay ?? null, + showInTimeline, + src: isMedia.data.src, + getStack: () => stackRef.current, + startMediaFrom: startMediaFrom ?? isMedia.data.startMediaFrom, + mediaFrameAtSequenceZero, + volume: isMedia.data.volumes, + muted: isMedia.data.muted, + refForOutline: refForOutline ?? null, + isInsideSeries, + frozenFrame: registeredFrozenFrame, + frozenMediaFrame, + singleChildComponent: singleChildComponent ?? null, }; } - registerSequence({ + return { from, trimBefore: registeredTrimBefore, duration: actualDurationInFrames, @@ -552,29 +541,19 @@ const RegularSequenceRefForwardingFunction: React.ForwardRefRenderFunction< isInsideSeries, frozenFrame: registeredFrozenFrame, singleChildComponent: singleChildComponent ?? null, - }); - return () => { - unregisterSequence(id); }; }, [ - durationInFrames, id, - name, - registerSequence, timelineClipName, - unregisterSequence, parentSequence?.id, actualDurationInFrames, from, - trimBefore, registeredTrimBefore, showInTimeline, nonce, loopDisplay, premountDisplay, postmountDisplay, - env.isStudio, - sequenceRegistrationEnabled, registrationControls, _remotionInternalEffects, effectRuntimeValues, @@ -588,6 +567,13 @@ const RegularSequenceRefForwardingFunction: React.ForwardRefRenderFunction< frozenMediaFrame, singleChildComponent, ]); + useSequenceRegistration({ + getSequence: + env.isStudio || sequenceRegistrationEnabled + ? getSequenceForRegistration + : null, + id, + }); // Ceil to support floats // https://github.com/remotion-dev/remotion/issues/2958 diff --git a/packages/core/src/SequenceManager.tsx b/packages/core/src/SequenceManager.tsx index c08373ece74..21fa2edb54e 100644 --- a/packages/core/src/SequenceManager.tsx +++ b/packages/core/src/SequenceManager.tsx @@ -12,6 +12,7 @@ import type { export type SequenceManagerContext = { registerSequence: (seq: TSequence) => void; + updateSequence: ((seq: TSequence) => void) | null; unregisterSequence: (id: string) => void; sequences: TSequence[]; }; @@ -26,6 +27,7 @@ export const SequenceManager = React.createContext({ registerSequence: () => { throw new Error('SequenceManagerContext not initialized'); }, + updateSequence: null, unregisterSequence: () => { throw new Error('SequenceManagerContext not initialized'); }, @@ -329,6 +331,18 @@ export const SequenceManagerProvider: React.FC<{ return [...seqs, seq]; }); }, []); + const updateSequence = useCallback((seq: TSequence) => { + setSequences((seqs) => { + const index = seqs.findIndex((item) => item.id === seq.id); + if (index === -1 || seqs[index] === seq) { + return seqs; + } + + const next = [...seqs]; + next[index] = seq; + return next; + }); + }, []); const unregisterSequence = useCallback((seq: string) => { setSequences((seqs) => seqs.filter((s) => s.id !== seq)); @@ -338,9 +352,10 @@ export const SequenceManagerProvider: React.FC<{ return { registerSequence, sequences, + updateSequence, unregisterSequence, }; - }, [registerSequence, sequences, unregisterSequence]); + }, [registerSequence, sequences, unregisterSequence, updateSequence]); const getDragOverrides = useCallback( (nodePath: SequencePropsSubscriptionKey) => { diff --git a/packages/core/src/TimelineContext.tsx b/packages/core/src/TimelineContext.tsx index 303adb86f75..483f54b8f57 100644 --- a/packages/core/src/TimelineContext.tsx +++ b/packages/core/src/TimelineContext.tsx @@ -1,11 +1,13 @@ import type {RefObject} from 'react'; import React, { createContext, + useCallback, useLayoutEffect, useMemo, useRef, useState, } from 'react'; +import {createRuntimeValueStore} from './runtime-value-store.js'; import { getInitialFrameState, type PlayableMediaTag, @@ -14,14 +16,7 @@ import {useDelayRender} from './use-delay-render'; export type TimelineContextValue = { frame: Record; - playing: boolean; - imperativePlaying: RefObject; - audioAndVideoTags: RefObject; -}; - -export type TimelineImperativeContextValue = { - frameRef: RefObject>; - imperativePlaying: RefObject; + isPlaying: () => boolean; audioAndVideoTags: RefObject; }; @@ -33,6 +28,12 @@ export type PlaybackRateContextValue = { export type SetTimelineContextValue = { setFrame: (u: React.SetStateAction>) => void; setPlaying: (u: React.SetStateAction) => void; + subscribePlaying: ( + listener: (state: Readonly<{playing: boolean}>) => void, + ) => () => void; + frameRef: RefObject>; + isPlaying: () => boolean; + audioAndVideoTags: RefObject; }; export const SetTimelineContext = createContext({ @@ -42,13 +43,16 @@ export const SetTimelineContext = createContext({ setPlaying: () => { throw new Error('default'); }, + subscribePlaying: () => () => undefined, + frameRef: {current: {}}, + isPlaying: () => { + throw new Error('default'); + }, + audioAndVideoTags: {current: []}, }); export const TimelineContext = createContext(null); -export const TimelineImperativeContext = - createContext(null); - export const PlaybackRateContext = createContext(null); @@ -60,8 +64,10 @@ export const TimelineContextProvider: React.FC<{ readonly children: React.ReactNode; readonly frameState: Record | null; }> = ({children, frameState}) => { - const [playing, setPlaying] = useState(false); - const imperativePlaying = useRef(false); + const playingStore = useMemo( + () => createRuntimeValueStore({playing: false}), + [], + ); const [playbackRate, setPlaybackRate] = useState(1); const audioAndVideoTags = useRef([]); @@ -73,6 +79,11 @@ export const TimelineContextProvider: React.FC<{ const frameRef = useRef(frame); frameRef.current = frame; + const readIsPlaying = useCallback( + () => playingStore.store.getSnapshot().playing, + [playingStore], + ); + const {delayRender, continueRender} = useDelayRender(); if (typeof window !== 'undefined') { @@ -113,20 +124,10 @@ export const TimelineContextProvider: React.FC<{ const timelineContextValue = useMemo((): TimelineContextValue => { return { frame, - playing, - imperativePlaying, + isPlaying: readIsPlaying, audioAndVideoTags, }; - }, [frame, playing]); - - const timelineImperativeContextValue = - useMemo((): TimelineImperativeContextValue => { - return { - frameRef, - imperativePlaying, - audioAndVideoTags, - }; - }, []); + }, [frame, readIsPlaying]); const playbackRateContextValue = useMemo((): PlaybackRateContextValue => { return { @@ -138,22 +139,29 @@ export const TimelineContextProvider: React.FC<{ const setTimelineContextValue = useMemo((): SetTimelineContextValue => { return { setFrame, - setPlaying, + setPlaying: (updater) => { + const current = playingStore.store.getSnapshot().playing; + const next = typeof updater === 'function' ? updater(current) : updater; + + if (current !== next) { + playingStore.setSnapshot({playing: next}); + } + }, + subscribePlaying: playingStore.store.subscribe, + frameRef, + isPlaying: readIsPlaying, + audioAndVideoTags, }; - }, []); + }, [playingStore, readIsPlaying]); return ( - - - - {children} - - - + + + {children} + + ); diff --git a/packages/core/src/freeze.tsx b/packages/core/src/freeze.tsx index 2f911f6c464..e358c121bef 100644 --- a/packages/core/src/freeze.tsx +++ b/packages/core/src/freeze.tsx @@ -71,10 +71,7 @@ export const Freeze: React.FC = ({ return { ...timelineContext, - playing: false, - imperativePlaying: { - current: false, - }, + isPlaying: () => false, frame: { [videoConfig.id]: frameToFreeze + relativeFrom, }, diff --git a/packages/core/src/internals.ts b/packages/core/src/internals.ts index c6ead6ff781..60c7a6f7164 100644 --- a/packages/core/src/internals.ts +++ b/packages/core/src/internals.ts @@ -157,6 +157,7 @@ import { resolveCompositionsRef, useResolvedVideoConfig, } from './ResolveCompositionConfig.js'; +import {createRuntimeValueStore} from './runtime-value-store.js'; import {resolveSequenceCrop} from './sequence-crop.js'; import type { OverrideIdToNodePaths, @@ -206,10 +207,8 @@ import { PlaybackRateContext, SetTimelineContext, TimelineContext, - TimelineImperativeContext, type PlaybackRateContextValue, type SetTimelineContextValue, - type TimelineImperativeContextValue, type TimelineContextValue, } from './TimelineContext.js'; import {truthy} from './truthy.js'; @@ -227,6 +226,7 @@ import { useMediaInTimeline, } from './use-media-in-timeline.js'; import {PixelDensityContext} from './use-pixel-density.js'; +import {usePlaying} from './use-playing.js'; import {usePremounting} from './use-premounting.js'; import type { CanUpdateSequencePropStatusFalse, @@ -250,6 +250,7 @@ import { type EffectDragOverrides, type PropStatuses, } from './use-schema.js'; +import {useSyncExternalStore} from './use-sync-external-store.js'; import {useUnsafeVideoConfig} from './use-unsafe-video-config.js'; import {useVideo} from './use-video.js'; import {validateMediaProps} from './validate-media-props.js'; @@ -309,6 +310,7 @@ export const Internals = { MEDIABUNNY_DURATION_VALUE_KEY, makeRenderResourceManager, RenderResourceManagerContext, + createRuntimeValueStore, useUnsafeVideoConfig, useFrameForVolumeProp, useTimelinePosition: TimelinePosition.useTimelinePosition, @@ -439,7 +441,7 @@ export const Internals = { TimelinePosition, DelayRenderContextType, TimelineContext, - TimelineImperativeContext, + usePlaying, PlaybackRateContext, AbsoluteTimeContext, RenderAssetManagerProvider, @@ -474,6 +476,10 @@ export const Internals = { useCropStyle, } as const; +// Keep this available to other Remotion packages without adding it to the +// public type of Internals. +Object.assign(Internals, {useSyncExternalStore}); + export type { ArrayFieldSchema, AssetFieldSchema, @@ -528,7 +534,6 @@ export type { TCompMetadata, TComposition, TimelineContextValue, - TimelineImperativeContextValue, TRenderAsset, TSequence, VisibleFieldSchema, diff --git a/packages/core/src/runtime-value-store.ts b/packages/core/src/runtime-value-store.ts index 25fb97cd272..890e59ab5d0 100644 --- a/packages/core/src/runtime-value-store.ts +++ b/packages/core/src/runtime-value-store.ts @@ -1,22 +1,26 @@ export type RuntimeValueSnapshot = Readonly>; -export type RuntimeValueStore = { - getSnapshot: () => RuntimeValueSnapshot; - subscribe: (listener: () => void) => () => void; +export type RuntimeValueStore< + TSnapshot extends RuntimeValueSnapshot = RuntimeValueSnapshot, +> = { + getSnapshot: () => TSnapshot; + subscribe: (listener: (snapshot: TSnapshot) => void) => () => void; }; -export type RuntimeValueStoreController = { - store: RuntimeValueStore; - setSnapshot: (newSnapshot: RuntimeValueSnapshot) => void; +export type RuntimeValueStoreController< + TSnapshot extends RuntimeValueSnapshot = RuntimeValueSnapshot, +> = { + store: RuntimeValueStore; + setSnapshot: (newSnapshot: TSnapshot) => void; }; -export const createRuntimeValueStore = ( - initialSnapshot: RuntimeValueSnapshot, -): RuntimeValueStoreController => { +export const createRuntimeValueStore = ( + initialSnapshot: TSnapshot, +): RuntimeValueStoreController => { let snapshot = initialSnapshot; - const listeners = new Set<() => void>(); + const listeners = new Set<(snapshot: TSnapshot) => void>(); - const store: RuntimeValueStore = { + const store: RuntimeValueStore = { getSnapshot: () => snapshot, subscribe: (listener) => { listeners.add(listener); @@ -35,7 +39,7 @@ export const createRuntimeValueStore = ( snapshot = newSnapshot; for (const listener of listeners) { - listener(); + listener(snapshot); } }, }; diff --git a/packages/core/src/test/canvas-image.test.tsx b/packages/core/src/test/canvas-image.test.tsx index a75756b57d8..fca9244e944 100644 --- a/packages/core/src/test/canvas-image.test.tsx +++ b/packages/core/src/test/canvas-image.test.tsx @@ -193,6 +193,7 @@ const SequenceRegistrationWrapper: React.FC<{ () => ({ registerSequence, unregisterSequence, + updateSequence: registerSequence, sequences: [], }), [registerSequence, unregisterSequence], diff --git a/packages/core/src/test/html-in-canvas.test.tsx b/packages/core/src/test/html-in-canvas.test.tsx index 9a928c89a33..fba22e9a09c 100644 --- a/packages/core/src/test/html-in-canvas.test.tsx +++ b/packages/core/src/test/html-in-canvas.test.tsx @@ -195,6 +195,7 @@ const SequenceTestWrapper: React.FC<{ return { registerSequence, sequences: [], + updateSequence: registerSequence, unregisterSequence, }; }, [registerSequence, unregisterSequence]); diff --git a/packages/core/src/test/nested-sequences.test.tsx b/packages/core/src/test/nested-sequences.test.tsx index d745c953ee5..00125e05784 100644 --- a/packages/core/src/test/nested-sequences.test.tsx +++ b/packages/core/src/test/nested-sequences.test.tsx @@ -18,10 +18,7 @@ const getForFrame = (frame: number, content: React.ReactNode) => { frame: { 'my-comp': frame, }, - playing: false, - imperativePlaying: { - current: false, - }, + isPlaying: () => false, audioAndVideoTags: { current: [], }, @@ -258,10 +255,7 @@ test('Sequence freeze pins the child frame without remounting the sequence', () frame: { 'my-comp': 15, }, - playing: false, - imperativePlaying: { - current: false, - }, + isPlaying: () => false, audioAndVideoTags: { current: [], }, @@ -282,10 +276,7 @@ test('Sequence freeze pins the child frame without remounting the sequence', () frame: { 'my-comp': 25, }, - playing: false, - imperativePlaying: { - current: false, - }, + isPlaying: () => false, audioAndVideoTags: { current: [], }, diff --git a/packages/core/src/test/sequence-register-once.test.tsx b/packages/core/src/test/sequence-register-once.test.tsx index 06870a7ec64..642e9659b21 100644 --- a/packages/core/src/test/sequence-register-once.test.tsx +++ b/packages/core/src/test/sequence-register-once.test.tsx @@ -108,7 +108,12 @@ const SequenceTestWrapperWithVisualModeOverrides: React.FC< const unregisterSequence = useCallback(() => undefined, []); const ctx: SequenceManagerContext = useMemo( - () => ({registerSequence, unregisterSequence, sequences: []}), + () => ({ + registerSequence, + unregisterSequence, + updateSequence: registerSequence, + sequences: [], + }), [registerSequence, unregisterSequence], ); @@ -242,6 +247,54 @@ test('Sequence calls registerSequence exactly once on mount', () => { expect(registerCalls).toBe(1); }); +test('Sequence timing changes update its registration without changing order', async () => { + let getSequences = (): TSequence[] => { + throw new Error('Sequence manager has not mounted'); + }; + + const CaptureSequences = () => { + const sequencesRef = React.useContext(SequenceManagerRefContext); + getSequences = () => sequencesRef.current; + return null; + }; + + const renderSequences = (firstDuration: number) => ( + + + + + + + + + + ); + + const rendered = render(renderSequences(10)); + await waitFor(() => { + expect(getSequences()).toHaveLength(2); + }); + const initialIds = getSequences().map((sequence) => sequence.id); + + rendered.rerender(renderSequences(15)); + await waitFor(() => { + expect(getSequences()[0]?.duration).toBe(15); + }); + expect(getSequences().map((sequence) => sequence.displayName)).toEqual([ + 'First', + 'Second', + ]); + expect(getSequences().map((sequence) => sequence.id)).toEqual(initialIds); +}); + test('Interactive runtime values update mounted consumers without re-registering the sequence', () => { const registeredSequences: TSequence[] = []; const onRegisterSequence = (sequence: TSequence) => { diff --git a/packages/core/src/test/series.test.tsx b/packages/core/src/test/series.test.tsx index 629c6a71874..fb98be908fa 100644 --- a/packages/core/src/test/series.test.tsx +++ b/packages/core/src/test/series.test.tsx @@ -50,10 +50,7 @@ const renderForFrame = (frame: number, markup: React.ReactNode) => { frame: { 'my-comp': frame, }, - playing: false, - imperativePlaying: { - current: false, - }, + isPlaying: () => false, audioAndVideoTags: {current: []}, }; diff --git a/packages/core/src/test/use-media-in-timeline.test.tsx b/packages/core/src/test/use-media-in-timeline.test.tsx index 55328fac54d..d5b9d74e520 100644 --- a/packages/core/src/test/use-media-in-timeline.test.tsx +++ b/packages/core/src/test/use-media-in-timeline.test.tsx @@ -44,6 +44,7 @@ afterAll(() => { test('useMediaInTimeline registers muted changes and unregisters the sequence', () => { const registerSequence = mock(); + const updateSequence = mock(); const unregisterSequence = mock(); const wrapper: React.FC<{ children: React.ReactNode; @@ -53,6 +54,7 @@ test('useMediaInTimeline registers muted changes and unregisters the sequence', return { registerSequence, unregisterSequence, + updateSequence, sequences: [], }; }, []); @@ -97,7 +99,8 @@ test('useMediaInTimeline registers muted changes and unregisters the sequence', }); rerender({muted: true}); - expect(registerSequence.mock.calls.at(-1)?.[0]).toMatchObject({muted: true}); + expect(registerSequence).toHaveBeenCalledTimes(1); + expect(updateSequence.mock.calls.at(-1)?.[0]).toMatchObject({muted: true}); unmount(); expect(unregisterSequence).toHaveBeenCalled(); @@ -114,6 +117,7 @@ test('useMediaInTimeline keeps documentation links for custom display names', () return { registerSequence, unregisterSequence, + updateSequence: null, sequences: [], }; }, []); diff --git a/packages/core/src/test/wrap-sequence-context.tsx b/packages/core/src/test/wrap-sequence-context.tsx index 09a2f7b6991..213d4cdac8e 100644 --- a/packages/core/src/test/wrap-sequence-context.tsx +++ b/packages/core/src/test/wrap-sequence-context.tsx @@ -8,11 +8,13 @@ import {LogLevelContext} from '../log-level-context.js'; import {SequenceManagerProvider} from '../SequenceManager.js'; import type { PlaybackRateContextValue, + SetTimelineContextValue, TimelineContextValue, } from '../TimelineContext.js'; import { AbsoluteTimeContext, PlaybackRateContext, + SetTimelineContext, TimelineContext, } from '../TimelineContext.js'; @@ -68,6 +70,15 @@ const mockPlaybackRateContext: PlaybackRateContextValue = { }, }; +const mockSetTimelineContext: SetTimelineContextValue = { + setFrame: () => undefined, + setPlaying: () => undefined, + subscribePlaying: () => () => undefined, + frameRef: {current: {}}, + isPlaying: () => false, + audioAndVideoTags: {current: []}, +}; + const MaybeTimelineProvider: React.FC<{ readonly children: React.ReactNode; readonly timelineContext: TimelineContextValue; @@ -115,8 +126,7 @@ export const WrapSequenceContext: React.FC<{ const timelineContext = useMemo( () => ({ frame: {'my-comp': currentFrame}, - playing: false, - imperativePlaying: {current: false}, + isPlaying: () => false, audioAndVideoTags: {current: []}, }), [currentFrame], @@ -127,13 +137,15 @@ export const WrapSequenceContext: React.FC<{ - - - - {children} - - - + + + + + {children} + + + + diff --git a/packages/core/src/timeline-position-state.ts b/packages/core/src/timeline-position-state.ts index d5ad0acb61a..bd8c660036e 100644 --- a/packages/core/src/timeline-position-state.ts +++ b/packages/core/src/timeline-position-state.ts @@ -1,5 +1,4 @@ -import type {RefObject} from 'react'; -import {useContext, useMemo} from 'react'; +import {useContext} from 'react'; import { AbsoluteTimeContext, PlaybackRateContext, @@ -118,18 +117,4 @@ export const useTimelineSetFrame = (): (( return setFrame; }; -type PlayingReturnType = readonly [ - boolean, - (u: React.SetStateAction) => void, - RefObject, -]; - -export const usePlayingState = (): PlayingReturnType => { - const {playing, imperativePlaying} = useTimelineContext(); - const {setPlaying} = useContext(SetTimelineContext); - - return useMemo( - () => [playing, setPlaying, imperativePlaying], - [imperativePlaying, playing, setPlaying], - ); -}; +export {usePlaying} from './use-playing.js'; diff --git a/packages/core/src/use-media-in-timeline.ts b/packages/core/src/use-media-in-timeline.ts index 77caf400c1a..7477e8ed730 100644 --- a/packages/core/src/use-media-in-timeline.ts +++ b/packages/core/src/use-media-in-timeline.ts @@ -1,15 +1,13 @@ -import {useContext, useEffect, useMemo, useState} from 'react'; +import {useCallback, useContext, useEffect, useMemo, useState} from 'react'; import {useMediaStartsAt} from './audio/use-audio-frame.js'; -import type {LoopDisplay} from './CompositionManager.js'; +import type {LoopDisplay, TSequence} from './CompositionManager.js'; import {getAssetDisplayName} from './get-asset-file-name.js'; import {getTimelineDuration} from './get-timeline-duration.js'; import {useNonce} from './nonce.js'; import {SequenceContext} from './SequenceContext.js'; -import { - SequenceManager, - SequenceRegistrationContext, -} from './SequenceManager.js'; +import {SequenceRegistrationContext} from './SequenceManager.js'; import {useRemotionEnvironment} from './use-remotion-environment.js'; +import {useSequenceRegistration} from './use-sequence-registration.js'; import {useVideoConfig} from './use-video-config.js'; import type {VolumeProp} from './volume-prop.js'; import {evaluateVolume} from './volume-prop.js'; @@ -173,7 +171,6 @@ export const useMediaInTimeline = ({ }) => { const parentSequence = useContext(SequenceContext); const startsAt = useMediaStartsAt(); - const {registerSequence, unregisterSequence} = useContext(SequenceManager); const sequenceRegistrationEnabled = useContext(SequenceRegistrationContext); const {durationInFrames} = useVideoConfig(); const mediaStartsAt = useMediaStartsAt(); @@ -196,24 +193,12 @@ export const useMediaInTimeline = ({ const {isStudio} = useRemotionEnvironment(); - useEffect(() => { + const getSequenceForRegistration = useCallback((): TSequence => { if (!src) { throw new Error('No src passed'); } - if ( - !isStudio && - !sequenceRegistrationEnabled && - window.process?.env?.NODE_ENV !== 'test' - ) { - return; - } - - if (!showInTimeline) { - return; - } - - registerSequence({ + return { effectRuntimeValues: null, type: mediaType, src, @@ -242,18 +227,12 @@ export const useMediaInTimeline = ({ isInsideSeries: false, frozenFrame: null, frozenMediaFrame: null, - }); - - return () => { - unregisterSequence(id); }; }, [ duration, id, parentSequence, src, - registerSequence, - unregisterSequence, volumes, doesVolumeChange, nonce, @@ -261,15 +240,21 @@ export const useMediaInTimeline = ({ startsAt, playbackRate, getStack, - showInTimeline, premountDisplay, postmountDisplay, loopDisplay, documentationLink, finalDisplayName, - isStudio, - sequenceRegistrationEnabled, refForOutline, muted, ]); + const registrationEnabled = + isStudio || + sequenceRegistrationEnabled || + (typeof window !== 'undefined' && window.process?.env?.NODE_ENV === 'test'); + useSequenceRegistration({ + getSequence: + registrationEnabled && showInTimeline ? getSequenceForRegistration : null, + id, + }); }; diff --git a/packages/core/src/use-media-playback.ts b/packages/core/src/use-media-playback.ts index 2c80e83cc8e..74d1ec6036f 100644 --- a/packages/core/src/use-media-playback.ts +++ b/packages/core/src/use-media-playback.ts @@ -17,7 +17,7 @@ import {playAndHandleNotAllowedError} from './play-and-handle-not-allowed-error. import {playbackLogging} from './playback-logging.js'; import {seek} from './seek.js'; import { - usePlayingState, + usePlaying, usePlaybackRate, useTimelinePosition, } from './timeline-position-state.js'; @@ -57,7 +57,7 @@ export const useMediaPlayback = ({ const {playbackRate: globalPlaybackRate} = usePlaybackRate(); const frame = useCurrentFrame(); const absoluteFrame = useTimelinePosition(); - const [playing] = usePlayingState(); + const playing = usePlaying(); const buffering = useContext(BufferingContextReact); const {fps} = useVideoConfig(); const mediaStartsAt = useMediaStartsAt(); diff --git a/packages/core/src/use-media-tag.ts b/packages/core/src/use-media-tag.ts index 2b507ba8807..9292871b3f1 100644 --- a/packages/core/src/use-media-tag.ts +++ b/packages/core/src/use-media-tag.ts @@ -1,5 +1,5 @@ import type {RefObject} from 'react'; -import {useEffect} from 'react'; +import {useEffect, useRef} from 'react'; import {useLogLevel, useMountTime} from './log-level-context.js'; import {playAndHandleNotAllowedError} from './play-and-handle-not-allowed-error.js'; import type {PlayableMediaTag} from './timeline-position-state.js'; @@ -21,7 +21,9 @@ export const useMediaTag = ({ isPremounting: boolean; isPostmounting: boolean; }) => { - const {audioAndVideoTags, imperativePlaying} = useTimelineContext(); + const {audioAndVideoTags, isPlaying} = useTimelineContext(); + const isPlayingRef = useRef(isPlaying); + isPlayingRef.current = isPlaying; const logLevel = useLogLevel(); const mountTime = useMountTime(); const env = useRemotionEnvironment(); @@ -30,7 +32,7 @@ export const useMediaTag = ({ const tag: PlayableMediaTag = { id, play: (reason) => { - if (!imperativePlaying.current) { + if (!isPlayingRef.current()) { // Don't play if for example in a state. return; } @@ -63,7 +65,6 @@ export const useMediaTag = ({ mediaRef, mediaType, onAutoPlayError, - imperativePlaying, isPremounting, isPostmounting, logLevel, diff --git a/packages/core/src/use-playing.ts b/packages/core/src/use-playing.ts new file mode 100644 index 00000000000..7617786179c --- /dev/null +++ b/packages/core/src/use-playing.ts @@ -0,0 +1,11 @@ +import {useContext} from 'react'; +import {useTimelineContext} from './timeline-position-state.js'; +import {SetTimelineContext} from './TimelineContext.js'; +import {useSyncExternalStore} from './use-sync-external-store.js'; + +export const usePlaying = () => { + const {isPlaying} = useTimelineContext(); + const {subscribePlaying} = useContext(SetTimelineContext); + // Read the local Freeze override, but subscribe to the shared playing store. + return useSyncExternalStore(subscribePlaying, isPlaying, isPlaying); +}; diff --git a/packages/core/src/use-sequence-registration.ts b/packages/core/src/use-sequence-registration.ts new file mode 100644 index 00000000000..7ca6740be25 --- /dev/null +++ b/packages/core/src/use-sequence-registration.ts @@ -0,0 +1,50 @@ +import {useContext, useEffect, useRef} from 'react'; +import type {TSequence} from './CompositionManager.js'; +import {SequenceManager} from './SequenceManager.js'; + +export const useSequenceRegistration = ({ + getSequence, + id, +}: { + getSequence: (() => TSequence) | null; + id: string; +}) => { + const {registerSequence, unregisterSequence, updateSequence} = + useContext(SequenceManager); + const getSequenceRef = useRef(getSequence); + getSequenceRef.current = getSequence; + const lastRegisteredGetterRef = useRef<(() => TSequence) | null>(null); + const registrationEnabled = getSequence !== null; + + useEffect(() => { + if (!registrationEnabled) { + return; + } + + const currentGetter = getSequenceRef.current; + if (currentGetter === null) { + throw new Error('Expected a sequence registration getter'); + } + + registerSequence(currentGetter()); + lastRegisteredGetterRef.current = currentGetter; + + return () => { + lastRegisteredGetterRef.current = null; + unregisterSequence(id); + }; + }, [id, registerSequence, registrationEnabled, unregisterSequence]); + + useEffect(() => { + if ( + getSequence === null || + updateSequence === null || + lastRegisteredGetterRef.current === getSequence + ) { + return; + } + + updateSequence(getSequence()); + lastRegisteredGetterRef.current = getSequence; + }, [getSequence, updateSequence]); +}; diff --git a/packages/core/src/use-sync-external-store.ts b/packages/core/src/use-sync-external-store.ts new file mode 100644 index 00000000000..665a64e1b28 --- /dev/null +++ b/packages/core/src/use-sync-external-store.ts @@ -0,0 +1,120 @@ +/** + * @license React + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * Adapted from use-sync-external-store 1.5.0. + */ + +import * as React from 'react'; + +type UseSyncExternalStore = ( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => TSnapshot, + getServerSnapshot?: () => TSnapshot, +) => TSnapshot; + +type StoreInstance = { + value: TSnapshot; + getSnapshot: () => TSnapshot; +}; + +const objectIs = + typeof Object.is === 'function' + ? Object.is + : (first: unknown, second: unknown) => + (first === second && + (first !== 0 || 1 / (first as number) === 1 / (second as number))) || + (Number.isNaN(first) && Number.isNaN(second)); + +let didWarnAboutUncachedGetSnapshot = false; + +const checkIfSnapshotChanged = ( + instance: StoreInstance, +): boolean => { + try { + return !objectIs(instance.value, instance.getSnapshot()); + } catch { + return true; + } +}; + +const useSyncExternalStoreShimClient: UseSyncExternalStore = ( + subscribe, + getSnapshot, +) => { + const value = getSnapshot(); + + if ( + process.env.NODE_ENV !== 'production' && + !didWarnAboutUncachedGetSnapshot && + !objectIs(value, getSnapshot()) + ) { + // eslint-disable-next-line no-console + console.error( + 'The result of getSnapshot should be cached to avoid an infinite loop', + ); + didWarnAboutUncachedGetSnapshot = true; + } + + const [{instance}, forceUpdate] = React.useState({ + instance: {value, getSnapshot}, + }); + + React.useLayoutEffect(() => { + instance.value = value; + instance.getSnapshot = getSnapshot; + + if (checkIfSnapshotChanged(instance)) { + forceUpdate({instance}); + } + }, [getSnapshot, instance, subscribe, value]); + + React.useEffect(() => { + if (checkIfSnapshotChanged(instance)) { + forceUpdate({instance}); + } + + return subscribe(() => { + if (checkIfSnapshotChanged(instance)) { + forceUpdate({instance}); + } + }); + }, [instance, subscribe]); + + React.useDebugValue(value); + return value; +}; + +const useSyncExternalStoreShimServer: UseSyncExternalStore = ( + _subscribe, + getSnapshot, +) => getSnapshot(); + +const shim = + typeof window === 'undefined' || + typeof window.document === 'undefined' || + typeof window.document.createElement === 'undefined' + ? useSyncExternalStoreShimServer + : useSyncExternalStoreShimClient; + +export const useSyncExternalStore: UseSyncExternalStore = + React.useSyncExternalStore ?? shim; diff --git a/packages/core/src/wrap-remotion-context.tsx b/packages/core/src/wrap-remotion-context.tsx index c2772fec896..5029853e21a 100644 --- a/packages/core/src/wrap-remotion-context.tsx +++ b/packages/core/src/wrap-remotion-context.tsx @@ -17,16 +17,11 @@ import { SequenceManagerRefContext, VisualModePropStatusesRefContext, } from './SequenceManager.js'; -import { - SetTimelineContext, - TimelineContext, - TimelineImperativeContext, -} from './TimelineContext.js'; +import {SetTimelineContext, TimelineContext} from './TimelineContext.js'; export function useRemotionContexts() { const compositionManagerCtx = React.useContext(CompositionManager); const timelineContext = React.useContext(TimelineContext); - const timelineImperativeContext = React.useContext(TimelineImperativeContext); const setTimelineContext = React.useContext(SetTimelineContext); const sequenceContext = React.useContext(SequenceContext); const nonceContext = React.useContext(NonceContext); @@ -46,7 +41,6 @@ export function useRemotionContexts() { () => ({ compositionManagerCtx, timelineContext, - timelineImperativeContext, setTimelineContext, sequenceContext, nonceContext, @@ -66,7 +60,6 @@ export function useRemotionContexts() { sequenceContext, setTimelineContext, timelineContext, - timelineImperativeContext, canUseRemotionHooksContext, preloadContext, resolveCompositionContext, @@ -110,27 +103,23 @@ export const RemotionContextProvider = ( - - - - - - {children} - - - - - + {children} + + + + diff --git a/packages/docs/docs/ai/webmcp.mdx b/packages/docs/docs/ai/webmcp.mdx index af6ba9ed784..7a56afa5ce7 100644 --- a/packages/docs/docs/ai/webmcp.mdx +++ b/packages/docs/docs/ai/webmcp.mdx @@ -16,6 +16,7 @@ Agents can: - See which items you currently have selected - See selectable items and select them - See available compositions and navigate to them +- Read the error currently shown in the Studio error overlay - Get the current HTML of your composition - See current outlines of elements and their geometry - Get your current playhead position and playback state @@ -41,6 +42,37 @@ The shape of inputs and outputs is not currently stable. Tools that modify the timeline, playback or guides require a composition to be open. +### `get_current_error` + +Returns the error currently shown in the Studio error overlay. It waits for source map symbolication and includes symbolicated stack frames when available, while preserving the raw stack as a fallback. Returns `null` when the error overlay is not visible. + +```ts title="Input" +{} +``` + +```ts title="Output" +type ScriptLine = { + lineNumber: number; + content: string; + highlight: boolean; +}; + +type SymbolicatedStackFrame = { + originalFunctionName: string | null; + originalFileName: string | null; + originalLineNumber: number | null; + originalColumnNumber: number | null; + originalScriptCode: ScriptLine[] | null; +}; + +null | { + name: string; + message: string; + stack: string | null; + symbolicatedStackFrames: SymbolicatedStackFrame[] | null; +} +``` + ### `get_compositions` Returns the mounted compositions in the same nested folder hierarchy shown in the Studio sidebar. @@ -481,6 +513,7 @@ Seeks to a zero-based frame. Values beyond the duration are clamped to the final You can ask a browser agent: - "What is currently selected in the Studio?" +- "Fix the error" - "List all mounted compositions and their folders." - "Open the `Shapes` composition." - "List the sequences and select the `Title` sequence." diff --git a/packages/docs/docs/mediabunny/version.mdx b/packages/docs/docs/mediabunny/version.mdx index fab9482654e..79398c59428 100644 --- a/packages/docs/docs/mediabunny/version.mdx +++ b/packages/docs/docs/mediabunny/version.mdx @@ -10,7 +10,7 @@ The following packages of Remotion are using [Mediabunny](https://mediabunny.dev - [`@remotion/media`](/docs/media) - [`@remotion/media-utils`](/docs/media-utils) -The current version of Remotion uses version [`1.55.1`](https://github.com/Vanilagy/mediabunny/releases/tag/v1.55.1) of Mediabunny. +The current version of Remotion uses version [`1.55.4`](https://github.com/Vanilagy/mediabunny/releases/tag/v1.55.4) of Mediabunny. If you use Mediabunny as a direct dependency in your project, you can install it with the correct version using: @@ -41,6 +41,7 @@ Note that only from 4.0.355, Mediabunny is not bundled in with Remotion but load | Remotion Version | Mediabunny Version | | ---------------- | ------------------ | +| 4.0.520 | 1.55.4 | | 4.0.513 | 1.55.1 | | 4.0.488 | 1.50.8 | | 4.0.487 | 1.50.7 | diff --git a/packages/example/e2e/error-overlay.test.mts b/packages/example/e2e/error-overlay.test.mts index 2a1987d7665..b1c944cd900 100644 --- a/packages/example/e2e/error-overlay.test.mts +++ b/packages/example/e2e/error-overlay.test.mts @@ -119,7 +119,44 @@ test.describe('error overlay dismissal', () => { }); await page.addInitScript(() => { Object.defineProperty(window.navigator, 'platform', {value: 'Win32'}); + type RegisteredTool = { + readonly name: string; + readonly execute: (input: Record) => Promise; + }; + const tools = new Map(); + Object.defineProperty(window, '__remotion_webmcp_tools', { + value: tools, + }); + Object.defineProperty(document, 'modelContext', { + value: { + registerTool: async ( + tool: RegisteredTool, + options: {readonly signal: AbortSignal}, + ) => { + tools.set(tool.name, tool); + options.signal.addEventListener('abort', () => { + if (tools.get(tool.name) === tool) { + tools.delete(tool.name); + } + }); + }, + }, + }); }); + const getCurrentError = () => { + return page.evaluate(async () => { + const tools = ( + window as typeof window & { + readonly __remotion_webmcp_tools: Map< + string, + {readonly execute: () => Promise} + >; + } + ).__remotion_webmcp_tools; + const tool = tools.get('get_current_error'); + return tool ? tool.execute() : 'not-registered'; + }); + }; await page.goto(`${STUDIO_URL}/error-overlay-unsymbolicated-e2e`); await expect(page.getByText('Expected defaults').first()).toBeVisible({ timeout: 15_000, @@ -246,6 +283,14 @@ test.describe('error overlay dismissal', () => { (element) => element.scrollWidth > element.clientWidth, ), ).toBe(true); + await expect.poll(getCurrentError).toEqual({ + name: 'TypeError', + message: 'Expected defaults', + stack: expect.stringContaining( + 'webpack-internal:///cannot-symbolicate.js', + ), + symbolicatedStackFrames: null, + }); await page.getByRole('button', {name: 'Copy stack'}).click(); await expect(page.getByRole('button', {name: 'Copied!'})).toBeVisible(); @@ -258,6 +303,7 @@ test.describe('error overlay dismissal', () => { // Sanity check: no error visible initially. await expect(errorMessage).toHaveCount(0); + await expect.poll(getCurrentError).toBe(null); // 1. Introduce the bug: remove the `radius: 24` argument. await writeAndWaitForRebuild(buggyContent, 'introducing the bug'); @@ -265,6 +311,19 @@ test.describe('error overlay dismissal', () => { await expect( page.getByText('ErrorOverlayRepro', {exact: true}), ).toBeVisible(); + await expect.poll(getCurrentError).toEqual({ + name: 'TypeError', + message: '"radius" must be a finite number, but got undefined', + stack: expect.any(String), + symbolicatedStackFrames: expect.arrayContaining([ + expect.objectContaining({ + originalFunctionName: 'ErrorOverlayRepro', + originalFileName: expect.stringContaining( + 'src/ErrorOverlayE2e/ErrorOverlayRepro.tsx', + ), + }), + ]), + }); await expect( page.getByText('react_stack_bottom_frame', {exact: true}), ).toHaveCount(0); @@ -333,6 +392,7 @@ test.describe('error overlay dismissal', () => { // dismiss once HMR applies the fix. await writeAndWaitForRebuild(originalContent, 'fixing the bug'); await expect(errorMessage).toHaveCount(0, {timeout: 15_000}); + await expect.poll(getCurrentError).toBe(null); // 3. Re-introduce the bug: the error UI should come back. This guards // against the boundary getting permanently stuck in the success state diff --git a/packages/example/e2e/studio-protocol.test.mts b/packages/example/e2e/studio-protocol.test.mts index 11327f2d9ce..517d9c3c5c4 100644 --- a/packages/example/e2e/studio-protocol.test.mts +++ b/packages/example/e2e/studio-protocol.test.mts @@ -82,11 +82,7 @@ export const MyComponent = () => { }; `, ); - const closeupDirectory = path.join( - temporaryProject, - 'src', - 'closeups', - ); + const closeupDirectory = path.join(temporaryProject, 'src', 'closeups'); fs.mkdirSync(closeupDirectory); fs.writeFileSync( path.join(closeupDirectory, 'Closeup.tsx'), @@ -443,16 +439,18 @@ const CloseupPlaceholder = () => { closeupDirectory, 'ProtocolElementScene.tsx', ); + const newCompositionElementFile = path.join( + closeupDirectory, + 'protocol-element.element.tsx', + ); await waitForFile(newCompositionFile); + await waitForFile(newCompositionElementFile); await expect .poll(() => fs.readFileSync(newCompositionFile, 'utf8')) .toContain('ProtocolElement'); - expect( - fs.readFileSync( - path.join(closeupDirectory, 'protocol-element.element.tsx'), - 'utf8', - ), - ).toContain('export const ProtocolElement'); + expect(fs.readFileSync(newCompositionElementFile, 'utf8')).toContain( + 'export const ProtocolElement', + ); const sourceWithNewComposition = fs.readFileSync( path.join(closeupDirectory, 'Closeup.tsx'), 'utf8', diff --git a/packages/example/e2e/studio.test.mts b/packages/example/e2e/studio.test.mts index c4df58a209a..3c55b6db0ea 100644 --- a/packages/example/e2e/studio.test.mts +++ b/packages/example/e2e/studio.test.mts @@ -416,66 +416,6 @@ test.describe('visual mode', () => { } }); - test('should virtualize a large timeline without hiding tracks', async ({ - page, - }) => { - await page.goto(`${STUDIO_URL}/timeline-virtualization-testbed`); - await expect(page).toHaveURL(/timeline-virtualization-testbed/, { - timeout: 15_000, - }); - - const timelineScroll = page - .locator('.__remotion-vertical-scrollbar') - .filter({has: page.locator('[data-timeline-scrollable]')}); - await expect(timelineScroll).toHaveCount(1); - await expect( - page.getByText('Virtual track 000', {exact: true}), - ).toBeVisible(); - await expect( - page.locator('[data-timeline-marquee-item][title="Virtual track 000"]'), - ).toBeVisible(); - - const mountedTrackLabels = page.getByText(/^Virtual track \d{3}$/); - expect(await mountedTrackLabels.count()).toBeLessThan(120); - - const revealTargetTrack = page.locator( - '[data-timeline-marquee-item][title="Reveal target"]', - ); - await expect(revealTargetTrack).toHaveCount(0); - const canvas = page.locator('.remotion-studio-composition-container'); - const visibleOutlines = canvas.locator( - '> svg[aria-hidden="true"] polygon[stroke="#0b84f3"][stroke-opacity="1"]', - ); - await retryCanvasInteractionUntilOutlineIsVisible({ - interaction: () => canvas.hover(), - outline: visibleOutlines, - page, - }); - await visibleOutlines.first().click({force: true}); - - await expect(revealTargetTrack).toBeVisible(); - const [revealTargetRect, timelineScrollRect] = await Promise.all([ - revealTargetTrack.boundingBox(), - timelineScroll.boundingBox(), - ]); - expect(revealTargetRect).not.toBeNull(); - expect(timelineScrollRect).not.toBeNull(); - expect(revealTargetRect!.y).toBeGreaterThanOrEqual(timelineScrollRect!.y); - expect(revealTargetRect!.y + revealTargetRect!.height).toBeLessThanOrEqual( - timelineScrollRect!.y + timelineScrollRect!.height, - ); - await expect( - page.getByText('Virtual track 119', {exact: true}), - ).toBeVisible(); - await expect( - page.locator('[data-timeline-marquee-item][title="Virtual track 119"]'), - ).toBeVisible(); - expect( - await timelineScroll.evaluate((element) => element.scrollTop), - ).toBeGreaterThan(0); - expect(await mountedTrackLabels.count()).toBeLessThan(120); - }); - test('should show negative sequence timing in the frame-zero gutter', async ({ page, }) => { @@ -800,42 +740,6 @@ test.describe('visual mode', () => { expect(openInEditorRequests).toEqual([]); }); - test('should keep canvas item context menus open', async ({page}) => { - await page.goto(`${STUDIO_URL}/AnimatedBarChart`); - await expect( - page.getByRole('button', {name: '0', exact: true}), - ).toBeVisible({timeout: 15_000}); - await page.locator('[data-timeline-scrubber]').click(); - await expect( - page.getByRole('button', {name: '90', exact: true}), - ).toBeVisible(); - - const canvasItem = page.getByText('Performance overview', {exact: true}); - const canvasItemOutline = page.locator( - 'polygon[data-remotion-prevent-selection-clear="true"][stroke-opacity="1"]', - ); - await retryCanvasInteractionUntilOutlineIsVisible({ - interaction: () => canvasItem.hover(), - outline: canvasItemOutline, - page, - }); - await expect(canvasItemOutline).toHaveCount(1); - await canvasItemOutline.click({button: 'right'}); - - const duplicateButton = page.getByRole('button', { - name: 'Duplicate', - exact: true, - }); - await expect(duplicateButton).toBeVisible(); - - await page.mouse.move(10, 10); - // Portals do not reliably trigger pointerleave in headless Chromium. - await page - .locator('.remotion-studio-composition-container') - .dispatchEvent('pointerleave'); - await expect(duplicateButton).toBeVisible(); - }); - test('should preserve property selection while dragging its outline', async ({ page, }) => { @@ -1179,6 +1083,33 @@ test.describe('visual mode', () => { await page.getByRole('button', {name: 'Compositions', exact: true}).click(); + await page.keyboard.press('ControlOrMeta+k'); + const folderSearch = page.getByRole('dialog'); + await folderSearch + .getByPlaceholder('Search compositions...') + .fill('visual-controls'); + await expect( + folderSearch.getByText('visual-controls', {exact: true}), + ).toHaveCount(2); + await expect( + folderSearch.getByText('effect-keyframe-e2e', {exact: true}), + ).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/visual-controls/); + + await page.keyboard.press('ControlOrMeta+k'); + const compositionSearch = page.getByRole('dialog'); + await compositionSearch + .getByPlaceholder('Search compositions...') + .fill('effect-keyframe-e2e'); + await expect( + compositionSearch.getByText('visual-controls', {exact: true}), + ).toHaveCount(1); + await expect( + compositionSearch.getByText('effect-keyframe-e2e', {exact: true}), + ).toBeVisible(); + await page.keyboard.press('Escape'); + await page.keyboard.press('ControlOrMeta+k'); await page .getByPlaceholder('Search compositions...') @@ -1827,11 +1758,57 @@ test.describe('visual mode', () => { await expect(visibleOutlines.first()).toBeVisible(); }); - test('should preserve following interactive elements after deleting a sibling', async ({ + test('should clear selection after context-menu deletion and preserve following interactive elements', async ({ context, page, }) => { + await context.addInitScript(() => { + type RegisteredTool = { + readonly name: string; + readonly execute: (input: Record) => Promise; + }; + const tools = new Map(); + Object.defineProperty(window, '__remotion_webmcp_tools', { + value: tools, + }); + Object.defineProperty(document, 'modelContext', { + value: { + registerTool: async ( + tool: RegisteredTool, + options: {readonly signal: AbortSignal}, + ) => { + tools.set(tool.name, tool); + options.signal.addEventListener('abort', () => { + if (tools.get(tool.name) === tool) { + tools.delete(tool.name); + } + }); + }, + }, + }); + }); await navigateToLostNodePathE2e(page); + const getWebMcpSelection = () => + page.evaluate(async () => { + const tools = ( + window as typeof window & { + readonly __remotion_webmcp_tools?: Map< + string, + {readonly execute: () => Promise} + >; + } + ).__remotion_webmcp_tools; + if (!tools) { + return null; + } + + const tool = tools.get('get_selection'); + if (!tool) { + return null; + } + + return tool.execute(); + }); const otherPage = await context.newPage(); await navigateToLostNodePathE2e(otherPage); const canvas = page.locator('.remotion-studio-composition-container'); @@ -1921,8 +1898,14 @@ test.describe('visual mode', () => { const eyebrow = page.locator( '[data-timeline-marquee-item][title="Eyebrow"]', ); - await eyebrow.click(); - await page.keyboard.press('Delete'); + await eyebrow.click({button: 'right'}); + await expect.poll(getWebMcpSelection).toEqual( + expect.objectContaining({ + selectionType: 'sequence', + selectedSequence: expect.objectContaining({name: 'Eyebrow'}), + }), + ); + await page.getByRole('button', {name: 'Delete', exact: true}).click(); await expect .poll(() => fs.readFileSync(lostNodePathE2eFile, 'utf-8')) @@ -1948,6 +1931,13 @@ test.describe('visual mode', () => { ).toBeVisible(); await expect(gridlineVisibilityToggle).toBeVisible(); await expect(otherGridlineVisibilityToggle).toBeVisible(); + await expect.poll(getWebMcpSelection).toEqual( + expect.objectContaining({ + currentSelection: null, + selectionType: null, + selectedSequence: null, + }), + ); await page.getByRole('button', {name: /^Undo/}).click(); await expect @@ -3568,29 +3558,6 @@ test.describe('visual mode', () => { }); }); - test('should clear the open-in-editor hover state when closing the menu', async ({ - page, - }) => { - await page.goto(`${STUDIO_URL}/schema-test`); - const openInAnotherApp = page - .getByTitle(exampleDir) - .getByRole('button', {name: 'Open in another app'}); - const configureDefaultApps = page.getByRole('button', { - name: 'Configure default apps...', - }); - - await openInAnotherApp.click(); - await expect(configureDefaultApps).toBeVisible(); - // The menu overlay intercepts pointerleave; clicking it closes the menu - // through the same outside-click path a user would take. - await page.mouse.click(10, 100); - await expect(configureDefaultApps).toBeHidden(); - await expect(openInAnotherApp).toHaveCSS( - 'background-color', - 'rgba(0, 0, 0, 0)', - ); - }); - test('should open submenus toward the side with more space', async ({ page, }) => { diff --git a/packages/example/src/DragAndDrop/index.tsx b/packages/example/src/DragAndDrop/index.tsx index febb50a4b91..37123645efc 100644 --- a/packages/example/src/DragAndDrop/index.tsx +++ b/packages/example/src/DragAndDrop/index.tsx @@ -17,14 +17,6 @@ const payloads = [ importPath: '@remotion/shapes', props: [{name: 'radius', value: 100}], }), - StudioProtocolInternals.makeDragData({ - type: 'composition', - compositionId: 'MyVideo', - compositionFile: 'src/Root.tsx', - width: null, - height: null, - durationInFrames: null, - }), StudioProtocolInternals.makeDragData({ type: 'effect', name: 'brightness', diff --git a/packages/gif/src/test/Gif.test.tsx b/packages/gif/src/test/Gif.test.tsx index accf55b6c67..68e5ada03e1 100644 --- a/packages/gif/src/test/Gif.test.tsx +++ b/packages/gif/src/test/Gif.test.tsx @@ -107,8 +107,7 @@ const compositionContext = { const timelineContext = { frame: {}, - playing: false, - imperativePlaying: {current: false}, + isPlaying: () => false, audioAndVideoTags: {current: []}, } as React.ContextType; @@ -139,6 +138,7 @@ const SequenceRegistrationWrapper: React.FC<{ ({ registerSequence, unregisterSequence, + updateSequence: registerSequence, sequences: [], }) as React.ContextType, [registerSequence, unregisterSequence], diff --git a/packages/mac-cursors/src/test/resolve-cursor.test.ts b/packages/mac-cursors/src/test/resolve-cursor.test.ts index 361769e6a70..d040dfa5772 100644 --- a/packages/mac-cursors/src/test/resolve-cursor.test.ts +++ b/packages/mac-cursors/src/test/resolve-cursor.test.ts @@ -106,8 +106,7 @@ test(' renders the default cursor when the cursor prop is omitted', } as React.ContextType; const timeline = { frame: {comp: 0}, - playing: false, - imperativePlaying: {current: false}, + isPlaying: () => false, audioAndVideoTags: {current: []}, } as React.ContextType; const markup = renderToString( diff --git a/packages/media/src/audio/audio-for-preview.tsx b/packages/media/src/audio/audio-for-preview.tsx index df3b0da4e39..6a4742088a0 100644 --- a/packages/media/src/audio/audio-for-preview.tsx +++ b/packages/media/src/audio/audio-for-preview.tsx @@ -16,7 +16,6 @@ import type {FallbackHtml5AudioProps} from './props'; const { useUnsafeVideoConfig, - Timeline, SharedAudioContext, usePlayerMutedState, useMediaVolumeState, @@ -25,6 +24,7 @@ const { warnAboutTooHighVolume, usePreload, SequenceContext, + usePlaying, } = Internals; type NewAudioForPreviewProps = { @@ -85,7 +85,7 @@ const AudioForPreviewAssertedShowing: React.FC = ({ const [shouldFallbackToNativeAudio, setShouldFallbackToNativeAudio] = useState(false); - const [playing] = Timeline.usePlayingState(); + const playing = usePlaying(); const {playbackRate: globalPlaybackRate} = Internals.usePlaybackRate(); const sharedAudioContext = useContext(SharedAudioContext); const buffer = useBufferState(); diff --git a/packages/media/src/test/__screenshots__/player-loop-frame-accuracy.test.tsx/media-player-frame-1711-video-frame-91-loop-trim-after-step-back-chrome.png b/packages/media/src/test/__screenshots__/player-loop-frame-accuracy.test.tsx/media-player-frame-1711-video-frame-91-loop-trim-after-step-back-chrome.png index b35edd4c95e..e8c9a3f723a 100644 Binary files a/packages/media/src/test/__screenshots__/player-loop-frame-accuracy.test.tsx/media-player-frame-1711-video-frame-91-loop-trim-after-step-back-chrome.png and b/packages/media/src/test/__screenshots__/player-loop-frame-accuracy.test.tsx/media-player-frame-1711-video-frame-91-loop-trim-after-step-back-chrome.png differ diff --git a/packages/media/src/video/video-for-preview.tsx b/packages/media/src/video/video-for-preview.tsx index a32b791ed10..1191ca0d429 100644 --- a/packages/media/src/video/video-for-preview.tsx +++ b/packages/media/src/video/video-for-preview.tsx @@ -37,7 +37,6 @@ import {warnAboutObjectFitInStyleOrClassName} from './warn-object-fit-css'; const { useUnsafeVideoConfig, - Timeline, SharedAudioContext, usePlayerMutedState, useMediaVolumeState, @@ -47,6 +46,7 @@ const { usePreload, SequenceContext, useEffectChainState, + usePlaying, } = Internals; type VideoForPreviewProps = NativeVideoProps & { @@ -128,7 +128,7 @@ const VideoForPreviewAssertedShowing: React.FC< const [shouldFallbackToNativeVideo, setShouldFallbackToNativeVideo] = useState(false); - const [playing] = Timeline.usePlayingState(); + const playing = usePlaying(); const {playbackRate: globalPlaybackRate} = Internals.usePlaybackRate(); const sharedAudioContext = useContext(SharedAudioContext); const buffer = useBufferState(); diff --git a/packages/player/src/EmitterProvider.tsx b/packages/player/src/EmitterProvider.tsx index 3951660f2ce..f0d10599191 100644 --- a/packages/player/src/EmitterProvider.tsx +++ b/packages/player/src/EmitterProvider.tsx @@ -2,7 +2,6 @@ import React, {useContext, useEffect, useState} from 'react'; import {Internals} from 'remotion'; import {PlayerEventEmitterContext} from './emitter-context.js'; import {PlayerEmitter} from './event-emitter.js'; -import {TimelineImperativeCompatibilityProvider} from './timeline-imperative-context.js'; import {useBufferStateEmitter} from './use-buffer-state-emitter.js'; export const PlayerEmitterProvider: React.FC<{ @@ -24,10 +23,8 @@ export const PlayerEmitterProvider: React.FC<{ useBufferStateEmitter(emitter); return ( - - - {children} - - + + {children} + ); }; diff --git a/packages/player/src/Player.tsx b/packages/player/src/Player.tsx index 606885ba3a6..366c70f14ce 100644 --- a/packages/player/src/Player.tsx +++ b/packages/player/src/Player.tsx @@ -1,6 +1,7 @@ import type {ComponentType, LazyExoticComponent, RefObject} from 'react'; import React, { forwardRef, + useCallback, useEffect, useImperativeHandle, useLayoutEffect, @@ -242,10 +243,18 @@ const PlayerFn = < const [frame, setFrame] = useState>(() => ({ [PLAYER_COMP_ID]: initialFrame ?? 0, })); - const [playing, setPlaying] = useState(false); + const frameRef = useRef(frame); + frameRef.current = frame; const rootRef = useRef(null); const audioAndVideoTags = useRef([]); - const imperativePlaying = useRef(false); + const playingStore = useMemo( + () => Internals.createRuntimeValueStore({playing: false}), + [], + ); + const readIsPlaying = useCallback( + () => playingStore.store.getSnapshot().playing, + [playingStore], + ); const [currentPlaybackRate, setCurrentPlaybackRate] = useState(playbackRate); if (typeof compositionHeight !== 'number') { @@ -410,11 +419,10 @@ const PlayerFn = < const timelineContextValue = useMemo((): TimelineContextValue => { return { frame, - playing, - imperativePlaying, + isPlaying: readIsPlaying, audioAndVideoTags, }; - }, [frame, playing]); + }, [frame, readIsPlaying]); const playbackRateContextValue = useMemo((): PlaybackRateContextValue => { return { @@ -426,9 +434,20 @@ const PlayerFn = < const setTimelineContextValue = useMemo((): SetTimelineContextValue => { return { setFrame, - setPlaying, + setPlaying: (updater) => { + const current = playingStore.store.getSnapshot().playing; + const next = typeof updater === 'function' ? updater(current) : updater; + + if (current !== next) { + playingStore.setSnapshot({playing: next}); + } + }, + subscribePlaying: playingStore.store.subscribe, + frameRef, + isPlaying: readIsPlaying, + audioAndVideoTags, }; - }, [setFrame]); + }, [playingStore, setFrame, frameRef, readIsPlaying]); if (typeof window !== 'undefined') { // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/packages/player/src/PlayerUI.tsx b/packages/player/src/PlayerUI.tsx index 0e790aabb7c..ef75b1480c4 100644 --- a/packages/player/src/PlayerUI.tsx +++ b/packages/player/src/PlayerUI.tsx @@ -169,7 +169,7 @@ const PlayerUI: React.ForwardRefRenderFunction< }, []); const player = usePlayerMethods(); - const [playing] = Internals.Timeline.usePlayingState(); + const playing = Internals.usePlaying(); const frame = Internals.Timeline.useTimelinePosition(); const play = useCallback( diff --git a/packages/player/src/SharedPlayerContext.tsx b/packages/player/src/SharedPlayerContext.tsx index cf108730204..891337b26e9 100644 --- a/packages/player/src/SharedPlayerContext.tsx +++ b/packages/player/src/SharedPlayerContext.tsx @@ -10,11 +10,9 @@ import type { PlaybackRateContextValue, RemotionEnvironment, SetMediaVolumeContextValue, - TimelineImperativeContextValue, TimelineContextValue, } from 'remotion'; import {Internals} from 'remotion'; -import {CoreTimelineImperativeContextProvider} from './timeline-imperative-context.js'; import {getPreferredVolume, persistVolume} from './volume-persistence.js'; export const PLAYER_COMP_ID = 'player-comp'; @@ -160,61 +158,46 @@ export const SharedPlayerContexts: React.FC<{ }; }, []); - const frameRef = useRef(timelineContext.frame); - frameRef.current = timelineContext.frame; - const timelineImperativeContextValue = - useMemo((): TimelineImperativeContextValue => { - return { - frameRef, - imperativePlaying: timelineContext.imperativePlaying, - audioAndVideoTags: timelineContext.audioAndVideoTags, - }; - }, [timelineContext.audioAndVideoTags, timelineContext.imperativePlaying]); - return ( - - - - - - + + + + + - - - + + - - {children} - - - - - - - - - - + {children} + + + + + + + + + diff --git a/packages/player/src/Thumbnail.tsx b/packages/player/src/Thumbnail.tsx index 849a48d215a..8dfa6066b20 100644 --- a/packages/player/src/Thumbnail.tsx +++ b/packages/player/src/Thumbnail.tsx @@ -83,16 +83,14 @@ const ThumbnailFn = < } const rootRef = useRef(null); - const imperativePlaying = useRef(false); const audioAndVideoTags = useRef([]); const timelineState: TimelineContextValue = useMemo(() => { const value: TimelineContextValue = { - playing: false, + isPlaying: () => false, frame: { [PLAYER_COMP_ID]: frameToDisplay, }, - imperativePlaying, audioAndVideoTags, }; diff --git a/packages/player/src/browser-mediasession.ts b/packages/player/src/browser-mediasession.ts index b8290c032d7..6730cc9e678 100644 --- a/packages/player/src/browser-mediasession.ts +++ b/packages/player/src/browser-mediasession.ts @@ -23,7 +23,7 @@ export const useBrowserMediaSession = ({ videoConfig: VideoConfig | null; playbackRate: number; }) => { - const [playing] = Internals.Timeline.usePlayingState(); + const playing = Internals.usePlaying(); const {pause, play, emitter, getCurrentFrame, seek} = usePlayerMethods(); const hasEverPlayed = useRef(false); diff --git a/packages/player/src/test/audio-context-resume.test.tsx b/packages/player/src/test/audio-context-resume.test.tsx index d17fa9bdf77..4e0b575fa59 100644 --- a/packages/player/src/test/audio-context-resume.test.tsx +++ b/packages/player/src/test/audio-context-resume.test.tsx @@ -67,6 +67,7 @@ class FrozenAudioContext { const sequenceManager = { registerSequence: () => undefined, unregisterSequence: () => undefined, + updateSequence: null, sequences: [], }; diff --git a/packages/player/src/test/index.test.ts b/packages/player/src/test/index.test.ts index 6059ed025d3..2761b65fd69 100644 --- a/packages/player/src/test/index.test.ts +++ b/packages/player/src/test/index.test.ts @@ -3,6 +3,7 @@ import React, {createRef, useRef} from 'react'; import {Html5Audio, Internals, useCurrentFrame} from 'remotion'; import type {PlayerRef} from '../player-methods.js'; import {Player} from '../Player.js'; +import {Thumbnail} from '../Thumbnail.js'; import type {UsePlayerMethods} from '../use-player-methods.js'; import {usePlayerMethods} from '../use-player-methods.js'; import {act, cleanup, fireEvent, render} from './test-utils.js'; @@ -17,6 +18,26 @@ test('It should throw an error if not being used inside a RemotionRoot', () => { }).toThrow(); }); +const ThumbnailPlayingProbe = () => { + const playing = Internals.usePlaying(); + return React.createElement('div', null, playing ? 'playing' : 'paused'); +}; + +test('Thumbnail supports usePlaying in its composition', () => { + const view = render( + React.createElement(Thumbnail, { + component: ThumbnailPlayingProbe, + durationInFrames: 100, + compositionWidth: 1920, + compositionHeight: 1080, + fps: 30, + frameToDisplay: 0, + }), + ); + + expect(view.getByText('paused')).toBeTruthy(); +}); + test('Seeking to the current frame does not rerender the composition', () => { let compositionRenders = 0; const playerRef = createRef(); @@ -104,44 +125,6 @@ test('Imperative player methods do not rerender when the frame changes', () => { expect(methodRenders).toBe(rendersAfterMount); }); -test('Player methods fall back when core has no timeline imperative context', () => { - const internalsWithOptionalContext = Internals as { - TimelineImperativeContext?: typeof Internals.TimelineImperativeContext; - }; - const timelineImperativeContext = - internalsWithOptionalContext.TimelineImperativeContext; - delete internalsWithOptionalContext.TimelineImperativeContext; - - try { - const methodsRef: {current: UsePlayerMethods | null} = {current: null}; - const renderCustomControls = () => - React.createElement(PlayerMethodsProbe, { - onRender: (methods) => { - methodsRef.current = methods; - }, - }); - - render( - React.createElement(Player, { - component: () => null, - durationInFrames: 100, - compositionWidth: 1920, - compositionHeight: 1080, - fps: 30, - controls: true, - renderCustomControls, - }), - ); - - expect(methodsRef.current?.getCurrentFrame()).toBe(0); - act(() => methodsRef.current?.seek(20)); - expect(methodsRef.current?.getCurrentFrame()).toBe(20); - } finally { - internalsWithOptionalContext.TimelineImperativeContext = - timelineImperativeContext; - } -}); - test('Playing from the last frame resets playback and dismisses the unplayed poster', () => { const playerRef = createRef(); const Composition = () => null; @@ -229,6 +212,7 @@ const AudioComposition = () => { value: { registerSequence: () => undefined, unregisterSequence: () => undefined, + updateSequence: null, sequences: [], }, }, diff --git a/packages/player/src/test/keep-audio-context-alive.test.tsx b/packages/player/src/test/keep-audio-context-alive.test.tsx index 5bb4571af00..bbae8b1a576 100644 --- a/packages/player/src/test/keep-audio-context-alive.test.tsx +++ b/packages/player/src/test/keep-audio-context-alive.test.tsx @@ -70,6 +70,7 @@ class TrackedAudioContext { const sequenceManager = { registerSequence: () => undefined, unregisterSequence: () => undefined, + updateSequence: null, sequences: [], }; diff --git a/packages/player/src/timeline-imperative-context.tsx b/packages/player/src/timeline-imperative-context.tsx deleted file mode 100644 index 339c0136398..00000000000 --- a/packages/player/src/timeline-imperative-context.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import type {Context} from 'react'; -import React, {useMemo, useRef} from 'react'; -import type {TimelineImperativeContextValue} from 'remotion'; -import {Internals} from 'remotion'; - -const PlayerTimelineImperativeContext = - React.createContext(null); - -const getCoreTimelineImperativeContext = () => { - return ( - Internals as typeof Internals & { - TimelineImperativeContext?: Context; - } - ).TimelineImperativeContext; -}; - -export const getTimelineImperativeContext = () => { - return getCoreTimelineImperativeContext() ?? PlayerTimelineImperativeContext; -}; - -export const CoreTimelineImperativeContextProvider: React.FC<{ - readonly children: React.ReactNode; - readonly value: TimelineImperativeContextValue; -}> = ({children, value}) => { - const TimelineImperativeContext = getCoreTimelineImperativeContext(); - - if (!TimelineImperativeContext) { - return children; - } - - return ( - - {children} - - ); -}; - -const LegacyTimelineImperativeContextProvider: React.FC<{ - readonly children: React.ReactNode; -}> = ({children}) => { - const timelineContext = Internals.Timeline.useTimelineContext(); - const frameRef = useRef(timelineContext.frame); - frameRef.current = timelineContext.frame; - - const value = useMemo((): TimelineImperativeContextValue => { - return { - frameRef, - imperativePlaying: timelineContext.imperativePlaying, - audioAndVideoTags: timelineContext.audioAndVideoTags, - }; - }, [timelineContext.audioAndVideoTags, timelineContext.imperativePlaying]); - - return ( - - {children} - - ); -}; - -export const TimelineImperativeCompatibilityProvider: React.FC<{ - readonly children: React.ReactNode; -}> = ({children}) => { - if (getCoreTimelineImperativeContext()) { - return children; - } - - return ( - - {children} - - ); -}; diff --git a/packages/player/src/use-playback.ts b/packages/player/src/use-playback.ts index 0e10fd075c9..f3de81a6d51 100644 --- a/packages/player/src/use-playback.ts +++ b/packages/player/src/use-playback.ts @@ -50,7 +50,7 @@ export const usePlayback = ({ }) => { const config = Internals.useUnsafeVideoConfig(); const frame = Internals.Timeline.useTimelinePosition(); - const [playing] = Internals.Timeline.usePlayingState(); + const playing = Internals.usePlaying(); const {pause, emitter, isPlaying} = usePlayerMethods(); const setFrame = Internals.Timeline.useTimelineSetFrame(); const sharedAudioContext = useContext(Internals.SharedAudioContext); diff --git a/packages/player/src/use-player-methods.ts b/packages/player/src/use-player-methods.ts index e140726c08b..1d3abf30e75 100644 --- a/packages/player/src/use-player-methods.ts +++ b/packages/player/src/use-player-methods.ts @@ -3,7 +3,6 @@ import {useCallback, useContext, useMemo, useRef} from 'react'; import {Internals, useRemotionEnvironment} from 'remotion'; import {PlayerEventEmitterContext} from './emitter-context.js'; import type {PlayerEmitter} from './event-emitter.js'; -import {getTimelineImperativeContext} from './timeline-imperative-context.js'; export type UsePlayerMethods = { frameBack: (frames: number) => void; @@ -22,8 +21,12 @@ export type UsePlayerMethods = { export const usePlayerMethods = (): UsePlayerMethods => { const setFrame = Internals.Timeline.useTimelineSetFrame(); const setTimelinePosition = Internals.Timeline.useTimelineSetFrame(); - const {setPlaying} = useContext(Internals.SetTimelineContext); - const timelineImperativeContext = useContext(getTimelineImperativeContext()); + const { + setPlaying, + frameRef, + isPlaying: readIsPlaying, + audioAndVideoTags, + } = useContext(Internals.SetTimelineContext); const audioContext = useContext(Internals.SharedAudioContext); const audioTagsContext = useContext(Internals.SharedAudioTagsContext); const environment = useRemotionEnvironment(); @@ -33,12 +36,6 @@ export const usePlayerMethods = (): UsePlayerMethods => { const playStart = useRef(0); const fallbackFrame = useRef(null); - if (!timelineImperativeContext) { - throw new Error( - 'Timeline imperative context is not available. This hook must be used inside a or the Remotion Studio.', - ); - } - if (!emitter) { throw new TypeError('Expected Player event emitter context'); } @@ -61,7 +58,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { } const unclamped = - timelineImperativeContext.frameRef.current[video.id] ?? + frameRef.current[video.id] ?? (environment.isPlayer ? 0 : Internals.Timeline.getFrameForComposition(video.id)); @@ -70,7 +67,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { unclamped, video.durationInFrames, ); - }, [environment.isPlayer, timelineImperativeContext, video]); + }, [environment.isPlayer, frameRef, video]); const seek = useCallback( (newFrame: number) => { @@ -84,11 +81,9 @@ export const usePlayerMethods = (): UsePlayerMethods => { fallbackFrame.current = frameToSeekTo; if (video?.id) { - if ( - timelineImperativeContext.frameRef.current[video.id] !== frameToSeekTo - ) { - timelineImperativeContext.frameRef.current = { - ...timelineImperativeContext.frameRef.current, + if (frameRef.current[video.id] !== frameToSeekTo) { + frameRef.current = { + ...frameRef.current, [video.id]: frameToSeekTo, }; } @@ -102,18 +97,12 @@ export const usePlayerMethods = (): UsePlayerMethods => { emitter.dispatchSeek(frameToSeekTo); }, - [ - config, - emitter, - setTimelinePosition, - timelineImperativeContext, - video?.id, - ], + [config, emitter, frameRef, setTimelinePosition, video?.id], ); const play = useCallback( (e?: SyntheticEvent | PointerEvent) => { - if (timelineImperativeContext.imperativePlaying.current) { + if (readIsPlaying()) { return; } @@ -135,60 +124,59 @@ export const usePlayerMethods = (): UsePlayerMethods => { * Play audios and videos directly here so they can benefit from * being triggered by a click */ - timelineImperativeContext.audioAndVideoTags.current.forEach((tag) => + audioAndVideoTags.current.forEach((tag) => tag.play('player play() was called and playing audio from a click'), ); - timelineImperativeContext.imperativePlaying.current = true; setPlaying(true); playStart.current = getCurrentFrame(); emitter.dispatchPlay(); }, [ + audioAndVideoTags, audioContext, audioTagsContext, config?.durationInFrames, emitter, getCurrentFrame, + readIsPlaying, seek, setPlaying, - timelineImperativeContext, ], ); const pause = useCallback(() => { - if (timelineImperativeContext.imperativePlaying.current) { - timelineImperativeContext.imperativePlaying.current = false; - + if (readIsPlaying()) { setPlaying(false); + emitter.dispatchPause(); audioContext?.suspend(); } - }, [audioContext, emitter, setPlaying, timelineImperativeContext]); + }, [audioContext, emitter, readIsPlaying, setPlaying]); const pauseAndReturnToPlayStart = useCallback(() => { - if (timelineImperativeContext.imperativePlaying.current) { - timelineImperativeContext.imperativePlaying.current = false; + if (readIsPlaying()) { + setPlaying(false); fallbackFrame.current = playStart.current; if (config) { - timelineImperativeContext.frameRef.current = { - ...timelineImperativeContext.frameRef.current, + frameRef.current = { + ...frameRef.current, [config.id]: playStart.current, }; setTimelinePosition((currentFrames) => ({ ...currentFrames, [config.id]: playStart.current, })); - setPlaying(false); emitter.dispatchPause(); } } }, [ config, emitter, + frameRef, + readIsPlaying, setPlaying, setTimelinePosition, - timelineImperativeContext, ]); const videoId = video?.id; @@ -200,21 +188,19 @@ export const usePlayerMethods = (): UsePlayerMethods => { return null; } - if (timelineImperativeContext.imperativePlaying.current) { + if (readIsPlaying()) { return; } const previousFrame = - timelineImperativeContext.frameRef.current[videoId] ?? - window.remotion_initialFrame ?? - 0; + frameRef.current[videoId] ?? window.remotion_initialFrame ?? 0; const newFrame = Math.max(0, previousFrame - frames); if (previousFrame === newFrame) { return; } - timelineImperativeContext.frameRef.current = { - ...timelineImperativeContext.frameRef.current, + frameRef.current = { + ...frameRef.current, [videoId]: newFrame, }; setFrame((currentFrames) => @@ -223,7 +209,7 @@ export const usePlayerMethods = (): UsePlayerMethods => { : {...currentFrames, [videoId]: newFrame}, ); }, - [setFrame, timelineImperativeContext, videoId], + [frameRef, readIsPlaying, setFrame, videoId], ); const frameForward = useCallback( @@ -232,21 +218,19 @@ export const usePlayerMethods = (): UsePlayerMethods => { return null; } - if (timelineImperativeContext.imperativePlaying.current) { + if (readIsPlaying()) { return; } const previousFrame = - timelineImperativeContext.frameRef.current[videoId] ?? - window.remotion_initialFrame ?? - 0; + frameRef.current[videoId] ?? window.remotion_initialFrame ?? 0; const newFrame = Math.min(lastFrame, previousFrame + frames); if (previousFrame === newFrame) { return; } - timelineImperativeContext.frameRef.current = { - ...timelineImperativeContext.frameRef.current, + frameRef.current = { + ...frameRef.current, [videoId]: newFrame, }; setFrame((currentFrames) => @@ -255,23 +239,23 @@ export const usePlayerMethods = (): UsePlayerMethods => { : {...currentFrames, [videoId]: newFrame}, ); }, - [lastFrame, setFrame, timelineImperativeContext, videoId], + [frameRef, lastFrame, readIsPlaying, setFrame, videoId], ); const toggle = useCallback( (e?: SyntheticEvent | PointerEvent) => { - if (timelineImperativeContext.imperativePlaying.current) { + if (readIsPlaying()) { pause(); } else { play(e); } }, - [pause, play, timelineImperativeContext], + [pause, play, readIsPlaying], ); const isPlaying = useCallback(() => { - return timelineImperativeContext.imperativePlaying.current; - }, [timelineImperativeContext]); + return readIsPlaying(); + }, [readIsPlaying]); const isBuffering = useCallback(() => { return bufferingContext.buffering.current; diff --git a/packages/studio-codemods/src/index.ts b/packages/studio-codemods/src/index.ts index 4fdc2ef5ac2..2407371b272 100644 --- a/packages/studio-codemods/src/index.ts +++ b/packages/studio-codemods/src/index.ts @@ -790,39 +790,24 @@ const replaceNullRoot = ({ const replaceJsxElementRoot = ({ element, root, - sequenceLocalName, source, unit, - wrapRootInSequence, }: { element: string; root: AstNode; - sequenceLocalName: string | null; source: string; unit: string; - wrapRootInSequence: boolean; }): TextEdit => { const start = getPosition(root, 'start'); const indent = lineIndentAt(source, start); const original = source.slice(start, getPosition(root, 'end')); - if (wrapRootInSequence && sequenceLocalName === null) { - throw new Error('Expected a Sequence import for a self-closing root'); - } - - const existingRoot = wrapRootInSequence - ? [ - `${indent}${unit}<${sequenceLocalName}>`, - `${indent}${unit}${unit}${original}`, - `${indent}${unit}`, - ] - : [`${indent}${unit}${original}`]; return { start, end: getPosition(root, 'end'), text: [ '<>', - ...existingRoot, + `${indent}${unit}${original}`, `${indent}${unit}${element}`, `${indent}`, ].join('\n'), @@ -882,11 +867,8 @@ export const insertSolidIntoSource = ({ candidates: ['Solid', 'RemotionSolid'], importedName: 'Solid', }); - const openingElement = - root.type === 'JSXElement' ? getNode(root, 'openingElement') : null; - const isSelfClosing = openingElement?.selfClosing === true; const sequenceImport = - isSelfClosing || from !== null + from !== null ? chooseLocalName({ ast, candidates: ['Sequence', 'RemotionSequence'], @@ -922,10 +904,8 @@ export const insertSolidIntoSource = ({ ? replaceJsxElementRoot({ element, root, - sequenceLocalName: sequenceImport?.localName ?? null, source, unit, - wrapRootInSequence: isSelfClosing, }) : appendToJsxRoot({ element, diff --git a/packages/studio-codemods/src/insert-jsx-element.ts b/packages/studio-codemods/src/insert-jsx-element.ts index e15f465c472..4899ccdde7a 100644 --- a/packages/studio-codemods/src/insert-jsx-element.ts +++ b/packages/studio-codemods/src/insert-jsx-element.ts @@ -898,25 +898,6 @@ const createSequenceElement = (): namedTypes.JSXElement => { ); }; -const createSequenceWithChild = ({ - child, - sequenceLocalName, -}: { - child: namedTypes.JSXElement; - sequenceLocalName: string; -}): namedTypes.JSXElement => { - return recast.types.builders.jsxElement( - recast.types.builders.jsxOpeningElement( - recast.types.builders.jsxIdentifier(sequenceLocalName), - [], - ), - recast.types.builders.jsxClosingElement( - recast.types.builders.jsxIdentifier(sequenceLocalName), - ), - [child], - ); -}; - const createNumberAttribute = ( name: string, value: number, @@ -2007,12 +1988,7 @@ const addElementToComponentRoot = ({ } if (rootNode.type === 'JSXElement') { - const existingRoot = rootNode.openingElement.selfClosing - ? createSequenceWithChild({ - child: stripParenthesizedExtra(rootNode), - sequenceLocalName: ensureSequenceImport(ast), - }) - : stripParenthesizedExtra(rootNode); + const existingRoot = stripParenthesizedExtra(rootNode); const fragment = recast.types.builders.jsxFragment( recast.types.builders.jsxOpeningFragment(), recast.types.builders.jsxClosingFragment(), @@ -2734,14 +2710,12 @@ const getInsertionRootSourceEdit = ({ nullRoot, prettierConfigOverride, root, - sequenceLocalName, }: { input: string; insertion: string; nullRoot: NullLiteral | null; prettierConfigOverride: Record | null; root: namedTypes.JSXElement | namedTypes.JSXFragment | null; - sequenceLocalName: string | null; }): SourceEdit => { const endOfLine = input.includes('\r\n') ? '\r\n' : '\n'; const unit = getIndentationUnit(input, prettierConfigOverride); @@ -2813,33 +2787,17 @@ const getInsertionRootSourceEdit = ({ const end = recastLocToOffset(input, root.loc.end); const indent = getLineIndent(input, start); const original = input.slice(start, end); - const existingRoot = root.openingElement.selfClosing - ? [ - `${indent}${unit}<${sequenceLocalName}>`, - indentExistingJsx({ - indent: `${indent}${unit}${unit}`, - original, - originalIndent: indent, - }), - `${indent}${unit}`, - ] - : [ - indentExistingJsx({ - indent: `${indent}${unit}`, - original, - originalIndent: indent, - }), - ]; - - if (root.openingElement.selfClosing && sequenceLocalName === null) { - throw new Error('Expected a Sequence import for a self-closing root'); - } + const existingRoot = indentExistingJsx({ + indent: `${indent}${unit}`, + original, + originalIndent: indent, + }); return { end, replacement: [ '<>', - ...existingRoot, + existingRoot, indentInsertedJsx({indent: `${indent}${unit}`, insertion}), `${indent}`, ].join(endOfLine), @@ -3447,20 +3405,6 @@ export const insertJsxElementIntoComposition = async ({ exportName: location.exportName, element: finalElementToInsert, }); - const finalRoot = componentDeclaration - ? getComponentRootNode(componentDeclaration) - : null; - const firstFinalRootChild = - finalRoot?.type === 'JSXFragment' - ? (finalRoot.children?.[0] ?? null) - : null; - const sequenceLocalName = - rootBeforeInsertion?.type === 'JSXElement' && - rootBeforeInsertion.openingElement.selfClosing && - firstFinalRootChild?.type === 'JSXElement' && - firstFinalRootChild.openingElement.name.type === 'JSXIdentifier' - ? firstFinalRootChild.openingElement.name.name - : null; const output = applySourceEdits({ edits: [ ...getInsertImportSourceEdits({ @@ -3482,7 +3426,6 @@ export const insertJsxElementIntoComposition = async ({ nullRoot: nullRootBeforeInsertion, prettierConfigOverride, root: rootBeforeInsertion, - sequenceLocalName, }), ], input, diff --git a/packages/studio-protocol/package.json b/packages/studio-protocol/package.json index 0d194d7b4c5..d793ff0a16a 100644 --- a/packages/studio-protocol/package.json +++ b/packages/studio-protocol/package.json @@ -32,6 +32,9 @@ }, "./package.json": "./package.json" }, + "dependencies": { + "zod": "catalog:" + }, "devDependencies": { "@remotion/eslint-config-internal": "workspace:*", "eslint": "catalog:", 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 81527fabf57..7f8b3a41847 100644 --- a/packages/studio-protocol/src/add-element-library-to-studio.ts +++ b/packages/studio-protocol/src/add-element-library-to-studio.ts @@ -1,3 +1,4 @@ +import * as z from 'zod/mini'; import {isAllowedStudioProtocolPageOrigin} from './install-in-studio'; import type {StudioProtocolFetcher} from './studio-discovery'; import { @@ -9,7 +10,10 @@ import { isAbortError, studioProtocolProbePorts, } from './studio-discovery'; -import {isRecord} from './validation'; +import { + isAwaitingConfirmationResponse, + parseStudioProtocolError, +} from './studio-response'; export type AddElementLibraryToStudioInput = { readonly url: string; @@ -63,6 +67,25 @@ export type StudioProtocolAddElementLibraryRequest = { readonly displayName: string | null; }; +const studioProtocolAddElementLibraryRequestSchema = z.object({ + operation: z.literal('add-element-library'), + protocol: z.literal('remotion-studio-protocol'), + protocolVersion: z.literal(1), + targetId: z.string().check(z.minLength(1)), + url: z.string(), + displayName: z.nullable(z.string()), +}); + +export const parseStudioProtocolAddElementLibraryRequest = ( + value: unknown, +): StudioProtocolAddElementLibraryRequest | null => { + const parsed = z.safeParse( + studioProtocolAddElementLibraryRequestSchema, + value, + ); + return parsed.success ? parsed.data : null; +}; + const failure = ( code: AddElementLibraryToStudioErrorCode, message: string, @@ -227,13 +250,7 @@ export const addElementLibraryToStudioWithDependencies = async ( ); } - if ( - response.ok && - isRecord(result) && - result.protocol === 'remotion-studio-protocol' && - result.protocolVersion === 1 && - result.status === 'awaiting-confirmation' - ) { + if (response.ok && isAwaitingConfirmationResponse(result)) { return { success: true, status: 'awaiting-confirmation', @@ -245,21 +262,15 @@ export const addElementLibraryToStudioWithDependencies = async ( }; } - if ( - isRecord(result) && - result.status === 'error' && - isRecord(result.error) && - typeof result.error.code === 'string' && - typeof result.error.message === 'string' - ) { - const {code, message} = result.error; + const protocolError = parseStudioProtocolError(result); + if (protocolError !== null) { return failure( - code === 'target-expired' + protocolError.code === 'target-expired' ? 'target-expired' - : code === 'no-config-file' + : protocolError.code === 'no-config-file' ? 'no-config-file' : 'request-rejected', - message, + protocolError.message, ); } diff --git a/packages/studio-protocol/src/asset-drag-data.ts b/packages/studio-protocol/src/asset-drag-data.ts index 81076329dd4..3c197c39652 100644 --- a/packages/studio-protocol/src/asset-drag-data.ts +++ b/packages/studio-protocol/src/asset-drag-data.ts @@ -1,4 +1,4 @@ -import {isRecord} from './validation'; +import * as z from 'zod/mini'; export type AssetDragData = { type: 'remotion-asset'; @@ -6,6 +6,12 @@ export type AssetDragData = { assetPath: string; }; +const assetDragDataSchema = z.object({ + type: z.literal('remotion-asset'), + version: z.literal(1), + assetPath: z.string().check(z.minLength(1)), +}); + export const makeAssetDragData = (assetPath: string): AssetDragData => { return { type: 'remotion-asset', @@ -16,18 +22,8 @@ export const makeAssetDragData = (assetPath: string): AssetDragData => { export const parseAssetDragData = (value: string): AssetDragData | null => { try { - const parsed: unknown = JSON.parse(value); - if ( - !isRecord(parsed) || - parsed.type !== 'remotion-asset' || - parsed.version !== 1 || - typeof parsed.assetPath !== 'string' || - parsed.assetPath.length === 0 - ) { - return null; - } - - return makeAssetDragData(parsed.assetPath); + const parsed = z.safeParse(assetDragDataSchema, JSON.parse(value)); + return parsed.success ? makeAssetDragData(parsed.data.assetPath) : null; } catch { return null; } diff --git a/packages/studio-protocol/src/browser-studio-link.ts b/packages/studio-protocol/src/browser-studio-link.ts index 66663d4bc17..7d3121ca9ab 100644 --- a/packages/studio-protocol/src/browser-studio-link.ts +++ b/packages/studio-protocol/src/browser-studio-link.ts @@ -1,11 +1,16 @@ +import * as z from 'zod/mini'; import type {StudioElementPayload} from './element-payload'; import {parseStudioElementPayload} from './element-payload'; -import {isRecord} from './validation'; const browserStudioHashKey = 'remotion-browser-studio'; const defaultBrowserStudioEndpoint = 'https://www.remotion.dev/experimental_new'; const maxEncodedPayloadLength = 1_100_000; +const browserStudioEnvelopeSchema = z.object({ + type: z.literal('remotion-browser-studio'), + version: z.literal(1), + payload: z.unknown(), +}); const toBase64Url = (value: string) => { const bytes = new TextEncoder().encode(value); @@ -97,16 +102,13 @@ export const parseBrowserStudioHash = ( } try { - const envelope: unknown = JSON.parse(decoded); - if ( - !isRecord(envelope) || - envelope.type !== 'remotion-browser-studio' || - envelope.version !== 1 - ) { - return null; - } - - return parseStudioElementPayload(envelope.payload); + const envelope = z.safeParse( + browserStudioEnvelopeSchema, + JSON.parse(decoded), + ); + return envelope.success + ? parseStudioElementPayload(envelope.data.payload) + : null; } catch { return null; } diff --git a/packages/studio-protocol/src/component-drag-data.ts b/packages/studio-protocol/src/component-drag-data.ts index e68dcf129d5..2739d47ab10 100644 --- a/packages/studio-protocol/src/component-drag-data.ts +++ b/packages/studio-protocol/src/component-drag-data.ts @@ -1,4 +1,4 @@ -import {isRecord} from './validation'; +import * as z from 'zod/mini'; export type ComponentProp = { name: string; @@ -22,75 +22,69 @@ export type ComponentDragData = { }; }; -export const isComponentIdentifier = (value: unknown): value is string => { - return typeof value === 'string' && /^[A-Z_$][A-Za-z0-9_$]*$/.test(value); -}; - -export const isComponentImportPath = (value: unknown): value is string => { - return ( - typeof value === 'string' && - value.length > 0 && - value.length < 200 && - !value.includes('\\') && - !value.includes('\0') && - !value.startsWith('/') && - /^[A-Za-z0-9@._/-]+$/.test(value) - ); -}; - -export const isComponentPropName = (value: unknown): value is string => { - return ( - typeof value === 'string' && - value !== 'style' && - /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value) +const componentIdentifierSchema = z + .string() + .check(z.regex(/^[A-Z_$][A-Za-z0-9_$]*$/)); +const componentImportPathSchema = z + .string() + .check( + z.refine( + (value) => + value.length > 0 && + value.length < 200 && + !value.includes('\\') && + !value.includes('\0') && + !value.startsWith('/') && + /^[A-Za-z0-9@._/-]+$/.test(value), + ), ); -}; - -export const isComponentProp = (value: unknown): value is ComponentProp => { - if (!isRecord(value) || !isComponentPropName(value.name)) { - return false; - } - - return ( - typeof value.value === 'string' || - typeof value.value === 'boolean' || - (typeof value.value === 'number' && Number.isFinite(value.value)) - ); -}; - -export const areComponentProps = (value: unknown): value is ComponentProp[] => { - if (!Array.isArray(value)) { - return false; - } - - const seen = new Set(); - for (const prop of value) { - if (!isComponentProp(prop) || seen.has(prop.name)) { - return false; - } - - seen.add(prop.name); - } - - return true; -}; - -const isComponentDimensions = ( - value: unknown, -): value is ComponentDimensions => { - if (!isRecord(value)) { - return false; - } - - return ( - typeof value.width === 'number' && - Number.isFinite(value.width) && - value.width >= 0 && - typeof value.height === 'number' && - Number.isFinite(value.height) && - value.height >= 0 +const componentPropNameSchema = z + .string() + .check( + z.refine( + (value) => value !== 'style' && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value), + ), ); -}; +const componentPropSchema = z.object({ + name: componentPropNameSchema, + value: z.union([z.string(), z.number(), z.boolean()]), +}); +const componentPropsSchema = z.array(componentPropSchema).check( + z.refine((props) => { + const names = props.map((prop) => prop.name); + return new Set(names).size === names.length; + }), +); +const componentDimensionsSchema = z.object({ + width: z.number().check(z.nonnegative()), + height: z.number().check(z.nonnegative()), +}); +const componentDragDataSchema = z.object({ + type: z.literal('remotion-component'), + version: z.literal(1), + component: z.object({ + componentName: componentIdentifierSchema, + dimensions: z.optional(componentDimensionsSchema), + importName: componentIdentifierSchema, + importPath: componentImportPathSchema, + props: componentPropsSchema, + }), +}); + +export const isComponentIdentifier = (value: unknown): value is string => + z.safeParse(componentIdentifierSchema, value).success; + +export const isComponentImportPath = (value: unknown): value is string => + z.safeParse(componentImportPathSchema, value).success; + +export const isComponentPropName = (value: unknown): value is string => + z.safeParse(componentPropNameSchema, value).success; + +export const isComponentProp = (value: unknown): value is ComponentProp => + z.safeParse(componentPropSchema, value).success; + +export const areComponentProps = (value: unknown): value is ComponentProp[] => + z.safeParse(componentPropsSchema, value).success; export const makeComponentDragData = ({ componentName, @@ -122,34 +116,17 @@ export const parseComponentDragData = ( value: string, ): ComponentDragData | null => { try { - const parsed: unknown = JSON.parse(value); - if ( - !isRecord(parsed) || - parsed.type !== 'remotion-component' || - parsed.version !== 1 || - !isRecord(parsed.component) - ) { - return null; - } - - const {componentName, dimensions, importName, importPath, props} = - parsed.component; - if ( - !isComponentIdentifier(componentName) || - !isComponentIdentifier(importName) || - !isComponentImportPath(importPath) || - !areComponentProps(props) || - (typeof dimensions !== 'undefined' && !isComponentDimensions(dimensions)) - ) { + const parsed = z.safeParse(componentDragDataSchema, JSON.parse(value)); + if (!parsed.success) { return null; } return makeComponentDragData({ - componentName, - dimensions: dimensions ?? null, - importName, - importPath, - props, + componentName: parsed.data.component.componentName, + dimensions: parsed.data.component.dimensions ?? null, + importName: parsed.data.component.importName, + importPath: parsed.data.component.importPath, + props: parsed.data.component.props, }); } catch { return null; diff --git a/packages/studio-protocol/src/composition-drag-data.ts b/packages/studio-protocol/src/composition-drag-data.ts deleted file mode 100644 index 9e514dafa12..00000000000 --- a/packages/studio-protocol/src/composition-drag-data.ts +++ /dev/null @@ -1,72 +0,0 @@ -import {isRecord} from './validation'; - -export type CompositionDragData = { - type: 'remotion-composition'; - version: 1; - compositionId: string; - compositionFile: string | null; -}; - -const isCompositionId = (value: unknown): value is string => { - return ( - typeof value === 'string' && - value.length > 0 && - value.length < 500 && - /^([a-zA-Z0-9-\u4E00-\u9FFF])+$/.test(value) - ); -}; - -const isCompositionFile = (value: unknown): value is string | null => { - if (value === null) { - return true; - } - - return ( - typeof value === 'string' && - value.length > 0 && - value.length < 2000 && - !value.includes('\0') && - !value.includes('\\') && - !value.startsWith('/') && - !value.split('/').includes('..') - ); -}; - -export const makeCompositionDragData = ({ - compositionFile, - compositionId, -}: { - compositionFile: string | null; - compositionId: string; -}): CompositionDragData => { - return { - type: 'remotion-composition', - version: 1, - compositionFile, - compositionId, - }; -}; - -export const parseCompositionDragData = ( - value: string, -): CompositionDragData | null => { - try { - const parsed: unknown = JSON.parse(value); - if ( - !isRecord(parsed) || - parsed.type !== 'remotion-composition' || - parsed.version !== 1 || - !isCompositionId(parsed.compositionId) || - !isCompositionFile(parsed.compositionFile) - ) { - return null; - } - - return makeCompositionDragData({ - compositionFile: parsed.compositionFile, - compositionId: parsed.compositionId, - }); - } catch { - return null; - } -}; diff --git a/packages/studio-protocol/src/drag-data.ts b/packages/studio-protocol/src/drag-data.ts index 3f00b7e58d3..613ae0f72af 100644 --- a/packages/studio-protocol/src/drag-data.ts +++ b/packages/studio-protocol/src/drag-data.ts @@ -10,18 +10,12 @@ import { type ComponentDragData, type ComponentProp, } from './component-drag-data'; -import { - makeCompositionDragData, - parseCompositionDragData, - type CompositionDragData, -} from './composition-drag-data'; import { getDragPreviewMetadata, makeDragMimeType, parseDragMimeType, type AssetDragPreviewMetadata, type ComponentDragPreviewMetadata, - type CompositionDragPreviewMetadata, type DragPreviewMetadata, type EffectDragPreviewMetadata, type ElementDragPreviewMetadata, @@ -66,15 +60,6 @@ export type MakeComponentDragDataInput = { readonly props: ComponentProp[]; }; -export type MakeCompositionDragDataInput = { - readonly type: 'composition'; - readonly compositionFile: string | null; - readonly compositionId: string; - readonly width: number | null; - readonly height: number | null; - readonly durationInFrames: number | null; -}; - export type MakeEffectDragDataInput = EffectDragData['effect'] & { readonly type: 'effect'; }; @@ -97,7 +82,6 @@ export type MakeRenderOutputDragDataInput = { export type MakeDragDataInput = | MakeAssetDragDataInput | MakeComponentDragDataInput - | MakeCompositionDragDataInput | MakeEffectDragDataInput | MakeElementDragDataInput | MakeRenderOutputDragDataInput @@ -124,7 +108,6 @@ export type DragDataTransfer = { export type RemotionDragData = | AssetDragData | ComponentDragData - | CompositionDragData | EffectDragData | ElementDragData | RenderOutputDragData @@ -141,11 +124,6 @@ export type ParsedDragData = readonly data: ComponentDragData; readonly preview: ComponentDragPreviewMetadata; } - | { - readonly type: 'composition'; - readonly data: CompositionDragData; - readonly preview: CompositionDragPreviewMetadata; - } | { readonly type: 'effect'; readonly data: EffectDragData; @@ -178,33 +156,6 @@ const construct = ( }; }; -const makeCompositionPreview = ( - input: MakeCompositionDragDataInput, -): CompositionDragPreviewMetadata => { - if ( - input.width === undefined || - input.height === undefined || - input.durationInFrames === undefined - ) { - throw new TypeError( - 'width, height, and durationInFrames must be set to a value or null', - ); - } - - if ((input.width === null) !== (input.height === null)) { - throw new TypeError( - 'width and height must either both be numbers or both be null', - ); - } - - return { - type: input.type, - width: input.width ?? undefined, - height: input.height ?? undefined, - durationInFrames: input.durationInFrames ?? undefined, - }; -}; - const makeAssetPreview = ( input: MakeAssetDragDataInput, ): AssetDragPreviewMetadata => { @@ -235,9 +186,6 @@ const makeAssetPreview = ( type MakeDragData = { (input: MakeAssetDragDataInput): ConstructedDragData; (input: MakeComponentDragDataInput): ConstructedDragData; - ( - input: MakeCompositionDragDataInput, - ): ConstructedDragData; (input: MakeEffectDragDataInput): ConstructedDragData; (input: MakeElementDragDataInput): ConstructedDragData; ( @@ -270,14 +218,6 @@ export const makeDragData = (( ...(input.dimensions ?? {}), }, ); - case 'composition': - return construct( - makeCompositionDragData({ - compositionFile: input.compositionFile, - compositionId: input.compositionId, - }), - makeCompositionPreview(input), - ); case 'effect': return construct( makeEffectDragData({ @@ -375,12 +315,6 @@ export const parseDragData = ( return {type: preview.type, data, preview}; } - case 'composition': { - const data = parseCompositionDragData(payload); - - return data === null ? null : {type: preview.type, data, preview}; - } - case 'effect': { const data = parseEffectDragData(payload); diff --git a/packages/studio-protocol/src/drag-preview-metadata.ts b/packages/studio-protocol/src/drag-preview-metadata.ts index 288ba5db02b..781be929c54 100644 --- a/packages/studio-protocol/src/drag-preview-metadata.ts +++ b/packages/studio-protocol/src/drag-preview-metadata.ts @@ -19,11 +19,6 @@ export type ComponentDragPreviewMetadata = Dimensions & { readonly type: 'component'; }; -export type CompositionDragPreviewMetadata = Dimensions & { - readonly type: 'composition'; - readonly durationInFrames?: number; -}; - export type EffectDragPreviewMetadata = { readonly type: 'effect'; }; @@ -44,7 +39,6 @@ export type RenderOutputDragPreviewMetadata = { export type DragPreviewMetadata = | AssetDragPreviewMetadata | ComponentDragPreviewMetadata - | CompositionDragPreviewMetadata | EffectDragPreviewMetadata | ElementDragPreviewMetadata | RenderOutputDragPreviewMetadata @@ -101,16 +95,6 @@ const assertDuration = (metadata: DragPreviewMetadata) => { ); } - if ( - metadata.type === 'composition' && - metadata.durationInFrames !== undefined && - !isBoundedInteger(metadata.durationInFrames, MAX_DURATION_IN_FRAMES) - ) { - throw new TypeError( - `durationInFrames must be an integer between 1 and ${MAX_DURATION_IN_FRAMES}`, - ); - } - if ( metadata.type === 'element' && !isBoundedInteger(metadata.durationInFrames, MAX_DURATION_IN_FRAMES) @@ -126,7 +110,7 @@ const getDuration = (metadata: DragPreviewMetadata) => { return metadata.durationInSeconds; } - if (metadata.type === 'composition' || metadata.type === 'element') { + if (metadata.type === 'element') { return metadata.durationInFrames; } @@ -216,7 +200,6 @@ export const parseDragMimeType = ( if ( type !== 'asset' && type !== 'component' && - type !== 'composition' && type !== 'effect' && type !== 'element' && type !== 'render-output' && @@ -251,12 +234,7 @@ export const parseDragMimeType = ( } const durationValue = values.get('duration'); - if ( - durationValue !== undefined && - type !== 'asset' && - type !== 'composition' && - type !== 'element' - ) { + if (durationValue !== undefined && type !== 'asset' && type !== 'element') { return null; } @@ -292,14 +270,6 @@ export const parseDragMimeType = ( }; } - if (type === 'composition') { - return { - type, - ...dimensions, - ...(duration === undefined ? {} : {durationInFrames: duration}), - }; - } - return {type, ...dimensions}; }; diff --git a/packages/studio-protocol/src/effect-drag-data.ts b/packages/studio-protocol/src/effect-drag-data.ts index 910e89178c9..a0afc08f6a1 100644 --- a/packages/studio-protocol/src/effect-drag-data.ts +++ b/packages/studio-protocol/src/effect-drag-data.ts @@ -1,4 +1,16 @@ -import {isRecord} from './validation'; +import * as z from 'zod/mini'; + +export type EffectConfigValue = + | string + | number + | boolean + | null + | EffectConfig + | readonly EffectConfigValue[]; + +export type EffectConfig = { + readonly [key: string]: EffectConfigValue; +}; export type EffectDragData = { type: 'remotion-effect'; @@ -6,13 +18,28 @@ export type EffectDragData = { effect: { name: string; importPath: string; - config: Record; + config: EffectConfig; }; }; +const effectConfigSchema = z.record(z.string(), z.json()); +const effectDragDataSchema = z.object({ + type: z.literal('remotion-effect'), + version: z.literal(1), + effect: z.object({ + name: z.string(), + importPath: z.string(), + config: effectConfigSchema, + }), +}); + export const makeEffectDragData = ( effect: EffectDragData['effect'], ): EffectDragData => { + if (!z.safeParse(effectConfigSchema, effect.config).success) { + throw new TypeError('Effect config must contain only finite JSON values'); + } + return { type: 'remotion-effect', version: 1, @@ -22,26 +49,16 @@ export const makeEffectDragData = ( export const parseEffectDragData = (value: string): EffectDragData | null => { try { - const parsed: unknown = JSON.parse(value); - if ( - !isRecord(parsed) || - parsed.type !== 'remotion-effect' || - parsed.version !== 1 || - !isRecord(parsed.effect) - ) { - return null; - } - - const {name, importPath, config} = parsed.effect; - if ( - typeof name !== 'string' || - typeof importPath !== 'string' || - !isRecord(config) - ) { + const parsed = z.safeParse(effectDragDataSchema, JSON.parse(value)); + if (!parsed.success) { return null; } - return makeEffectDragData({name, importPath, config}); + return makeEffectDragData({ + name: parsed.data.effect.name, + importPath: parsed.data.effect.importPath, + config: parsed.data.effect.config, + }); } catch { return null; } diff --git a/packages/studio-protocol/src/element-drag-data.ts b/packages/studio-protocol/src/element-drag-data.ts index 51efda6ba39..4e43df0f4f9 100644 --- a/packages/studio-protocol/src/element-drag-data.ts +++ b/packages/studio-protocol/src/element-drag-data.ts @@ -1,8 +1,9 @@ +import * as z from 'zod/mini'; import { isComponentIdentifier, type ComponentDimensions, } from './component-drag-data'; -import {isRecord, isValidPackageName} from './validation'; +import {isValidPackageName} from './validation'; export type ElementInstallationMode = 'wrapped' | 'component-owned-sequence'; @@ -30,31 +31,40 @@ export type ElementDragData = { }; }; -export const isLowercaseElementFileName = (value: unknown): value is string => { - return ( - typeof value === 'string' && - value.length > 0 && - value.length < 120 && - value === value.toLowerCase() && - value.endsWith('.tsx') && - !value.includes('/') && - !value.includes('\\') && - !value.includes('\0') && - !value.includes('..') && - /^[a-z0-9][a-z0-9.-]*\.tsx$/.test(value) +const lowercaseElementFileNameSchema = z + .string() + .check( + z.refine( + (value) => + value.length > 0 && + value.length < 120 && + value === value.toLowerCase() && + value.endsWith('.tsx') && + !value.includes('/') && + !value.includes('\\') && + !value.includes('\0') && + !value.includes('..') && + /^[a-z0-9][a-z0-9.-]*\.tsx$/.test(value), + ), ); -}; - -const isSlug = (value: unknown): value is string => { - return ( - typeof value === 'string' && - value.length > 0 && - value.length < 120 && - /^[a-z0-9][a-z0-9/-]*$/.test(value) && - !value.includes('..') && - !value.includes('//') +const slugSchema = z + .string() + .check( + z.refine( + (value) => + value.length > 0 && + value.length < 120 && + /^[a-z0-9][a-z0-9/-]*$/.test(value) && + !value.includes('..') && + !value.includes('//'), + ), ); -}; + +export const isLowercaseElementFileName = (value: unknown): value is string => + z.safeParse(lowercaseElementFileNameSchema, value).success; + +const isSlug = (value: unknown): value is string => + z.safeParse(slugSchema, value).success; export const makeElementFileNameFromSlug = (slug: string) => { if (!isSlug(slug)) return null; @@ -83,36 +93,43 @@ const packagesProvidedByRemotionProjects = new Set([ 'remotion', ]); -const isExactVersion = (value: unknown): value is string => - typeof value === 'string' && - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test( - value, +const exactVersionSchema = z + .string() + .check( + z.regex( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, + ), ); +const elementDependencyEnvelopeSchema = z.looseObject({ + name: z.string().check(z.refine(isValidPackageName)), + version: z.unknown(), +}); const getElementDependencyError = (value: unknown): string | null => { - if ( - !isRecord(value) || - typeof value.name !== 'string' || - !isValidPackageName(value.name) - ) { + const parsed = z.safeParse(elementDependencyEnvelopeSchema, value); + if (!parsed.success) { return `Invalid Element dependency: ${JSON.stringify(value)}`; } - if (packagesProvidedByRemotionProjects.has(value.name)) { - return `${JSON.stringify(value.name)} is provided by Remotion projects and must not be declared as an Element dependency.`; + if (packagesProvidedByRemotionProjects.has(parsed.data.name)) { + return `${JSON.stringify(parsed.data.name)} is provided by Remotion projects and must not be declared as an Element dependency.`; } - if (value.name.startsWith('@remotion/')) { - return value.version === null + if (parsed.data.name.startsWith('@remotion/')) { + return parsed.data.version === null ? null - : `Remotion Element dependency ${JSON.stringify(value.name)} must use version: null.`; + : `Remotion Element dependency ${JSON.stringify(parsed.data.name)} must use version: null.`; } - return isExactVersion(value.version) + return z.safeParse(exactVersionSchema, parsed.data.version).success ? null - : `Non-Remotion Element dependency ${JSON.stringify(value.name)} must declare an exact version.`; + : `Non-Remotion Element dependency ${JSON.stringify(parsed.data.name)} must declare an exact version.`; }; +const elementDependencySchema = elementDependencyEnvelopeSchema.check( + z.refine((value) => getElementDependencyError(value) === null), +); + export function assertElementDependency( value: unknown, ): asserts value is ElementDependency { @@ -124,7 +141,8 @@ export function assertElementDependency( export const isElementDependency = ( value: unknown, -): value is ElementDependency => getElementDependencyError(value) === null; +): value is ElementDependency => + z.safeParse(elementDependencySchema, value).success; export const makeElementDragData = ({ dependencies, @@ -162,77 +180,67 @@ export const makeElementDragData = ({ }; }; -const isDimensions = (value: unknown): value is ComponentDimensions => - isRecord(value) && - typeof value.width === 'number' && - Number.isFinite(value.width) && - value.width > 0 && - typeof value.height === 'number' && - Number.isFinite(value.height) && - value.height > 0; - -const isElementInstallationMode = ( - value: unknown, -): value is ElementInstallationMode => - value === 'wrapped' || value === 'component-owned-sequence'; - -const isDuration = (value: unknown): value is number => - typeof value === 'number' && - Number.isInteger(value) && - value > 0 && - value <= 100_000_000; +const dimensionsSchema = z.object({ + width: z.number().check(z.positive()), + height: z.number().check(z.positive()), +}); +const elementInstallationModeSchema = z.union([ + z.literal('wrapped'), + z.literal('component-owned-sequence'), +]); +const durationSchema = z + .number() + .check(z.int(), z.positive(), z.lte(100_000_000)); +const elementDragDataSchema = z.object({ + type: z.literal('remotion-element'), + version: z.literal(1), + element: z.object({ + dependencies: z.array(z.unknown()).check(z.maxLength(100)), + durationInFrames: z.optional(durationSchema), + installationMode: z.optional(elementInstallationModeSchema), + slug: slugSchema, + displayName: z.string().check(z.minLength(1), z.maxLength(119)), + sourceCode: z + .string() + .check( + z.refine( + (value) => + value.trim().length > 0 && + value.length < 200_000 && + getElementComponentNameFromSourceCode(value) !== null, + ), + ), + dimensions: z.optional(z.nullable(dimensionsSchema)), + }), +}); export const parseElementDragData = (value: string): ElementDragData | null => { try { - const parsed: unknown = JSON.parse(value); + const parsed = z.safeParse(elementDragDataSchema, JSON.parse(value)); if ( - !isRecord(parsed) || - parsed.type !== 'remotion-element' || - parsed.version !== 1 || - !isRecord(parsed.element) - ) - return null; - const { - dependencies, - dimensions, - displayName, - durationInFrames, - slug, - sourceCode, - installationMode, - } = parsed.element; - const validDependencies = - Array.isArray(dependencies) && dependencies.length <= 100 - ? dependencies - : null; - if ( - !isSlug(slug) || - typeof displayName !== 'string' || - displayName.length === 0 || - displayName.length >= 120 || - typeof sourceCode !== 'string' || - sourceCode.trim().length === 0 || - sourceCode.length >= 200000 || - getElementComponentNameFromSourceCode(sourceCode) === null || - makeElementFileNameFromSlug(slug) === null || - validDependencies === null || - !validDependencies.every(isElementDependency) || - (durationInFrames !== undefined && !isDuration(durationInFrames)) || - (installationMode !== undefined && - !isElementInstallationMode(installationMode)) || - (dimensions !== undefined && - dimensions !== null && - !isDimensions(dimensions)) - ) + !parsed.success || + makeElementFileNameFromSlug(parsed.data.element.slug) === null + ) { return null; + } + + const dependencies: ElementDependency[] = []; + for (const dependency of parsed.data.element.dependencies) { + if (!isElementDependency(dependency)) { + return null; + } + + dependencies.push(dependency); + } + return makeElementDragData({ - dependencies: validDependencies, - dimensions: dimensions ?? null, - displayName, - durationInFrames, - slug, - sourceCode, - installationMode, + dependencies, + dimensions: parsed.data.element.dimensions ?? null, + displayName: parsed.data.element.displayName, + durationInFrames: parsed.data.element.durationInFrames, + slug: parsed.data.element.slug, + sourceCode: parsed.data.element.sourceCode, + installationMode: parsed.data.element.installationMode, }); } catch { return null; diff --git a/packages/studio-protocol/src/element-payload.ts b/packages/studio-protocol/src/element-payload.ts index 2c6c4561d71..38d75f302e2 100644 --- a/packages/studio-protocol/src/element-payload.ts +++ b/packages/studio-protocol/src/element-payload.ts @@ -1,3 +1,4 @@ +import * as z from 'zod/mini'; import type {ComponentDimensions} from './component-drag-data'; import {makeDragData} from './drag-data'; import { @@ -9,7 +10,6 @@ import { type ElementDragData, type ElementInstallationMode, } from './element-drag-data'; -import {isRecord} from './validation'; const MAX_PAYLOAD_SIZE = 250_000; const MAX_SOURCE_CODE_SIZE = 200_000; @@ -29,11 +29,16 @@ export type StudioElementPayload = ElementDragData & { readonly durationInFrames: number; }; +const durationSchema = z.int().check(z.positive(), z.lte(100_000_000)); +const studioElementPayloadEnvelopeSchema = z.object({ + type: z.literal('remotion-element'), + version: z.literal(1), + durationInFrames: durationSchema, + element: z.unknown(), +}); + const isDuration = (value: unknown): value is number => - typeof value === 'number' && - Number.isInteger(value) && - value >= 1 && - value <= 100_000_000; + z.safeParse(durationSchema, value).success; const assertCreateElementPayloadInput = ( input: CreateElementPayloadInput, @@ -112,19 +117,16 @@ export const createElementPayload = ( export const parseStudioElementPayload = ( value: unknown, ): StudioElementPayload | null => { - if ( - !isRecord(value) || - value.type !== 'remotion-element' || - value.version !== 1 || - !isDuration(value.durationInFrames) || - !isRecord(value.element) - ) + const parsed = z.safeParse(studioElementPayloadEnvelopeSchema, value); + if (!parsed.success) { return null; + } + const element = parseElementDragData( JSON.stringify({ - type: value.type, - version: value.version, - element: value.element, + type: parsed.data.type, + version: parsed.data.version, + element: parsed.data.element, }), ); if (element === null) return null; @@ -132,9 +134,9 @@ export const parseStudioElementPayload = ( ...element, element: { ...element.element, - durationInFrames: value.durationInFrames, + durationInFrames: parsed.data.durationInFrames, }, - durationInFrames: value.durationInFrames, + durationInFrames: parsed.data.durationInFrames, }; return JSON.stringify(payload).length <= MAX_PAYLOAD_SIZE ? payload : null; }; diff --git a/packages/studio-protocol/src/index.ts b/packages/studio-protocol/src/index.ts index dbee8616eac..d34d60832d8 100644 --- a/packages/studio-protocol/src/index.ts +++ b/packages/studio-protocol/src/index.ts @@ -1,4 +1,7 @@ -import {addElementLibraryToStudioWithDependencies} from './add-element-library-to-studio'; +import { + addElementLibraryToStudioWithDependencies, + parseStudioProtocolAddElementLibraryRequest, +} from './add-element-library-to-studio'; import { makeBrowserStudioUrl, openInBrowserStudio, @@ -16,9 +19,16 @@ import { makeElementFileNameFromSlug, } from './element-drag-data'; import {parseStudioElementPayload} from './element-payload'; -import {installInStudioWithDependencies} from './install-in-studio'; +import { + installInStudioWithDependencies, + parseStudioProtocolInstallRequest, +} from './install-in-studio'; import {isValidPublicLicenseKey} from './license-key'; -import {setLicenseKeyInStudio} from './set-license-key-in-studio'; +import { + parseStudioProtocolSetLicenseKeyRequest, + setLicenseKeyInStudio, +} from './set-license-key-in-studio'; +import {parseStudioProtocolDescriptor} from './studio-discovery'; export { addElementLibraryToStudio, @@ -32,13 +42,11 @@ export type { ComponentDragData, ComponentProp, } from './component-drag-data'; -export type {CompositionDragData} from './composition-drag-data'; export type { ConstructedDragData, DragDataTransfer, MakeAssetDragDataInput, MakeComponentDragDataInput, - MakeCompositionDragDataInput, MakeDragDataInput, MakeEffectDragDataInput, MakeElementDragDataInput, @@ -51,7 +59,6 @@ export type { export type { AssetDragPreviewMetadata, ComponentDragPreviewMetadata, - CompositionDragPreviewMetadata, DragPreviewMetadata, DragPreviewMetadataWithMimeType, EffectDragPreviewMetadata, @@ -60,7 +67,11 @@ export type { SfxDragPreviewMetadata, } from './drag-preview-metadata'; export {setStudioDragData} from './drag-transport'; -export type {EffectDragData} from './effect-drag-data'; +export type { + EffectConfig, + EffectConfigValue, + EffectDragData, +} from './effect-drag-data'; export type { ElementDependency, ElementDragData, @@ -100,5 +111,9 @@ export const StudioProtocolInternals = { parseBrowserStudioHash, parseDragData, parseStudioElementPayload, + parseStudioProtocolAddElementLibraryRequest, + parseStudioProtocolDescriptor, + parseStudioProtocolInstallRequest, + parseStudioProtocolSetLicenseKeyRequest, setLicenseKeyInStudio, }; diff --git a/packages/studio-protocol/src/install-in-studio.ts b/packages/studio-protocol/src/install-in-studio.ts index eb223bf5616..1a63de5d0bd 100644 --- a/packages/studio-protocol/src/install-in-studio.ts +++ b/packages/studio-protocol/src/install-in-studio.ts @@ -1,4 +1,8 @@ -import type {StudioElementPayload} from './element-payload'; +import * as z from 'zod/mini'; +import { + parseStudioElementPayload, + type StudioElementPayload, +} from './element-payload'; import type {StudioProtocolFetcher} from './studio-discovery'; import { discoverStudios, @@ -9,7 +13,10 @@ import { isAbortError, studioProtocolProbePorts, } from './studio-discovery'; -import {isRecord} from './validation'; +import { + isAwaitingConfirmationResponse, + parseStudioProtocolError, +} from './studio-response'; export type InstallInStudioErrorCode = | 'unsupported-origin' @@ -55,6 +62,44 @@ export type StudioProtocolInstallRequest = { readonly payload: StudioElementPayload; }; +const studioProtocolInstallRequestEnvelopeSchema = z.object({ + operation: z.literal('install-element'), + protocol: z.literal('remotion-studio-protocol'), + protocolVersion: z.literal(1), + targetId: z.string(), + payload: z.unknown(), +}); + +export const parseStudioProtocolInstallRequest = ( + value: unknown, +): + | {readonly status: 'valid'; readonly request: StudioProtocolInstallRequest} + | {readonly status: 'invalid-request' | 'invalid-payload'} => { + const envelope = z.safeParse( + studioProtocolInstallRequestEnvelopeSchema, + value, + ); + if (!envelope.success) { + return {status: 'invalid-request'}; + } + + const payload = parseStudioElementPayload(envelope.data.payload); + if (payload === null) { + return {status: 'invalid-payload'}; + } + + return { + status: 'valid', + request: { + operation: envelope.data.operation, + protocol: envelope.data.protocol, + protocolVersion: envelope.data.protocolVersion, + targetId: envelope.data.targetId, + payload, + }, + }; +}; + const failure = ( code: InstallInStudioErrorCode, message: string, @@ -195,13 +240,7 @@ export const installInStudioWithDependencies = async ( ); } - if ( - response.ok && - isRecord(result) && - result.protocol === 'remotion-studio-protocol' && - result.protocolVersion === 1 && - result.status === 'awaiting-confirmation' - ) { + if (response.ok && isAwaitingConfirmationResponse(result)) { return { success: true, status: 'awaiting-confirmation', @@ -214,17 +253,13 @@ export const installInStudioWithDependencies = async ( }; } - if ( - isRecord(result) && - result.status === 'error' && - isRecord(result.error) && - typeof result.error.code === 'string' && - typeof result.error.message === 'string' - ) { - const {code} = result.error; + const protocolError = parseStudioProtocolError(result); + if (protocolError !== null) { return failure( - code === 'target-expired' ? 'target-expired' : 'request-rejected', - result.error.message, + protocolError.code === 'target-expired' + ? 'target-expired' + : 'request-rejected', + protocolError.message, ); } diff --git a/packages/studio-protocol/src/render-output-drag-data.ts b/packages/studio-protocol/src/render-output-drag-data.ts index 43864e1a9dd..f4513ee390d 100644 --- a/packages/studio-protocol/src/render-output-drag-data.ts +++ b/packages/studio-protocol/src/render-output-drag-data.ts @@ -1,4 +1,4 @@ -import {isRecord} from './validation'; +import * as z from 'zod/mini'; export type RenderOutputDragData = { type: 'remotion-render-output'; @@ -7,6 +7,20 @@ export type RenderOutputDragData = { fileName: string; }; +const renderOutputDragDataSchema = z.object({ + type: z.literal('remotion-render-output'), + version: z.literal(1), + outputPath: z.string().check(z.minLength(1)), + fileName: z + .string() + .check( + z.refine( + (value) => + value.length > 0 && !value.includes('/') && !value.includes('\\'), + ), + ), +}); + export const makeRenderOutputDragData = ({ outputPath, fileName, @@ -26,25 +40,13 @@ export const parseRenderOutputDragData = ( value: string, ): RenderOutputDragData | null => { try { - const parsed: unknown = JSON.parse(value); - if ( - !isRecord(parsed) || - parsed.type !== 'remotion-render-output' || - parsed.version !== 1 || - typeof parsed.outputPath !== 'string' || - parsed.outputPath.length === 0 || - typeof parsed.fileName !== 'string' || - parsed.fileName.length === 0 || - parsed.fileName.includes('/') || - parsed.fileName.includes('\\') - ) { - return null; - } - - return makeRenderOutputDragData({ - outputPath: parsed.outputPath, - fileName: parsed.fileName, - }); + const parsed = z.safeParse(renderOutputDragDataSchema, JSON.parse(value)); + return parsed.success + ? makeRenderOutputDragData({ + outputPath: parsed.data.outputPath, + fileName: parsed.data.fileName, + }) + : null; } catch { return null; } 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 b17e4079c7f..54afb060538 100644 --- a/packages/studio-protocol/src/set-license-key-in-studio.ts +++ b/packages/studio-protocol/src/set-license-key-in-studio.ts @@ -1,3 +1,4 @@ +import * as z from 'zod/mini'; import {isValidPublicLicenseKey} from './license-key'; import type {StudioProtocolFetcher} from './studio-discovery'; import { @@ -9,7 +10,10 @@ import { isAbortError, studioProtocolProbePorts, } from './studio-discovery'; -import {isRecord} from './validation'; +import { + isAwaitingConfirmationResponse, + parseStudioProtocolError, +} from './studio-response'; export type SetLicenseKeyInStudioErrorCode = | 'invalid-license-key' @@ -54,6 +58,21 @@ export type StudioProtocolSetLicenseKeyRequest = { readonly licenseKey: string; }; +const studioProtocolSetLicenseKeyRequestSchema = z.object({ + operation: z.literal('set-license-key'), + protocol: z.literal('remotion-studio-protocol'), + protocolVersion: z.literal(1), + targetId: z.string().check(z.minLength(1)), + licenseKey: z.string(), +}); + +export const parseStudioProtocolSetLicenseKeyRequest = ( + value: unknown, +): StudioProtocolSetLicenseKeyRequest | null => { + const parsed = z.safeParse(studioProtocolSetLicenseKeyRequestSchema, value); + return parsed.success ? parsed.data : null; +}; + export const setLicenseKeyInStudioWithDependencies = async ( licenseKey: string, dependencies: SetLicenseKeyInStudioDependencies, @@ -182,13 +201,7 @@ export const setLicenseKeyInStudioWithDependencies = async ( }; } - if ( - response.ok && - isRecord(result) && - result.protocol === 'remotion-studio-protocol' && - result.protocolVersion === 1 && - result.status === 'awaiting-confirmation' - ) { + if (response.ok && isAwaitingConfirmationResponse(result)) { return { success: true, status: 'awaiting-confirmation', @@ -200,23 +213,17 @@ export const setLicenseKeyInStudioWithDependencies = async ( }; } - if ( - isRecord(result) && - result.status === 'error' && - isRecord(result.error) && - typeof result.error.code === 'string' && - typeof result.error.message === 'string' - ) { - const {code, message} = result.error; + const protocolError = parseStudioProtocolError(result); + if (protocolError !== null) { return { success: false, code: - code === 'target-expired' + protocolError.code === 'target-expired' ? 'target-expired' - : code === 'no-config-file' + : protocolError.code === 'no-config-file' ? 'no-config-file' : 'request-rejected', - message, + message: protocolError.message, }; } diff --git a/packages/studio-protocol/src/sfx-drag-data.ts b/packages/studio-protocol/src/sfx-drag-data.ts index c5ed8aeffc3..01b5848cfec 100644 --- a/packages/studio-protocol/src/sfx-drag-data.ts +++ b/packages/studio-protocol/src/sfx-drag-data.ts @@ -1,4 +1,5 @@ -import {isRecord, isUrl} from './validation'; +import * as z from 'zod/mini'; +import {isUrl} from './validation'; export type SfxDragData = { type: 'remotion-sfx'; @@ -9,6 +10,15 @@ export type SfxDragData = { }; }; +const sfxDragDataSchema = z.object({ + type: z.literal('remotion-sfx'), + version: z.literal(1), + sfx: z.object({ + name: z.string().check(z.minLength(1)), + url: z.string().check(z.refine(isUrl)), + }), +}); + export const makeSfxDragData = ({ name, url, @@ -22,27 +32,13 @@ export const makeSfxDragData = ({ export const parseSfxDragData = (value: string): SfxDragData | null => { try { - const parsed: unknown = JSON.parse(value); - if ( - !isRecord(parsed) || - parsed.type !== 'remotion-sfx' || - parsed.version !== 1 || - !isRecord(parsed.sfx) - ) { - return null; - } - - const {name, url} = parsed.sfx; - if ( - typeof name !== 'string' || - name.length === 0 || - typeof url !== 'string' || - !isUrl(url) - ) { - return null; - } - - return makeSfxDragData({name, url}); + const parsed = z.safeParse(sfxDragDataSchema, JSON.parse(value)); + return parsed.success + ? makeSfxDragData({ + name: parsed.data.sfx.name, + url: parsed.data.sfx.url, + }) + : null; } catch { return null; } diff --git a/packages/studio-protocol/src/studio-discovery.ts b/packages/studio-protocol/src/studio-discovery.ts index e1fc3b30fff..a0f2cb372d5 100644 --- a/packages/studio-protocol/src/studio-discovery.ts +++ b/packages/studio-protocol/src/studio-discovery.ts @@ -1,4 +1,4 @@ -import {isRecord} from './validation'; +import * as z from 'zod/mini'; export type StudioProtocolTarget = { readonly id: string; @@ -57,6 +57,67 @@ export const studioProtocolProbePorts = [ export const focusedStudioMaxAge = 5 * 60 * 1000; const requestTimeout = 2_000; +const targetSchema = z.looseObject({ + id: z.string().check(z.minLength(1)), + expiresAt: z.number(), + lastFocusedAt: z.number(), +}); +const installTargetSchema = z.looseObject({ + id: z.string().check(z.minLength(1)), + expiresAt: z.number(), + lastFocusedAt: z.number(), + compositionId: z.string().check(z.minLength(1)), +}); +const installCapabilitySchema = z.looseObject({ + type: z.literal('install-element'), + payloadType: z.literal('remotion-element'), + payloadVersions: z.array(z.number()), + target: z.nullable(installTargetSchema), +}); +const setLicenseKeyCapabilitySchema = z.looseObject({ + type: z.literal('set-license-key'), + target: z.nullable(targetSchema), +}); +const addElementLibraryCapabilitySchema = z.looseObject({ + type: z.literal('add-element-library'), + target: z.nullable(targetSchema), +}); +const capabilitySchema = z.union([ + installCapabilitySchema, + setLicenseKeyCapabilitySchema, + addElementLibraryCapabilitySchema, +]); +const descriptorEnvelopeSchema = z.looseObject({ + protocol: z.literal('remotion-studio-protocol'), + protocolVersion: z.literal(1), + studioVersion: z.string(), + projectName: z.nullable(z.string()), + capabilities: z.array(z.unknown()), +}); +const descriptorSchema = z + .looseObject({ + protocol: z.literal('remotion-studio-protocol'), + protocolVersion: z.literal(1), + studioVersion: z.string(), + projectName: z.nullable(z.string()), + capabilities: z.array(capabilitySchema), + }) + .check( + z.refine((descriptor) => { + const capabilityTypes = descriptor.capabilities.map( + (capability) => capability.type, + ); + return new Set(capabilityTypes).size === capabilityTypes.length; + }), + ); +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, @@ -78,90 +139,32 @@ export const fetchWithTimeout = async ({ } }; -const isNullableString = (value: unknown): value is string | null => - value === null || typeof value === 'string'; - -const isTarget = (value: unknown): value is StudioProtocolTarget => - isRecord(value) && - typeof value.id === 'string' && - value.id.length > 0 && - typeof value.expiresAt === 'number' && - Number.isFinite(value.expiresAt) && - typeof value.lastFocusedAt === 'number' && - Number.isFinite(value.lastFocusedAt); - -const isInstallTarget = ( - value: unknown, -): value is StudioProtocolInstallTarget => { - if (!isRecord(value)) { - return false; - } - - const {compositionId} = value; - return ( - isTarget(value) && - typeof compositionId === 'string' && - compositionId.length > 0 - ); -}; - -const isCapability = (value: unknown): value is StudioProtocolCapability => { - if (!isRecord(value)) { - return false; - } - - if (value.type === 'install-element') { - return ( - value.payloadType === 'remotion-element' && - Array.isArray(value.payloadVersions) && - value.payloadVersions.every((version) => typeof version === 'number') && - (value.target === null || isInstallTarget(value.target)) - ); - } - - if (value.type === 'set-license-key') { - return value.target === null || isTarget(value.target); - } - - return ( - value.type === 'add-element-library' && - (value.target === null || isTarget(value.target)) - ); -}; - export const isStudioProtocolDescriptor = ( value: unknown, -): value is StudioProtocolDescriptor => { - if ( - !isRecord(value) || - value.protocol !== 'remotion-studio-protocol' || - value.protocolVersion !== 1 || - typeof value.studioVersion !== 'string' || - !isNullableString(value.projectName) || - !Array.isArray(value.capabilities) || - !value.capabilities.every(isCapability) - ) { - return false; - } +): value is StudioProtocolDescriptor => + z.safeParse(descriptorSchema, value).success; - const capabilityTypes = value.capabilities.map( - (capability) => capability.type, - ); - return new Set(capabilityTypes).size === capabilityTypes.length; -}; - -const parseStudioProtocolDescriptor = ( +export const parseStudioProtocolDescriptor = ( value: unknown, ): StudioProtocolDescriptor | null => { - if (!isRecord(value) || !Array.isArray(value.capabilities)) { + const envelope = z.safeParse(descriptorEnvelopeSchema, value); + if (!envelope.success) { return null; } - const descriptor = { - ...value, - capabilities: value.capabilities.filter(isCapability), - }; - return isStudioProtocolDescriptor(descriptor) ? descriptor : null; + const capabilities: StudioProtocolCapability[] = []; + for (const capability of envelope.data.capabilities) { + const parsedCapability = z.safeParse(capabilitySchema, capability); + if (parsedCapability.success) { + capabilities.push(parsedCapability.data); + } + } + + const descriptor = z.safeParse(descriptorSchema, { + ...envelope.data, + capabilities, + }); + return descriptor.success ? descriptor.data : null; }; export const getInstallCapability = ( @@ -229,10 +232,13 @@ export const discoverStudios = async ( return null; } + const protocolVersionEnvelope = z.safeParse( + protocolVersionEnvelopeSchema, + value, + ); if ( - isRecord(value) && - value.protocol === 'remotion-studio-protocol' && - value.protocolVersion !== 1 + protocolVersionEnvelope.success && + protocolVersionEnvelope.data.protocolVersion !== 1 ) { foundUnsupportedProtocol = true; return null; @@ -276,8 +282,7 @@ export const hasLegacyStudio = async ( return false; } - const value: unknown = await response.json(); - return isRecord(value) && value.type === 'remotion-studio'; + return z.safeParse(legacyStudioSchema, await response.json()).success; } catch { return false; } diff --git a/packages/studio-protocol/src/studio-response.ts b/packages/studio-protocol/src/studio-response.ts new file mode 100644 index 00000000000..05f0cc516ce --- /dev/null +++ b/packages/studio-protocol/src/studio-response.ts @@ -0,0 +1,26 @@ +import * as z from 'zod/mini'; + +const awaitingConfirmationResponseSchema = z.object({ + protocol: z.literal('remotion-studio-protocol'), + protocolVersion: z.literal(1), + status: z.literal('awaiting-confirmation'), +}); +const studioProtocolErrorSchema = z.object({ + status: z.literal('error'), + error: z.object({ + code: z.string(), + message: z.string(), + }), +}); + +export const isAwaitingConfirmationResponse = (value: unknown): boolean => + z.safeParse(awaitingConfirmationResponseSchema, value).success; + +export const parseStudioProtocolError = ( + value: unknown, +): {readonly code: string; readonly message: string} | null => { + const parsed = z.safeParse(studioProtocolErrorSchema, value); + return parsed.success + ? {code: parsed.data.error.code, message: parsed.data.error.message} + : null; +}; diff --git a/packages/studio-shared/src/test/asset-drag-data.test.ts b/packages/studio-protocol/src/test/asset-drag-data.test.ts similarity index 94% rename from packages/studio-shared/src/test/asset-drag-data.test.ts rename to packages/studio-protocol/src/test/asset-drag-data.test.ts index 2255b9f337f..0eea34e6595 100644 --- a/packages/studio-shared/src/test/asset-drag-data.test.ts +++ b/packages/studio-protocol/src/test/asset-drag-data.test.ts @@ -1,5 +1,5 @@ import {expect, test} from 'bun:test'; -import {StudioProtocolInternals} from '@remotion/studio-protocol'; +import {StudioProtocolInternals} from '../index'; const assetMimeType = StudioProtocolInternals.makeDragData({ type: 'asset', diff --git a/packages/studio-shared/src/test/component-drag-data.test.ts b/packages/studio-protocol/src/test/component-drag-data.test.ts similarity index 99% rename from packages/studio-shared/src/test/component-drag-data.test.ts rename to packages/studio-protocol/src/test/component-drag-data.test.ts index c4e24746eaf..49cb4752702 100644 --- a/packages/studio-shared/src/test/component-drag-data.test.ts +++ b/packages/studio-protocol/src/test/component-drag-data.test.ts @@ -2,7 +2,7 @@ import {expect, test} from 'bun:test'; import { StudioProtocolInternals, type MakeComponentDragDataInput, -} from '@remotion/studio-protocol'; +} from '../index'; const makeComponentDragData = ( input: Omit, diff --git a/packages/studio-protocol/src/test/drag-data.test.ts b/packages/studio-protocol/src/test/drag-data.test.ts index c705483e9ca..346f6e719c5 100644 --- a/packages/studio-protocol/src/test/drag-data.test.ts +++ b/packages/studio-protocol/src/test/drag-data.test.ts @@ -1,7 +1,6 @@ import {expect, test} from 'bun:test'; import { StudioProtocolInternals, - type MakeCompositionDragDataInput, type MakeDragDataInput, type MakeElementDragDataInput, type MakeAssetDragDataInput, @@ -23,19 +22,15 @@ const inputs: MakeDragDataInput[] = [ importPath: '@remotion/shapes', props: [{name: 'radius', value: 100}], }, - { - type: 'composition', - compositionFile: 'src/Root.tsx', - compositionId: 'MyVideo', - width: 1920, - height: 1080, - durationInFrames: 150, - }, { type: 'effect', name: 'brightness', importPath: '@remotion/effects/brightness', - config: {brightness: 1.2}, + config: { + brightness: 1.2, + channels: ['red', 'green', 'blue'], + settings: {enabled: true, fallback: null}, + }, }, { type: 'element', @@ -64,6 +59,7 @@ test('constructs and parses all drag data families', () => { const parsed = StudioProtocolInternals.parseDragData(constructed); expect(parsed?.type).toBe(input.type); + expect(parsed?.data).toEqual(constructed.data); expect(constructed.mimeType).toStartWith( 'application/vnd.remotion.drag+json;v=1;type=', ); @@ -73,16 +69,16 @@ test('constructs and parses all drag data families', () => { test('puts preview metadata in the same MIME type as the payload', () => { const constructed = StudioProtocolInternals.makeDragData({ - type: 'composition', - compositionFile: 'src/Root.tsx', - compositionId: 'MyVideo', - width: 1920, - height: 1080, - durationInFrames: 150, + type: 'component', + componentName: 'Circle', + dimensions: {width: 200, height: 200}, + importName: 'Circle', + importPath: '@remotion/shapes', + props: [], }); expect(constructed.mimeType).toBe( - 'application/vnd.remotion.drag+json;v=1;type=composition;width=1920;height=1080;duration=150', + 'application/vnd.remotion.drag+json;v=1;type=component;width=200;height=200', ); expect( StudioProtocolInternals.getDragPreviewMetadata([ @@ -90,64 +86,13 @@ test('puts preview metadata in the same MIME type as the payload', () => { constructed.mimeType, ]), ).toEqual({ - type: 'composition', + type: 'component', mimeType: constructed.mimeType, - width: 1920, - height: 1080, - durationInFrames: 150, - }); - expect(StudioProtocolInternals.parseDragData(constructed)).toEqual({ - type: 'composition', - data: { - type: 'remotion-composition', - version: 1, - compositionFile: 'src/Root.tsx', - compositionId: 'MyVideo', - }, - preview: { - type: 'composition', - width: 1920, - height: 1080, - durationInFrames: 150, - }, + width: 200, + height: 200, }); }); -test('accepts explicit null composition metadata', () => { - const constructed = StudioProtocolInternals.makeDragData({ - type: 'composition', - compositionFile: null, - compositionId: 'UnresolvedVideo', - width: null, - height: null, - durationInFrames: null, - }); - - expect(constructed.mimeType).toBe( - 'application/vnd.remotion.drag+json;v=1;type=composition', - ); - expect(StudioProtocolInternals.parseDragData(constructed)?.preview).toEqual({ - type: 'composition', - }); - expect(() => - StudioProtocolInternals.makeDragData({ - type: 'composition', - compositionFile: null, - compositionId: 'MissingMetadata', - } as MakeCompositionDragDataInput), - ).toThrow('must be set to a value or null'); - expect(() => - StudioProtocolInternals.makeDragData({ - type: 'composition', - compositionFile: null, - compositionId: 'PartiallyResolvedDimensions', - width: 1920, - height: null, - durationInFrames: null, - }), - ).toThrow('must either both be numbers or both be null'); -}); - test('requires asset metadata and accepts explicit null values', () => { const constructed = StudioProtocolInternals.makeDragData({ type: 'asset', @@ -211,6 +156,22 @@ test('requires a duration for element drags', () => { }); test('rejects malformed and mismatched drag data', () => { + expect(() => + StudioProtocolInternals.makeDragData({ + type: 'effect', + name: 'brightness', + importPath: '@remotion/effects/brightness', + config: {brightness: Number.POSITIVE_INFINITY}, + }), + ).toThrow('Effect config must contain only finite JSON values'); + expect( + StudioProtocolInternals.parseDragData({ + mimeType: 'application/vnd.remotion.drag+json;v=1;type=effect', + payload: + '{"type":"remotion-effect","version":1,"effect":{"name":"brightness","importPath":"@remotion/effects/brightness","config":{"brightness":1e400}}}', + }), + ).toBe(null); + const renderOutput = StudioProtocolInternals.makeDragData({ type: 'render-output', outputPath: 'out/video.mp4', @@ -242,7 +203,8 @@ test('rejects malformed and mismatched drag data', () => { ).toBe(null); const invalidMimeTypes = [ - 'application/vnd.remotion.drag+json;v=2;type=composition', + 'application/vnd.remotion.drag+json;v=2;type=component', + 'application/vnd.remotion.drag+json;v=1;type=composition', 'application/vnd.remotion.drag+json;v=1;type=unknown', 'application/vnd.remotion.drag+json;v=1;type=element;width=1920', 'application/vnd.remotion.drag+json;v=1;type=effect;duration=10', diff --git a/packages/studio-shared/src/test/element-drag-data.test.ts b/packages/studio-protocol/src/test/element-drag-data.test.ts similarity index 98% rename from packages/studio-shared/src/test/element-drag-data.test.ts rename to packages/studio-protocol/src/test/element-drag-data.test.ts index 5f4a0c85087..90259658c05 100644 --- a/packages/studio-shared/src/test/element-drag-data.test.ts +++ b/packages/studio-protocol/src/test/element-drag-data.test.ts @@ -1,8 +1,5 @@ import {expect, test} from 'bun:test'; -import { - StudioProtocolInternals, - type ElementDragData, -} from '@remotion/studio-protocol'; +import {StudioProtocolInternals, type ElementDragData} from '../index'; type ElementInput = Omit; diff --git a/packages/studio-shared/src/test/sfx-drag-data.test.ts b/packages/studio-protocol/src/test/sfx-drag-data.test.ts similarity index 84% rename from packages/studio-shared/src/test/sfx-drag-data.test.ts rename to packages/studio-protocol/src/test/sfx-drag-data.test.ts index 85fb70285c4..582501297cf 100644 --- a/packages/studio-shared/src/test/sfx-drag-data.test.ts +++ b/packages/studio-protocol/src/test/sfx-drag-data.test.ts @@ -1,9 +1,5 @@ import {expect, test} from 'bun:test'; -import { - StudioProtocolInternals, - type SfxDragData, -} from '@remotion/studio-protocol'; -import {isUrl} from '../url'; +import {StudioProtocolInternals, type SfxDragData} from '../index'; const sfxMimeType = StudioProtocolInternals.makeDragData({ type: 'sfx', @@ -44,9 +40,6 @@ test('accepts any URL in SFX drag data', () => { expect(parseSfxDragData(JSON.stringify(nonRemotionUrlDragData))).toEqual( nonRemotionUrlDragData, ); - expect(isUrl('https://remotion.media/whip.wav')).toBe(true); - expect(isUrl('https://example.com/whip.wav')).toBe(true); - expect(isUrl('not-a-url')).toBe(false); }); test('rejects invalid SFX drag data', () => { diff --git a/packages/studio-protocol/src/test/studio-discovery.test.ts b/packages/studio-protocol/src/test/studio-discovery.test.ts new file mode 100644 index 00000000000..9b3fa9379eb --- /dev/null +++ b/packages/studio-protocol/src/test/studio-discovery.test.ts @@ -0,0 +1,40 @@ +import {expect, test} from 'bun:test'; +import {StudioProtocolInternals} from '../index'; + +const installCapability = { + type: 'install-element' as const, + payloadType: 'remotion-element' as const, + payloadVersions: [1], + target: { + id: 'target', + expiresAt: 2_000, + lastFocusedAt: 1_000, + compositionId: 'Main', + }, + futureCapabilityMetadata: true, +}; + +const descriptor = { + protocol: 'remotion-studio-protocol' as const, + protocolVersion: 1 as const, + studioVersion: '4.0.519', + projectName: 'Project', + capabilities: [installCapability, {type: 'future-capability'}], + futureDescriptorMetadata: true, +}; + +test('filters future capabilities, preserves extensions, and rejects duplicates', () => { + const parsed = + StudioProtocolInternals.parseStudioProtocolDescriptor(descriptor); + expect(parsed).toEqual({ + ...descriptor, + capabilities: [installCapability], + }); + + expect( + StudioProtocolInternals.parseStudioProtocolDescriptor({ + ...descriptor, + capabilities: [installCapability, installCapability], + }), + ).toBe(null); +}); diff --git a/packages/studio-protocol/src/validation.ts b/packages/studio-protocol/src/validation.ts index a7ddf298850..cc5c990785e 100644 --- a/packages/studio-protocol/src/validation.ts +++ b/packages/studio-protocol/src/validation.ts @@ -1,7 +1,3 @@ -export const isRecord = (value: unknown): value is Record => { - return typeof value === 'object' && value !== null && !Array.isArray(value); -}; - export const isUrl = (value: string): boolean => { try { const parsed = new URL(value); diff --git a/packages/studio-server/src/preview-server/routes/insert-element.ts b/packages/studio-server/src/preview-server/routes/insert-element.ts index 254871d7412..f80a484463b 100644 --- a/packages/studio-server/src/preview-server/routes/insert-element.ts +++ b/packages/studio-server/src/preview-server/routes/insert-element.ts @@ -74,7 +74,10 @@ export const insertElementHandler: ApiHandler< throw new Error('from must be a non-negative integer'); } - const installationMode = element.installationMode ?? 'wrapped'; + const installationMode = + element.installationMode === null + ? 'wrapped' + : element.installationMode; const componentOwnsSequence = installationMode === 'component-owned-sequence'; @@ -150,7 +153,7 @@ export const insertElementHandler: ApiHandler< importPath: plan.importPath, props: componentOwnsSequence ? [ - ...(element.durationInFrames === undefined + ...(element.durationInFrames === null ? [] : [ { @@ -169,7 +172,7 @@ export const insertElementHandler: ApiHandler< ? null : { dimensions: element.dimensions, - durationInFrames: element.durationInFrames ?? null, + durationInFrames: element.durationInFrames, from, name: element.displayName, position, diff --git a/packages/studio-server/src/preview-server/studio-protocol/handle-element-library.ts b/packages/studio-server/src/preview-server/studio-protocol/handle-element-library.ts index 9e541137b46..f91ea272d51 100644 --- a/packages/studio-server/src/preview-server/studio-protocol/handle-element-library.ts +++ b/packages/studio-server/src/preview-server/studio-protocol/handle-element-library.ts @@ -1,5 +1,5 @@ import type {IncomingMessage, ServerResponse} from 'node:http'; -import {z} from 'zod'; +import {StudioProtocolInternals} from '@remotion/studio-protocol'; import {consumeStudioProtocolTarget} from '../element-install-state'; import type {LiveEventsServer} from '../live-events'; import {parseRequestBody, RequestBodyTooLargeError} from '../parse-body'; @@ -11,15 +11,6 @@ import {writeStudioProtocolError} from './protocol-response'; type FocusStudioTab = (studioUrl: string) => void; -const studioProtocolElementLibraryRequestSchema = z.object({ - operation: z.literal('add-element-library'), - protocol: z.literal('remotion-studio-protocol'), - protocolVersion: z.literal(1), - targetId: z.string().min(1), - url: z.string(), - displayName: z.string().nullable(), -}); - const MAX_STUDIO_PROTOCOL_ELEMENT_LIBRARY_BODY_SIZE = 16 * 1024; export const handleStudioProtocolElementLibrary = async ({ @@ -83,8 +74,8 @@ export const handleStudioProtocolElementLibrary = async ({ } const parsedRequest = - studioProtocolElementLibraryRequestSchema.safeParse(body); - if (!parsedRequest.success) { + StudioProtocolInternals.parseStudioProtocolAddElementLibraryRequest(body); + if (parsedRequest === null) { writeStudioProtocolError({ code: 'unsupported-protocol', message: 'Invalid Remotion Studio Protocol request.', @@ -96,7 +87,7 @@ export const handleStudioProtocolElementLibrary = async ({ let normalizedUrl: string; try { - const parsedUrl = new URL(parsedRequest.data.url); + const parsedUrl = new URL(parsedRequest.url); if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { throw new Error('Unsupported protocol'); } @@ -112,7 +103,7 @@ export const handleStudioProtocolElementLibrary = async ({ return; } - const displayName = parsedRequest.data.displayName?.trim() ?? null; + const displayName = parsedRequest.displayName?.trim() ?? null; if (displayName === '') { writeStudioProtocolError({ code: 'invalid-display-name', @@ -127,7 +118,7 @@ export const handleStudioProtocolElementLibrary = async ({ now: Date.now(), origin: requestOrigin, purpose: 'add-element-library', - targetId: parsedRequest.data.targetId, + targetId: parsedRequest.targetId, }); if (target === null) { writeStudioProtocolError({ diff --git a/packages/studio-server/src/preview-server/studio-protocol/handle-install.ts b/packages/studio-server/src/preview-server/studio-protocol/handle-install.ts index 4c613f06086..0981c85ac46 100644 --- a/packages/studio-server/src/preview-server/studio-protocol/handle-install.ts +++ b/packages/studio-server/src/preview-server/studio-protocol/handle-install.ts @@ -1,7 +1,9 @@ import type {IncomingMessage, ServerResponse} from 'node:http'; -import {StudioProtocolInternals} from '@remotion/studio-protocol'; +import { + StudioProtocolInternals, + type StudioElementPayload, +} from '@remotion/studio-protocol'; import type {ElementInstallRequest} from '@remotion/studio-shared'; -import {z} from 'zod'; import type {getElementInstallTarget} from '../element-install-state'; import {consumeStudioProtocolTarget} from '../element-install-state'; import type {LiveEventsServer} from '../live-events'; @@ -14,14 +16,6 @@ import {writeStudioProtocolError} from './protocol-response'; type FocusStudioTab = (studioUrl: string) => void; -const studioProtocolInstallRequestSchema = z.object({ - operation: z.literal('install-element'), - protocol: z.literal('remotion-studio-protocol'), - protocolVersion: z.literal(1), - targetId: z.string(), - payload: z.unknown(), -}); - // A valid payload may contain 250,000 JSON characters. Allow room for its // UTF-8 representation and the protocol envelope while keeping memory bounded. const MAX_STUDIO_PROTOCOL_INSTALL_BODY_SIZE = 1_000_000; @@ -33,7 +27,7 @@ const deliverElementInstall = ({ origin, target, }: { - readonly element: ElementInstallRequest['element']; + readonly element: StudioElementPayload['element']; readonly focusStudioTab: FocusStudioTab; readonly liveEventsServer: LiveEventsServer; readonly origin: string; @@ -49,7 +43,11 @@ const deliverElementInstall = ({ createdAt: Date.now(), compositionFile: target.compositionFile, compositionId: target.compositionId, - element, + element: { + ...element, + durationInFrames: element.durationInFrames ?? null, + installationMode: element.installationMode ?? null, + }, from: null, position: null, source: { @@ -127,24 +125,18 @@ export const handleStudioProtocolInstall = async ({ return; } - const parsedRequest = studioProtocolInstallRequestSchema.safeParse(body); - if (!parsedRequest.success) { - writeStudioProtocolError({ - code: 'unsupported-protocol', - message: 'Invalid Remotion Studio Protocol request.', - response, - status: 400, - }); - return; - } - - const payload = StudioProtocolInternals.parseStudioElementPayload( - parsedRequest.data.payload, - ); - if (payload === null) { + const parsedRequest = + StudioProtocolInternals.parseStudioProtocolInstallRequest(body); + if (parsedRequest.status !== 'valid') { writeStudioProtocolError({ - code: 'invalid-payload', - message: 'Invalid Element payload.', + code: + parsedRequest.status === 'invalid-payload' + ? 'invalid-payload' + : 'unsupported-protocol', + message: + parsedRequest.status === 'invalid-payload' + ? 'Invalid Element payload.' + : 'Invalid Remotion Studio Protocol request.', response, status: 400, }); @@ -155,7 +147,7 @@ export const handleStudioProtocolInstall = async ({ now: Date.now(), origin: requestOrigin, purpose: 'install-element', - targetId: parsedRequest.data.targetId, + targetId: parsedRequest.request.targetId, }); if (target === null) { writeStudioProtocolError({ @@ -169,7 +161,7 @@ export const handleStudioProtocolInstall = async ({ if ( !deliverElementInstall({ - element: payload.element, + element: parsedRequest.request.payload.element, focusStudioTab, liveEventsServer, origin: requestOrigin, diff --git a/packages/studio-server/src/preview-server/studio-protocol/handle-license-key.ts b/packages/studio-server/src/preview-server/studio-protocol/handle-license-key.ts index 5f602e4e7e7..5e1b8d813cc 100644 --- a/packages/studio-server/src/preview-server/studio-protocol/handle-license-key.ts +++ b/packages/studio-server/src/preview-server/studio-protocol/handle-license-key.ts @@ -1,6 +1,5 @@ import type {IncomingMessage, ServerResponse} from 'node:http'; import {StudioProtocolInternals} from '@remotion/studio-protocol'; -import {z} from 'zod'; import {consumeStudioProtocolTarget} from '../element-install-state'; import type {LiveEventsServer} from '../live-events'; import {parseRequestBody, RequestBodyTooLargeError} from '../parse-body'; @@ -12,14 +11,6 @@ import {writeStudioProtocolError} from './protocol-response'; type FocusStudioTab = (studioUrl: string) => void; -const studioProtocolLicenseKeyRequestSchema = z.object({ - operation: z.literal('set-license-key'), - protocol: z.literal('remotion-studio-protocol'), - protocolVersion: z.literal(1), - targetId: z.string().min(1), - licenseKey: z.string(), -}); - const MAX_STUDIO_PROTOCOL_LICENSE_KEY_BODY_SIZE = 4096; export const handleStudioProtocolLicenseKey = async ({ @@ -82,8 +73,9 @@ export const handleStudioProtocolLicenseKey = async ({ return; } - const parsedRequest = studioProtocolLicenseKeyRequestSchema.safeParse(body); - if (!parsedRequest.success) { + const parsedRequest = + StudioProtocolInternals.parseStudioProtocolSetLicenseKeyRequest(body); + if (parsedRequest === null) { writeStudioProtocolError({ code: 'unsupported-protocol', message: 'Invalid Remotion Studio Protocol request.', @@ -94,9 +86,7 @@ export const handleStudioProtocolLicenseKey = async ({ } if ( - !StudioProtocolInternals.isValidPublicLicenseKey( - parsedRequest.data.licenseKey, - ) + !StudioProtocolInternals.isValidPublicLicenseKey(parsedRequest.licenseKey) ) { writeStudioProtocolError({ code: 'invalid-license-key', @@ -111,7 +101,7 @@ export const handleStudioProtocolLicenseKey = async ({ now: Date.now(), origin: requestOrigin, purpose: 'set-license-key', - targetId: parsedRequest.data.targetId, + targetId: parsedRequest.targetId, }); if (target === null) { writeStudioProtocolError({ @@ -135,7 +125,7 @@ export const handleStudioProtocolLicenseKey = async ({ const delivered = liveEventsServer.sendEventToClientId(target.clientId, { type: 'license-key-install-request', - licenseKey: parsedRequest.data.licenseKey, + licenseKey: parsedRequest.licenseKey, }); if (!delivered) { writeStudioProtocolError({ diff --git a/packages/studio-server/src/test/insert-element.test.ts b/packages/studio-server/src/test/insert-element.test.ts index f8dcc79577c..8ac0c982432 100644 --- a/packages/studio-server/src/test/insert-element.test.ts +++ b/packages/studio-server/src/test/insert-element.test.ts @@ -54,6 +54,7 @@ const element: InsertElementRequest['element'] = { dependencies: [], dimensions: {width: 900, height: 260}, durationInFrames: 72, + installationMode: null, displayName: 'Lower Third', slug: 'overlays/lower-third', sourceCode: incomingElementSource, diff --git a/packages/studio-server/src/test/resolve-composition-component.test.ts b/packages/studio-server/src/test/resolve-composition-component.test.ts index 7f01bd12513..347f9c96c72 100644 --- a/packages/studio-server/src/test/resolve-composition-component.test.ts +++ b/packages/studio-server/src/test/resolve-composition-component.test.ts @@ -422,7 +422,7 @@ test('canAddSequence=true for self-closing root JSX return', async () => { } }); -test('wraps a self-closing root in a Sequence before inserting', async () => { +test('inserts a Solid next to a self-closing root without wrapping it', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'remotion-resolve-')); try { await fs.writeFile( @@ -452,12 +452,9 @@ test('wraps a self-closing root in a Sequence before inserting', async () => { compositionFile: 'Root.tsx', compositionId: 'test', element: { - type: 'asset', - assetType: 'audio', - src: 'music.mp3', - srcType: 'static', - dimensions: null, - durationInFrames: null, + type: 'solid', + width: 1920, + height: 1080, position: null, }, from: null, @@ -465,14 +462,14 @@ test('wraps a self-closing root in a Sequence before inserting', async () => { }); expect(result.output).toContain( - "import {staticFile, Sequence} from 'remotion';", + "import {staticFile, Solid} from 'remotion';", ); - expect(result.output).toContain(''); + expect(result.output).not.toContain('', ); - expect(result.output).toContain(''); - expect(result.output).toContain("