From 08ab33eded8c071f67df50f558518a83017d5377 Mon Sep 17 00:00:00 2001 From: JonnyBurger Date: Thu, 14 May 2026 10:33:31 +0200 Subject: [PATCH 01/73] `@remotion/studio`: Save effect props back to source Allows visually editing _experimentalEffects props on Sequence/HtmlInCanvas in the Studio and saving them back to the source file, mirroring the existing sequence prop editing flow. - Add `factoryName` to `EffectDefinition` and `sourceIndex` to descriptors so the Studio can map runtime descriptors back to their source array position with reorder safety. - New `EffectOverridesContext` set by `wrapInSchema`; `useMemoizedEffects` merges drag overrides > code values > runtime params per effect. - Extend `/api/subscribe-to-sequence-props` with effects[] subscriptions and effects[] statuses in the response/SSE updates. - New `/api/save-effect-props` route + `update-effect-props` codemod (verifies factoryName, updates/removes property on first-arg ObjectExpression, suppresses HMR, supports undo/redo). - New `TimelineEffectFieldRow` component dispatched from `TimelineExpandedRow` for editable effect rows in the timeline. Co-authored-by: Cursor --- packages/core/src/SequenceManager.tsx | 147 +++++++- packages/core/src/effects/create-effect.ts | 1 + packages/core/src/effects/effect-internals.ts | 8 +- .../src/effects/effect-overrides-context.ts | 18 + packages/core/src/effects/effect-types.ts | 14 + .../core/src/effects/use-memoized-effects.ts | 122 ++++++- packages/core/src/internals.ts | 17 +- .../core/src/test/effect-internals.test.ts | 2 + .../src/test/sequence-register-once.test.tsx | 8 + packages/core/src/use-schema.ts | 11 + packages/core/src/wrap-in-schema.ts | 29 +- packages/effects/src/blur/blur-horizontal.ts | 1 + packages/effects/src/blur/blur-vertical.ts | 1 + packages/effects/src/halftone.ts | 1 + packages/effects/src/tint.ts | 1 + packages/effects/src/wave.ts | 1 + .../light-leaks/src/light-leak-internals.ts | 1 + packages/starburst/src/starburst-effect.ts | 1 + .../update-effect-props.ts | 317 ++++++++++++++++++ .../src/preview-server/api-routes.ts | 2 + .../routes/can-update-effect-props.ts | 247 ++++++++++++++ .../routes/can-update-sequence-props.ts | 104 +++++- .../routes/save-effect-props.ts | 141 ++++++++ .../routes/save-sequence-props.ts | 12 +- .../routes/subscribe-to-sequence-props.ts | 6 +- .../preview-server/sequence-props-watchers.ts | 13 +- .../src/preview-server/undo-stack.ts | 2 + .../test/compute-effect-props-status.test.ts | 144 ++++++++ .../compute-sequence-props-status.test.ts | 6 + .../src/test/unsupported-translate.test.ts | 1 + .../src/test/update-effect-props.test.ts | 115 +++++++ packages/studio-shared/src/api-requests.ts | 39 ++- packages/studio-shared/src/index.ts | 9 + .../src/optimistic-update-for-code-values.ts | 1 + ...ptimistic-update-for-effect-code-values.ts | 66 ++++ .../studio-shared/src/schema-field-info.ts | 59 +++- .../optimistic-update-for-code-values.test.ts | 3 + ...stic-update-for-effect-code-values.test.ts | 101 ++++++ .../Timeline/SubscribeToNodePaths.tsx | 28 +- .../src/components/Timeline/Timeline.tsx | 1 + .../Timeline/TimelineEffectFieldRow.tsx | 210 ++++++++++++ .../Timeline/TimelineExpandedRow.tsx | 13 + .../components/Timeline/TimelineFieldRow.tsx | 12 +- .../sequence-props-subscription-store.ts | 4 + .../use-sequence-props-subscription.ts | 5 + .../studio/src/helpers/timeline-layout.ts | 62 ++-- 46 files changed, 2009 insertions(+), 98 deletions(-) create mode 100644 packages/core/src/effects/effect-overrides-context.ts create mode 100644 packages/studio-server/src/codemods/update-effect-props/update-effect-props.ts create mode 100644 packages/studio-server/src/preview-server/routes/can-update-effect-props.ts create mode 100644 packages/studio-server/src/preview-server/routes/save-effect-props.ts create mode 100644 packages/studio-server/src/test/compute-effect-props-status.test.ts create mode 100644 packages/studio-server/src/test/update-effect-props.test.ts create mode 100644 packages/studio-shared/src/optimistic-update-for-effect-code-values.ts create mode 100644 packages/studio-shared/src/test/optimistic-update-for-effect-code-values.test.ts create mode 100644 packages/studio/src/components/Timeline/TimelineEffectFieldRow.tsx diff --git a/packages/core/src/SequenceManager.tsx b/packages/core/src/SequenceManager.tsx index 40125ca3572..a46381a6805 100644 --- a/packages/core/src/SequenceManager.tsx +++ b/packages/core/src/SequenceManager.tsx @@ -4,8 +4,11 @@ import type { CanUpdateSequencePropStatus, CodeValues, DragOverrides, + EffectDragOverrides, GetCodeValues, GetDragOverrides, + GetEffectCodeValues, + GetEffectDragOverrides, } from './use-schema.js'; export type SequenceManagerContext = { @@ -45,10 +48,12 @@ export const SequenceVisibilityToggleContext = export type VisualModeCodeValues = { getCodeValues: GetCodeValues; + getEffectCodeValues: GetEffectCodeValues; }; export type VisualModeDragOverrides = { getDragOverrides: GetDragOverrides; + getEffectDragOverrides: GetEffectDragOverrides; }; export type VisualModeSetters = { @@ -58,6 +63,16 @@ export type VisualModeSetters = { value: unknown, ) => void; clearDragOverrides: (nodePath: SequenceNodePath) => void; + setEffectDragOverrides: ( + nodePath: SequenceNodePath, + effectIndex: number, + key: string, + value: unknown, + ) => void; + clearEffectDragOverrides: ( + nodePath: SequenceNodePath, + effectIndex: number, + ) => void; setCodeValues: ( nodePath: SequenceNodePath, values: ( @@ -66,9 +81,33 @@ export type VisualModeSetters = { ) => void; }; +export type CanUpdateEffectPropsResponseTrue = { + canUpdate: true; + effectIndex: number; + factoryName: string; + props: Record; +}; + +export type CanUpdateEffectPropsResponseFalse = { + canUpdate: false; + effectIndex: number; + factoryName: string; + reason: + | 'effect-reordered' + | 'no-args-object' + | 'not-found' + | 'computed' + | 'not-call-expression'; +}; + +export type CanUpdateEffectPropsResponse = + | CanUpdateEffectPropsResponseTrue + | CanUpdateEffectPropsResponseFalse; + export type CanUpdateSequencePropsResponseTrue = { canUpdate: true; props: Record; + effects: CanUpdateEffectPropsResponse[]; }; export type CanUpdateSequencePropsResponseFalse = { @@ -96,6 +135,24 @@ const getCodeValuesCtx = ( return status.props; }; +const getEffectCodeValuesCtx = ( + codeValues: CodeValues, + nodePath: SequenceNodePath, + effectIndex: number, +) => { + const status = codeValues[nodePathToString(nodePath)]; + if (!status || !status.canUpdate) { + return undefined; + } + + const effect = status.effects.find((e) => e.effectIndex === effectIndex); + if (!effect || !effect.canUpdate) { + return undefined; + } + + return effect.props; +}; + export type GetCodeValuesType = typeof getCodeValuesCtx; export const VisualModeCodeValuesContext = @@ -103,6 +160,9 @@ export const VisualModeCodeValuesContext = getCodeValues: () => { throw new Error('VisualModeCodeValuesContext not initialized'); }, + getEffectCodeValues: () => { + throw new Error('VisualModeCodeValuesContext not initialized'); + }, }); export const VisualModeDragOverridesContext = @@ -110,6 +170,9 @@ export const VisualModeDragOverridesContext = getDragOverrides: () => { throw new Error('VisualModeDragOverridesContext not initialized'); }, + getEffectDragOverrides: () => { + throw new Error('VisualModeDragOverridesContext not initialized'); + }, }); export const VisualModeSettersContext = React.createContext({ @@ -119,11 +182,22 @@ export const VisualModeSettersContext = React.createContext({ clearDragOverrides: () => { throw new Error('VisualModeSettersContext not initialized'); }, + setEffectDragOverrides: () => { + throw new Error('VisualModeSettersContext not initialized'); + }, + clearEffectDragOverrides: () => { + throw new Error('VisualModeSettersContext not initialized'); + }, setCodeValues: () => { throw new Error('VisualModeSettersContext not initialized'); }, }); +const effectDragOverridesKey = ( + nodePath: SequenceNodePath, + effectIndex: number, +): string => `${nodePathToString(nodePath)}.effects.${effectIndex}`; + export const SequenceManagerProvider: React.FC<{ readonly children: React.ReactNode; }> = ({children}) => { @@ -132,6 +206,8 @@ export const SequenceManagerProvider: React.FC<{ const [dragOverrides, setControlOverrides] = useState({}); const controlOverridesRef = useRef(dragOverrides); controlOverridesRef.current = dragOverrides; + const [effectDragOverridesState, setEffectDragOverridesState] = + useState({}); const [codeValues, setCodeValuesMapState] = useState({}); const setDragOverrides = useCallback( @@ -160,6 +236,43 @@ export const SequenceManagerProvider: React.FC<{ }); }, []); + const setEffectDragOverrides = useCallback( + ( + nodePath: SequenceNodePath, + effectIndex: number, + key: string, + value: unknown, + ) => { + setEffectDragOverridesState((prev) => { + const mapKey = effectDragOverridesKey(nodePath, effectIndex); + return { + ...prev, + [mapKey]: { + ...prev[mapKey], + [key]: value, + }, + }; + }); + }, + [], + ); + + const clearEffectDragOverrides = useCallback( + (nodePath: SequenceNodePath, effectIndex: number) => { + setEffectDragOverridesState((prev) => { + const mapKey = effectDragOverridesKey(nodePath, effectIndex); + if (!prev[mapKey]) { + return prev; + } + + const next = {...prev}; + delete next[mapKey]; + return next; + }); + }, + [], + ); + const setCodeValues = useCallback( ( nodePath: SequenceNodePath, @@ -215,6 +328,17 @@ export const SequenceManagerProvider: React.FC<{ [dragOverrides], ); + const getEffectDragOverrides = useCallback( + (nodePath: SequenceNodePath, effectIndex: number) => { + return ( + effectDragOverridesState[ + effectDragOverridesKey(nodePath, effectIndex) + ] ?? {} + ); + }, + [effectDragOverridesState], + ); + const getCodeValues = useCallback( (nodePath: SequenceNodePath) => { return getCodeValuesCtx(codeValues, nodePath); @@ -222,25 +346,42 @@ export const SequenceManagerProvider: React.FC<{ [codeValues], ); + const getEffectCodeValues = useCallback( + (nodePath: SequenceNodePath, effectIndex: number) => { + return getEffectCodeValuesCtx(codeValues, nodePath, effectIndex); + }, + [codeValues], + ); + const codeValuesContext: VisualModeCodeValues = useMemo(() => { return { getCodeValues, + getEffectCodeValues, }; - }, [getCodeValues]); + }, [getCodeValues, getEffectCodeValues]); const dragOverridesContext: VisualModeDragOverrides = useMemo(() => { return { getDragOverrides, + getEffectDragOverrides, }; - }, [getDragOverrides]); + }, [getDragOverrides, getEffectDragOverrides]); const settersContext: VisualModeSetters = useMemo(() => { return { setDragOverrides, clearDragOverrides, + setEffectDragOverrides, + clearEffectDragOverrides, setCodeValues, }; - }, [setDragOverrides, clearDragOverrides, setCodeValues]); + }, [ + setDragOverrides, + clearDragOverrides, + setEffectDragOverrides, + clearEffectDragOverrides, + setCodeValues, + ]); return ( diff --git a/packages/core/src/effects/create-effect.ts b/packages/core/src/effects/create-effect.ts index 516d9cbcb37..5e3daec65ae 100644 --- a/packages/core/src/effects/create-effect.ts +++ b/packages/core/src/effects/create-effect.ts @@ -23,6 +23,7 @@ export const createEffect = ( params, stack: new Error().stack!, effectKey: widened.calculateKey(params), + sourceIndex: -1, memoized: false, }); return factory as EffectFactory

; diff --git a/packages/core/src/effects/effect-internals.ts b/packages/core/src/effects/effect-internals.ts index 260a3ca4a8f..fa973960105 100644 --- a/packages/core/src/effects/effect-internals.ts +++ b/packages/core/src/effects/effect-internals.ts @@ -12,13 +12,15 @@ export const flattenEffects = ( effects: EffectsProp, ): EffectDescriptor[] => { const out: EffectDescriptor[] = []; - for (const item of effects) { + for (let sourceIndex = 0; sourceIndex < effects.length; sourceIndex++) { + const item = effects[sourceIndex]; if (Array.isArray(item)) { for (const inner of item) { - out.push(inner); + out.push({...inner, sourceIndex}); } } else { - out.push(item as EffectDescriptor); + const descriptor = item as EffectDescriptor; + out.push({...descriptor, sourceIndex}); } } diff --git a/packages/core/src/effects/effect-overrides-context.ts b/packages/core/src/effects/effect-overrides-context.ts new file mode 100644 index 00000000000..4aff34aa8c8 --- /dev/null +++ b/packages/core/src/effects/effect-overrides-context.ts @@ -0,0 +1,18 @@ +import {createContext} from 'react'; +import type {SequenceNodePath} from '../SequenceManager.js'; + +/** + * Provides the resolved `nodePath` of the JSX element whose + * `_experimentalEffects` prop is being rendered. Used by + * `useMemoizedEffects` to look up live drag overrides and + * code values for individual effects when rendering inside + * the Studio. + */ +export type EffectOverridesContextValue = { + nodePath: SequenceNodePath | null; +}; + +export const EffectOverridesContext = + createContext({ + nodePath: null, + }); diff --git a/packages/core/src/effects/effect-types.ts b/packages/core/src/effects/effect-types.ts index aec317701d4..19429c2be0f 100644 --- a/packages/core/src/effects/effect-types.ts +++ b/packages/core/src/effects/effect-types.ts @@ -32,6 +32,13 @@ export type EffectApplyParams = { export type EffectDefinition = { readonly type: string; readonly label: string; + /** + * Public source identifier of the factory function the user calls in their + * code (e.g. `'tint'` for `tint({...})`). Used by the studio to verify + * that an effect at array index `i` in `_experimentalEffects` still + * matches the runtime effect when saving prop edits back to source. + */ + readonly factoryName: string; readonly backend: Backend; /** * Stable string for comparing effect instances: two descriptors with the same @@ -51,6 +58,13 @@ type BaseEffectDescriptor

= { readonly stack: string; readonly effectKey: string; readonly params: P; + /** + * Index of this descriptor in the user's `_experimentalEffects` source array + * (pre-flatten). Compound factories like `blur()` produce multiple + * descriptors that share the same `sourceIndex`. `-1` means the descriptor + * has not yet been placed in an effects array (e.g. fresh from `createEffect`). + */ + readonly sourceIndex: number; }; export type EffectDescriptor

= BaseEffectDescriptor

& { diff --git a/packages/core/src/effects/use-memoized-effects.ts b/packages/core/src/effects/use-memoized-effects.ts index 313537607f1..9501ed9c184 100644 --- a/packages/core/src/effects/use-memoized-effects.ts +++ b/packages/core/src/effects/use-memoized-effects.ts @@ -1,37 +1,133 @@ -import {useRef} from 'react'; +import {useContext, useRef} from 'react'; +import { + VisualModeCodeValuesContext, + VisualModeDragOverridesContext, +} from '../SequenceManager.js'; +import {EffectOverridesContext} from './effect-overrides-context.js'; import type { EffectDefinitionAndStack, EffectDescriptor, } from './effect-types.js'; +const mergeOverrides = ({ + descriptor, + codeOverrides, + dragOverrides, +}: { + descriptor: EffectDescriptor; + codeOverrides: Record | null; + dragOverrides: Record | null; +}): {params: unknown; effectKey: string} => { + if (!codeOverrides && !dragOverrides) { + return {params: descriptor.params, effectKey: descriptor.effectKey}; + } + + const merged: Record = { + ...(descriptor.params as Record), + }; + + if (codeOverrides) { + for (const [key, value] of Object.entries(codeOverrides)) { + if (value !== undefined) { + merged[key] = value; + } + } + } + + if (dragOverrides) { + for (const [key, value] of Object.entries(dragOverrides)) { + merged[key] = value; + } + } + + return { + params: merged, + effectKey: descriptor.definition.calculateKey(merged), + }; +}; + +const extractCodeOverrides = ( + propStatus: + | Record + | undefined, +): Record | null => { + if (!propStatus) { + return null; + } + + const out: Record = {}; + let hasAny = false; + for (const [key, status] of Object.entries(propStatus)) { + if (status.canUpdate && 'codeValue' in status) { + out[key] = status.codeValue; + hasAny = true; + } + } + + return hasAny ? out : null; +}; + export const useMemoizedEffects = ( effects: EffectDescriptor[], ): EffectDefinitionAndStack[] => { const previousRef = useRef[] | null>(null); + const {nodePath} = useContext(EffectOverridesContext); + const {getEffectCodeValues} = useContext(VisualModeCodeValuesContext); + const {getEffectDragOverrides} = useContext(VisualModeDragOverridesContext); + + const resolved = effects.map((descriptor) => { + if (nodePath === null) { + return { + descriptor, + params: descriptor.params, + effectKey: descriptor.effectKey, + }; + } + + const propStatus = getEffectCodeValues(nodePath, descriptor.sourceIndex); + const codeOverrides = extractCodeOverrides(propStatus); + const dragOverridesMap = getEffectDragOverrides( + nodePath, + descriptor.sourceIndex, + ); + const dragOverrides = + Object.keys(dragOverridesMap).length === 0 ? null : dragOverridesMap; + + const {params, effectKey} = mergeOverrides({ + descriptor, + codeOverrides, + dragOverrides, + }); + + return {descriptor, params, effectKey}; + }); + const previous = previousRef.current; const isSame = previous !== null && - previous.length === effects.length && - // Note: If p.stack changes, we don't update. - // We hope this is fine because we assume we cannot fast refresh and change the nodePath + previous.length === resolved.length && previous.every( (p, i) => - p.definition === effects[i].definition && - p.effectKey === effects[i].effectKey, + p.definition === resolved[i].descriptor.definition && + p.effectKey === resolved[i].effectKey && + p.sourceIndex === resolved[i].descriptor.sourceIndex, ); if (isSame) { return previous; } - const next: EffectDefinitionAndStack[] = effects.map((e) => ({ - definition: e.definition, - stack: e.stack, - effectKey: e.effectKey, - params: e.params, - memoized: true, - })); + const next: EffectDefinitionAndStack[] = resolved.map( + ({descriptor, params, effectKey}) => ({ + definition: descriptor.definition, + stack: descriptor.stack, + effectKey, + params, + sourceIndex: descriptor.sourceIndex, + memoized: true, + }), + ); previousRef.current = next; return next; }; diff --git a/packages/core/src/internals.ts b/packages/core/src/internals.ts index 90f79050913..66414781e95 100644 --- a/packages/core/src/internals.ts +++ b/packages/core/src/internals.ts @@ -116,6 +116,9 @@ import type {ResolvedStackLocation} from './sequence-stack-traces.js'; import {SequenceStackTracesUpdateContext} from './sequence-stack-traces.js'; import {SequenceContext} from './SequenceContext.js'; import type { + CanUpdateEffectPropsResponse, + CanUpdateEffectPropsResponseFalse, + CanUpdateEffectPropsResponseTrue, CanUpdateSequencePropsResponse, CanUpdateSequencePropsResponseTrue, CanUpdateSequencePropsResponseFalse, @@ -158,12 +161,18 @@ import { useBasicMediaInTimeline, useMediaInTimeline, } from './use-media-in-timeline.js'; -import type {GetCodeValues, GetDragOverrides} from './use-schema.js'; +import type { + GetCodeValues, + GetDragOverrides, + GetEffectCodeValues, + GetEffectDragOverrides, +} from './use-schema.js'; import { computeEffectiveSchemaValuesDotNotation, type CanUpdateSequencePropStatus, type CodeValues, type DragOverrides, + type EffectDragOverrides, } from './use-schema.js'; import {useUnsafeVideoConfig} from './use-unsafe-video-config.js'; import {useVideo} from './use-video.js'; @@ -359,9 +368,12 @@ export type { CanUpdateSequencePropStatus, CodeValues, GetCodeValues, + GetEffectCodeValues, DragOverrides, + EffectDragOverrides, ScheduleAudioNodeResult, GetDragOverrides, + GetEffectDragOverrides, NonceHistory, OverrideIdsToNodePathsGettersContext, OverrideIdsToNodePathsSettersContext, @@ -369,6 +381,9 @@ export type { OverrideIdToNodePaths, OverrideToNodeSetters, OverrideToNodePathGetters, + CanUpdateEffectPropsResponse, + CanUpdateEffectPropsResponseTrue, + CanUpdateEffectPropsResponseFalse, CanUpdateSequencePropsResponse, CanUpdateSequencePropsResponseTrue, CanUpdateSequencePropsResponseFalse, diff --git a/packages/core/src/test/effect-internals.test.ts b/packages/core/src/test/effect-internals.test.ts index 0f3b6f3cba8..6eefb0eaed3 100644 --- a/packages/core/src/test/effect-internals.test.ts +++ b/packages/core/src/test/effect-internals.test.ts @@ -12,6 +12,7 @@ const makeDef = ( backend: Backend, ): EffectDefinition => ({ type, + factoryName: type, label: type, backend, calculateKey: () => type, @@ -29,6 +30,7 @@ const makeDesc = ( params: {}, stack: new Error().stack!, effectKey: type, + sourceIndex: -1, memoized: false, }); diff --git a/packages/core/src/test/sequence-register-once.test.tsx b/packages/core/src/test/sequence-register-once.test.tsx index 6aa26504e7b..af4134edf63 100644 --- a/packages/core/src/test/sequence-register-once.test.tsx +++ b/packages/core/src/test/sequence-register-once.test.tsx @@ -42,6 +42,9 @@ test('Sequence calls registerSequence exactly once on mount', () => { getCodeValues: () => { throw new Error('VisualModeCodeValuesContext not initialized'); }, + getEffectCodeValues: () => { + throw new Error('VisualModeCodeValuesContext not initialized'); + }, }), [], ); @@ -51,6 +54,9 @@ test('Sequence calls registerSequence exactly once on mount', () => { getDragOverrides: () => { throw new Error('VisualModeDragOverridesContext not initialized'); }, + getEffectDragOverrides: () => { + throw new Error('VisualModeDragOverridesContext not initialized'); + }, }), [], ); @@ -59,6 +65,8 @@ test('Sequence calls registerSequence exactly once on mount', () => { () => ({ setDragOverrides: () => undefined, clearDragOverrides: () => undefined, + setEffectDragOverrides: () => undefined, + clearEffectDragOverrides: () => undefined, setCodeValues: () => undefined, }), [], diff --git a/packages/core/src/use-schema.ts b/packages/core/src/use-schema.ts index 797bb2f74d9..e8c91f66b2e 100644 --- a/packages/core/src/use-schema.ts +++ b/packages/core/src/use-schema.ts @@ -14,16 +14,27 @@ export type CanUpdateSequencePropStatus = | {canUpdate: false; reason: 'computed'}; export type DragOverrides = Record>; +export type EffectDragOverrides = Record>; export type CodeValues = Record; export type GetCodeValues = ( nodePath: SequenceNodePath, ) => Record | undefined; +export type GetEffectCodeValues = ( + nodePath: SequenceNodePath, + effectIndex: number, +) => Record | undefined; + export type GetDragOverrides = ( nodePath: SequenceNodePath, ) => DragOverrides[string]; +export type GetEffectDragOverrides = ( + nodePath: SequenceNodePath, + effectIndex: number, +) => Record; + const findFieldInSchema = ( schema: SequenceSchema, key: string, diff --git a/packages/core/src/wrap-in-schema.ts b/packages/core/src/wrap-in-schema.ts index efb03a11884..23f10cbc906 100644 --- a/packages/core/src/wrap-in-schema.ts +++ b/packages/core/src/wrap-in-schema.ts @@ -1,6 +1,7 @@ import React, {forwardRef, useState, useContext, useMemo} from 'react'; import type {SequenceControls} from './CompositionManager.js'; import {deleteNestedKey} from './delete-nested-key.js'; +import {EffectOverridesContext} from './effects/effect-overrides-context.js'; import { flattenActiveSchema, getFlatSchemaWithAllKeys, @@ -211,14 +212,26 @@ export const wrapInSchema = ( propsToDelete, }); - return React.createElement(Component, { - ...mergedProps, - _experimentalControls: controls, - ref, - } as Props & { - _experimentalControls: SequenceControls | undefined; - ref: typeof ref; - }); + // eslint-disable-next-line react-hooks/rules-of-hooks + const effectOverridesContext = useMemo( + () => ({ + nodePath, + }), + [nodePath], + ); + + return React.createElement( + EffectOverridesContext.Provider, + {value: effectOverridesContext}, + React.createElement(Component, { + ...mergedProps, + _experimentalControls: controls, + ref, + } as Props & { + _experimentalControls: SequenceControls | undefined; + ref: typeof ref; + }), + ); }); Wrapped.displayName = `wrapInSchema(${Component.displayName || Component.name || 'Component'})`; diff --git a/packages/effects/src/blur/blur-horizontal.ts b/packages/effects/src/blur/blur-horizontal.ts index d3735c49d2d..973fc66663d 100644 --- a/packages/effects/src/blur/blur-horizontal.ts +++ b/packages/effects/src/blur/blur-horizontal.ts @@ -17,6 +17,7 @@ export type BlurHorizontalParams = { // use [`blur`](./index.ts) which composes both horizontal and vertical passes. export const blurHorizontal = createEffect({ type: 'remotion/blur-horizontal', + factoryName: 'blurHorizontal', label: 'Blur (horizontal)', backend: 'webgl2', calculateKey: (params) => String(params.radius), diff --git a/packages/effects/src/blur/blur-vertical.ts b/packages/effects/src/blur/blur-vertical.ts index 2e06a929f57..b30e47cdcba 100644 --- a/packages/effects/src/blur/blur-vertical.ts +++ b/packages/effects/src/blur/blur-vertical.ts @@ -17,6 +17,7 @@ export type BlurVerticalParams = { // use [`blur`](./index.ts) which composes both horizontal and vertical passes. export const blurVertical = createEffect({ type: 'remotion/blur-vertical', + factoryName: 'blurVertical', label: 'Blur (vertical)', backend: 'webgl2', calculateKey: (params) => String(params.radius), diff --git a/packages/effects/src/halftone.ts b/packages/effects/src/halftone.ts index 8348da18bf7..70767787d87 100644 --- a/packages/effects/src/halftone.ts +++ b/packages/effects/src/halftone.ts @@ -271,6 +271,7 @@ const parseColorRgba = ( // with a screen tone instead of luminance-driven ink on opaque pixels alone. export const halftone = createEffect({ type: 'remotion/halftone', + factoryName: 'halftone', label: 'Halftone', backend: 'webgl2', calculateKey: (params) => { diff --git a/packages/effects/src/tint.ts b/packages/effects/src/tint.ts index e7a4eec1d0b..f55d970c80a 100644 --- a/packages/effects/src/tint.ts +++ b/packages/effects/src/tint.ts @@ -36,6 +36,7 @@ const resolve = (p: TintParams): TintResolved => ({ // backend; tinting respects the source's alpha mask. export const tint = createEffect({ type: 'remotion/tint', + factoryName: 'tint', label: 'Tint', backend: '2d', calculateKey: (params) => { diff --git a/packages/effects/src/wave.ts b/packages/effects/src/wave.ts index a32e3c7cbca..b0c7b17cce4 100644 --- a/packages/effects/src/wave.ts +++ b/packages/effects/src/wave.ts @@ -30,6 +30,7 @@ const resolve = (p: WaveParams): WaveResolved => ({ // up/down with a sine wave that animates over time. Operates on the 2D backend. export const wave = createEffect({ type: 'remotion/wave', + factoryName: 'wave', label: 'Wave', backend: '2d', calculateKey: (params) => { diff --git a/packages/light-leaks/src/light-leak-internals.ts b/packages/light-leaks/src/light-leak-internals.ts index 4c1e93c1efd..f5780e29ecc 100644 --- a/packages/light-leaks/src/light-leak-internals.ts +++ b/packages/light-leaks/src/light-leak-internals.ts @@ -195,6 +195,7 @@ const linkProgram = ( const lightLeak = createEffect({ type: 'remotion/light-leak', + factoryName: 'lightLeak', label: 'Light leak', backend: 'webgl2', calculateKey: (params) => { diff --git a/packages/starburst/src/starburst-effect.ts b/packages/starburst/src/starburst-effect.ts index 734d8ab5bf5..f9cd3ccaf60 100644 --- a/packages/starburst/src/starburst-effect.ts +++ b/packages/starburst/src/starburst-effect.ts @@ -293,6 +293,7 @@ const linkProgram = ( const starburstImpl = createEffect({ type: 'remotion/starburst', + factoryName: 'starburstImpl', label: 'Starburst', backend: 'webgl2', calculateKey: (params) => { diff --git a/packages/studio-server/src/codemods/update-effect-props/update-effect-props.ts b/packages/studio-server/src/codemods/update-effect-props/update-effect-props.ts new file mode 100644 index 00000000000..32541f4b143 --- /dev/null +++ b/packages/studio-server/src/codemods/update-effect-props/update-effect-props.ts @@ -0,0 +1,317 @@ +import type { + ArrayExpression, + CallExpression, + Expression, + JSXAttribute, + ObjectExpression, + ObjectProperty, + StringLiteral, +} from '@babel/types'; +import {stringifyDefaultProps} from '@remotion/studio-shared'; +import type {ExpressionKind} from 'ast-types/lib/gen/kinds'; +import * as recast from 'recast'; +import type {SequenceNodePath} from 'remotion'; +import {findJsxElementAtNodePath} from '../../preview-server/routes/can-update-sequence-props'; +import {formatFileContent} from '../format-file-content'; +import {parseAst, serializeAst} from '../parse-ast'; + +const b = recast.types.builders; + +export type EffectPropUpdate = { + key: string; + value: unknown; + defaultValue: unknown | null; +}; + +export type UpdateEffectPropsResult = { + output: string; + formatted: boolean; + oldValueString: string; + logLine: number; +}; + +const parseValueExpression = (value: unknown): ExpressionKind => { + const code = `a = ${stringifyDefaultProps({props: value, enumPaths: []})}`; + const ast = parseAst(code); + const stmt = ast.program.body[0]; + if ( + stmt.type !== 'ExpressionStatement' || + stmt.expression.type !== 'AssignmentExpression' + ) { + throw new Error('Failed to parse effect prop value expression'); + } + + return stmt.expression.right as ExpressionKind; +}; + +const findExperimentalEffectsAttr = ( + attrs: readonly (JSXAttribute | unknown)[], +): JSXAttribute | null => { + for (const attr of attrs) { + if ((attr as JSXAttribute).type !== 'JSXAttribute') { + continue; + } + + const a = attr as JSXAttribute; + if ( + a.name.type === 'JSXIdentifier' && + a.name.name === '_experimentalEffects' + ) { + return a; + } + } + + return null; +}; + +const getCalleeName = (call: CallExpression): string | null => { + const {callee} = call; + if (callee.type === 'Identifier') { + return callee.name; + } + + if ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' + ) { + return callee.property.name; + } + + return null; +}; + +export type EffectArrayElement = + | {kind: 'call'; callee: string; node: CallExpression} + | {kind: 'unsupported'; reason: 'not-call-expression' | 'spread'}; + +export const enumerateEffectArrayElements = ( + arr: ArrayExpression, +): EffectArrayElement[] => { + const out: EffectArrayElement[] = []; + for (const el of arr.elements) { + if (el === null) { + out.push({kind: 'unsupported', reason: 'not-call-expression'}); + continue; + } + + if (el.type === 'SpreadElement') { + out.push({kind: 'unsupported', reason: 'spread'}); + continue; + } + + if (el.type !== 'CallExpression') { + out.push({kind: 'unsupported', reason: 'not-call-expression'}); + continue; + } + + const call = el as CallExpression; + const callee = getCalleeName(call); + if (callee === null) { + out.push({kind: 'unsupported', reason: 'not-call-expression'}); + continue; + } + + out.push({kind: 'call', callee, node: call}); + } + + return out; +}; + +export const findEffectCallExpression = ({ + attr, + effectIndex, + factoryName, +}: { + attr: JSXAttribute; + effectIndex: number; + factoryName: string; +}): + | {kind: 'ok'; call: CallExpression} + | { + kind: 'error'; + reason: + | 'no-args-object' + | 'not-found' + | 'effect-reordered' + | 'not-call-expression'; + } => { + if (!attr.value || attr.value.type !== 'JSXExpressionContainer') { + return {kind: 'error', reason: 'not-call-expression'}; + } + + const expr = attr.value.expression as Expression; + if (expr.type !== 'ArrayExpression') { + return {kind: 'error', reason: 'not-call-expression'}; + } + + const arr = expr as ArrayExpression; + const elements = enumerateEffectArrayElements(arr); + + if (effectIndex < 0 || effectIndex >= elements.length) { + return {kind: 'error', reason: 'not-found'}; + } + + const target = elements[effectIndex]; + if (target.kind !== 'call') { + return {kind: 'error', reason: 'not-call-expression'}; + } + + if (target.callee !== factoryName) { + return {kind: 'error', reason: 'effect-reordered'}; + } + + return {kind: 'ok', call: target.node}; +}; + +const findObjectProperty = ( + objExpr: ObjectExpression, + propertyName: string, +): {propIndex: number; prop: ObjectProperty | undefined} => { + const propIndex = objExpr.properties.findIndex( + (p) => + p.type === 'ObjectProperty' && + ((p.key.type === 'Identifier' && p.key.name === propertyName) || + (p.key.type === 'StringLiteral' && + (p.key as StringLiteral).value === propertyName)), + ); + + return { + propIndex, + prop: + propIndex !== -1 + ? (objExpr.properties[propIndex] as ObjectProperty) + : undefined, + }; +}; + +export const updateEffectPropsAst = ({ + input, + sequenceNodePath, + effectIndex, + factoryName, + update, +}: { + input: string; + sequenceNodePath: SequenceNodePath; + effectIndex: number; + factoryName: string; + update: EffectPropUpdate; +}): { + serialized: string; + oldValueString: string; + logLine: number; +} => { + const ast = parseAst(input); + const jsx = findJsxElementAtNodePath(ast, sequenceNodePath); + if (!jsx) { + throw new Error( + 'Could not find a JSX element at the specified location to update effects', + ); + } + + const attr = findExperimentalEffectsAttr(jsx.attributes ?? []); + if (!attr) { + throw new Error( + 'Could not find _experimentalEffects on the target JSX element', + ); + } + + const found = findEffectCallExpression({ + attr, + effectIndex, + factoryName, + }); + + if (found.kind === 'error') { + throw new Error(`Cannot update effect prop: ${found.reason}`); + } + + const {call} = found; + + if ( + call.arguments.length === 0 || + call.arguments[0].type !== 'ObjectExpression' + ) { + throw new Error('Cannot update effect prop: no-args-object'); + } + + const objExpr = call.arguments[0] as ObjectExpression; + const {prop} = findObjectProperty(objExpr, update.key); + + const isDefault = + update.defaultValue !== null && + JSON.stringify(update.value) === JSON.stringify(update.defaultValue); + + let oldValueString = ''; + if (prop) { + oldValueString = recast.print(prop.value).code; + } else if (update.defaultValue !== null) { + oldValueString = JSON.stringify(update.defaultValue); + } + + if (isDefault) { + if (prop) { + const idx = objExpr.properties.indexOf(prop); + if (idx !== -1) { + objExpr.properties.splice(idx, 1); + } + } + } else { + const newValueExpr = parseValueExpression(update.value); + if (prop) { + prop.value = newValueExpr as ObjectProperty['value']; + } else { + objExpr.properties.push( + b.objectProperty( + b.identifier(update.key), + newValueExpr, + ) as ObjectProperty, + ); + } + } + + const logLine = call.loc?.start.line ?? jsx.loc?.start.line ?? 1; + + return { + serialized: serializeAst(ast), + oldValueString, + logLine, + }; +}; + +export const updateEffectProps = async ({ + input, + sequenceNodePath, + effectIndex, + factoryName, + update, + prettierConfigOverride, +}: { + input: string; + sequenceNodePath: SequenceNodePath; + effectIndex: number; + factoryName: string; + update: EffectPropUpdate; + prettierConfigOverride?: Record | null; +}): Promise => { + const {serialized, oldValueString, logLine} = updateEffectPropsAst({ + input, + sequenceNodePath, + effectIndex, + factoryName, + update, + }); + + const {output, formatted} = await formatFileContent({ + input: serialized, + prettierConfigOverride, + }); + + return { + output, + oldValueString, + formatted, + logLine, + }; +}; diff --git a/packages/studio-server/src/preview-server/api-routes.ts b/packages/studio-server/src/preview-server/api-routes.ts index 38a2cae7c19..1eb0f47db67 100644 --- a/packages/studio-server/src/preview-server/api-routes.ts +++ b/packages/studio-server/src/preview-server/api-routes.ts @@ -13,6 +13,7 @@ import {projectInfoHandler} from './routes/project-info'; import {redoHandler} from './routes/redo'; import {handleRemoveRender} from './routes/remove-render'; import {handleRestartStudio} from './routes/restart-studio'; +import {saveEffectPropsHandler} from './routes/save-effect-props'; import {saveSequencePropsHandler} from './routes/save-sequence-props'; import {subscribeToDefaultProps} from './routes/subscribe-to-default-props'; import {subscribeToFileExistence} from './routes/subscribe-to-file-existence'; @@ -44,6 +45,7 @@ export const allApiRoutes: { '/api/subscribe-to-sequence-props': subscribeToSequenceProps, '/api/unsubscribe-from-sequence-props': unsubscribeFromSequenceProps, '/api/save-sequence-props': saveSequencePropsHandler, + '/api/save-effect-props': saveEffectPropsHandler, '/api/delete-jsx-node': deleteJsxNodeHandler, '/api/duplicate-jsx-node': duplicateJsxNodeHandler, '/api/update-available': handleUpdate, diff --git a/packages/studio-server/src/preview-server/routes/can-update-effect-props.ts b/packages/studio-server/src/preview-server/routes/can-update-effect-props.ts new file mode 100644 index 00000000000..0ea968c1fdf --- /dev/null +++ b/packages/studio-server/src/preview-server/routes/can-update-effect-props.ts @@ -0,0 +1,247 @@ +import {readFileSync} from 'node:fs'; +import path from 'node:path'; +import type { + CallExpression, + Expression, + JSXAttribute, + JSXOpeningElement, + ObjectExpression, + ObjectProperty, +} from '@babel/types'; +import type {EffectSubscription} from '@remotion/studio-shared'; +import type { + CanUpdateEffectPropsResponse, + CanUpdateSequencePropStatus, + SequenceNodePath, +} from 'remotion'; +import {parseAst} from '../../codemods/parse-ast'; +import { + enumerateEffectArrayElements, + type EffectArrayElement, +} from '../../codemods/update-effect-props/update-effect-props'; +import { + extractStaticValue, + findJsxElementAtNodePath, + isStaticValue, +} from './can-update-sequence-props'; + +const findExperimentalEffectsAttr = ( + jsx: JSXOpeningElement, +): JSXAttribute | null => { + for (const attr of jsx.attributes) { + if (attr.type !== 'JSXAttribute') { + continue; + } + + if ( + attr.name.type === 'JSXIdentifier' && + attr.name.name === '_experimentalEffects' + ) { + return attr; + } + } + + return null; +}; + +const getEffectsArrayElements = ( + attr: JSXAttribute | null, +): EffectArrayElement[] | null => { + if (!attr || !attr.value || attr.value.type !== 'JSXExpressionContainer') { + return null; + } + + const expr = attr.value.expression as Expression; + if (expr.type !== 'ArrayExpression') { + return null; + } + + return enumerateEffectArrayElements(expr); +}; + +const getPropsFromObjectExpression = ({ + objExpr, + keys, +}: { + objExpr: ObjectExpression; + keys: string[]; +}): Record => { + const out: Record = {}; + + for (const key of keys) { + const prop = objExpr.properties.find( + (p) => + p.type === 'ObjectProperty' && + ((p.key.type === 'Identifier' && p.key.name === key) || + (p.key.type === 'StringLiteral' && + (p.key as {value: string}).value === key)), + ) as ObjectProperty | undefined; + + if (!prop) { + out[key] = {canUpdate: true, codeValue: undefined}; + continue; + } + + const valueExpr = prop.value as Expression; + if (!isStaticValue(valueExpr)) { + out[key] = {canUpdate: false, reason: 'computed'}; + continue; + } + + out[key] = { + canUpdate: true, + codeValue: extractStaticValue(valueExpr), + }; + } + + return out; +}; + +export const computeEffectPropStatus = ({ + jsx, + subscription, + keys, +}: { + jsx: JSXOpeningElement; + subscription: {effectIndex: number; factoryName: string}; + keys: string[]; +}): CanUpdateEffectPropsResponse => { + const attr = findExperimentalEffectsAttr(jsx); + const elements = getEffectsArrayElements(attr); + + if (!elements) { + return { + canUpdate: false, + effectIndex: subscription.effectIndex, + factoryName: subscription.factoryName, + reason: 'not-found', + }; + } + + if ( + subscription.effectIndex < 0 || + subscription.effectIndex >= elements.length + ) { + return { + canUpdate: false, + effectIndex: subscription.effectIndex, + factoryName: subscription.factoryName, + reason: 'not-found', + }; + } + + const target = elements[subscription.effectIndex]; + if (target.kind !== 'call') { + return { + canUpdate: false, + effectIndex: subscription.effectIndex, + factoryName: subscription.factoryName, + reason: 'not-call-expression', + }; + } + + if (target.callee !== subscription.factoryName) { + return { + canUpdate: false, + effectIndex: subscription.effectIndex, + factoryName: subscription.factoryName, + reason: 'effect-reordered', + }; + } + + const call: CallExpression = target.node; + if (call.arguments.length === 0) { + const emptyProps: Record = {}; + for (const key of keys) { + emptyProps[key] = {canUpdate: true, codeValue: undefined}; + } + + return { + canUpdate: true, + effectIndex: subscription.effectIndex, + factoryName: subscription.factoryName, + props: emptyProps, + }; + } + + const firstArg = call.arguments[0]; + if (firstArg.type !== 'ObjectExpression') { + return { + canUpdate: false, + effectIndex: subscription.effectIndex, + factoryName: subscription.factoryName, + reason: 'no-args-object', + }; + } + + const resolvedProps = getPropsFromObjectExpression({ + objExpr: firstArg as ObjectExpression, + keys, + }); + + return { + canUpdate: true, + effectIndex: subscription.effectIndex, + factoryName: subscription.factoryName, + props: resolvedProps, + }; +}; + +export const computeEffectPropsStatusesFromContent = ({ + fileContents, + sequenceNodePath, + effects, + keysFor, +}: { + fileContents: string; + sequenceNodePath: SequenceNodePath; + effects: EffectSubscription[]; + keysFor: (effect: EffectSubscription) => string[]; +}): CanUpdateEffectPropsResponse[] => { + const ast = parseAst(fileContents); + const jsx = findJsxElementAtNodePath(ast, sequenceNodePath); + if (!jsx) { + return effects.map((effect) => ({ + canUpdate: false as const, + effectIndex: effect.effectIndex, + factoryName: effect.factoryName, + reason: 'not-found' as const, + })); + } + + return effects.map((effect) => + computeEffectPropStatus({ + jsx, + subscription: effect, + keys: keysFor(effect), + }), + ); +}; + +export const computeEffectPropsStatusesFromFile = ({ + fileName, + sequenceNodePath, + effects, + keysFor, + remotionRoot, +}: { + fileName: string; + sequenceNodePath: SequenceNodePath; + effects: EffectSubscription[]; + keysFor: (effect: EffectSubscription) => string[]; + remotionRoot: string; +}): CanUpdateEffectPropsResponse[] => { + const absolutePath = path.resolve(remotionRoot, fileName); + const fileRelativeToRoot = path.relative(remotionRoot, absolutePath); + if (fileRelativeToRoot.startsWith('..')) { + throw new Error('Cannot read a file outside the project'); + } + + const fileContents = readFileSync(absolutePath, 'utf-8'); + return computeEffectPropsStatusesFromContent({ + fileContents, + sequenceNodePath, + effects, + keysFor, + }); +}; diff --git a/packages/studio-server/src/preview-server/routes/can-update-sequence-props.ts b/packages/studio-server/src/preview-server/routes/can-update-sequence-props.ts index 70826a8a33b..29dd74a0b5e 100644 --- a/packages/studio-server/src/preview-server/routes/can-update-sequence-props.ts +++ b/packages/studio-server/src/preview-server/routes/can-update-sequence-props.ts @@ -10,13 +10,18 @@ import type { TSAsExpression, UnaryExpression, } from '@babel/types'; -import type {SubscribeToSequencePropsResponse} from '@remotion/studio-shared'; +import type { + EffectSubscription, + SubscribeToSequencePropsResponse, +} from '@remotion/studio-shared'; import * as recast from 'recast'; import type {CanUpdateSequencePropsResponseTrue} from 'remotion'; import type {SequenceNodePath} from 'remotion'; import type {CanUpdateSequencePropStatus} from 'remotion'; +import {getAllSchemaKeys} from '../../codemods/get-all-schema-keys'; import {parseAst} from '../../codemods/parse-ast'; import {getAstNodePath} from '../../helpers/get-ast-node-path'; +import {computeEffectPropStatus} from './can-update-effect-props'; type CanUpdatePropStatus = CanUpdateSequencePropStatus; @@ -300,19 +305,29 @@ const getNestedPropStatus = ( return {canUpdate: true, codeValue}; }; -export const computeSequencePropsStatusFromContent = ( - fileContents: string, - nodePath: SequenceNodePath, - keys: string[], -): CanUpdateSequencePropsResponseTrue => { - const ast = parseAst(fileContents); - - const jsxElement = findJsxElementAtNodePath(ast, nodePath); - - if (!jsxElement) { - throw new Error('Could not find a JSX element at the specified location'); - } +const computeEffectsForJsx = ({ + jsxElement, + effects, +}: { + jsxElement: JSXOpeningElement; + effects: EffectSubscription[]; +}) => { + return effects.map((effect) => + computeEffectPropStatus({ + jsx: jsxElement, + subscription: effect, + keys: getAllSchemaKeys(effect.schema), + }), + ); +}; +const computeSequenceOnlyPropsRecord = ({ + jsxElement, + keys, +}: { + jsxElement: JSXOpeningElement; + keys: string[]; +}): Record => { const allProps = getPropsStatus(jsxElement); const filteredProps: Record = {}; for (const key of keys) { @@ -330,9 +345,60 @@ export const computeSequencePropsStatusFromContent = ( } } + return filteredProps; +}; + +export const computeSequencePropsOnlyStatus = ({ + fileName, + nodePath, + keys, + remotionRoot, +}: { + fileName: string; + nodePath: SequenceNodePath; + keys: string[]; + remotionRoot: string; +}): {canUpdate: true; props: Record} => { + const absolutePath = path.resolve(remotionRoot, fileName); + const fileRelativeToRoot = path.relative(remotionRoot, absolutePath); + if (fileRelativeToRoot.startsWith('..')) { + throw new Error('Cannot read a file outside the project'); + } + + const fileContents = readFileSync(absolutePath, 'utf-8'); + const ast = parseAst(fileContents); + const jsxElement = findJsxElementAtNodePath(ast, nodePath); + if (!jsxElement) { + throw new Error('Could not find a JSX element at the specified location'); + } + + return { + canUpdate: true as const, + props: computeSequenceOnlyPropsRecord({jsxElement, keys}), + }; +}; + +export const computeSequencePropsStatusFromContent = ( + fileContents: string, + nodePath: SequenceNodePath, + keys: string[], + effects: EffectSubscription[], +): CanUpdateSequencePropsResponseTrue => { + const ast = parseAst(fileContents); + + const jsxElement = findJsxElementAtNodePath(ast, nodePath); + + if (!jsxElement) { + throw new Error('Could not find a JSX element at the specified location'); + } + + const filteredProps = computeSequenceOnlyPropsRecord({jsxElement, keys}); + const effectsStatuses = computeEffectsForJsx({jsxElement, effects}); + return { canUpdate: true as const, props: filteredProps, + effects: effectsStatuses, }; }; @@ -340,11 +406,13 @@ export const computeSequencePropsStatus = ({ fileName, nodePath, keys, + effects, remotionRoot, }: { fileName: string; nodePath: SequenceNodePath; keys: string[]; + effects: EffectSubscription[]; remotionRoot: string; }): CanUpdateSequencePropsResponseTrue => { const absolutePath = path.resolve(remotionRoot, fileName); @@ -354,18 +422,25 @@ export const computeSequencePropsStatus = ({ } const fileContents = readFileSync(absolutePath, 'utf-8'); - return computeSequencePropsStatusFromContent(fileContents, nodePath, keys); + return computeSequencePropsStatusFromContent( + fileContents, + nodePath, + keys, + effects, + ); }; export const computeSequencePropsStatusFromFilenameByLine = ({ fileName, line, keys, + effects, remotionRoot, }: { fileName: string; line: number; keys: string[]; + effects: EffectSubscription[]; remotionRoot: string; }): SubscribeToSequencePropsResponse => { try { @@ -388,6 +463,7 @@ export const computeSequencePropsStatusFromFilenameByLine = ({ fileName, nodePath: resolvedNodePath, keys, + effects, remotionRoot, }), nodePath: resolvedNodePath, diff --git a/packages/studio-server/src/preview-server/routes/save-effect-props.ts b/packages/studio-server/src/preview-server/routes/save-effect-props.ts new file mode 100644 index 00000000000..398a50da9c2 --- /dev/null +++ b/packages/studio-server/src/preview-server/routes/save-effect-props.ts @@ -0,0 +1,141 @@ +import {readFileSync} from 'node:fs'; +import path from 'node:path'; +import {RenderInternals} from '@remotion/renderer'; +import type { + SaveEffectPropsRequest, + SaveEffectPropsResponse, +} from '@remotion/studio-shared'; +import {getAllSchemaKeys} from '../../codemods/get-all-schema-keys'; +import {parseAst} from '../../codemods/parse-ast'; +import {updateEffectProps} from '../../codemods/update-effect-props/update-effect-props'; +import {writeFileAndNotifyFileWatchers} from '../../file-watcher'; +import type {ApiHandler} from '../api-types'; +import { + printUndoHint, + pushToUndoStack, + suppressUndoStackInvalidation, +} from '../undo-stack'; +import {suppressBundlerUpdateForFile} from '../watch-ignore-next-change'; +import {computeEffectPropStatus} from './can-update-effect-props'; +import {findJsxElementAtNodePath} from './can-update-sequence-props'; +import {formatPropChange} from './log-updates/format-prop-change'; +import {logUpdate, normalizeQuotes} from './log-updates/log-update'; + +export const saveEffectPropsHandler: ApiHandler< + SaveEffectPropsRequest, + SaveEffectPropsResponse +> = async ({ + input: { + fileName, + sequenceNodePath, + effectIndex, + factoryName, + key, + value, + defaultValue, + schema, + }, + remotionRoot, + logLevel, +}) => { + RenderInternals.Log.trace( + {indent: false, logLevel}, + `[save-effect-props] Received request for fileName="${fileName}" effectIndex=${effectIndex} factoryName="${factoryName}" key="${key}"`, + ); + const absolutePath = path.resolve(remotionRoot, fileName); + const fileRelativeToRoot = path.relative(remotionRoot, absolutePath); + if (fileRelativeToRoot.startsWith('..')) { + throw new Error('Cannot modify a file outside the project'); + } + + const fileContents = readFileSync(absolutePath, 'utf-8'); + + const parsedDefault = defaultValue !== null ? JSON.parse(defaultValue) : null; + + const {output, oldValueString, formatted, logLine} = await updateEffectProps({ + input: fileContents, + sequenceNodePath, + effectIndex, + factoryName, + update: { + key, + value: JSON.parse(value), + defaultValue: parsedDefault, + }, + }); + + const newValueString = JSON.stringify(JSON.parse(value)); + const defaultValueString = + parsedDefault !== null ? JSON.stringify(parsedDefault) : null; + + const normalizedOld = normalizeQuotes(oldValueString); + const normalizedNew = normalizeQuotes(newValueString); + const normalizedDefault = + defaultValueString !== null ? normalizeQuotes(defaultValueString) : null; + + const undoPropChange = formatPropChange({ + key, + oldValueString: normalizedNew, + newValueString: normalizedOld, + defaultValueString: normalizedDefault, + removedProps: [], + addedProps: [], + }); + const redoPropChange = formatPropChange({ + key, + oldValueString: normalizedOld, + newValueString: normalizedNew, + defaultValueString: normalizedDefault, + removedProps: [], + addedProps: [], + }); + + pushToUndoStack({ + filePath: absolutePath, + oldContents: fileContents, + logLevel, + remotionRoot, + logLine, + description: { + undoMessage: `↩️ ${undoPropChange}`, + redoMessage: `↪️ ${redoPropChange}`, + }, + entryType: 'effect-props', + suppressHmrOnFileRestore: true, + }); + suppressUndoStackInvalidation(absolutePath); + suppressBundlerUpdateForFile(absolutePath); + writeFileAndNotifyFileWatchers(absolutePath, output); + + logUpdate({ + fileRelativeToRoot, + line: logLine, + key: `${factoryName}[${effectIndex}].${key}`, + oldValueString, + newValueString, + defaultValueString, + formatted, + logLevel, + removedProps: [], + addedProps: [], + }); + + printUndoHint(logLevel); + + const ast = parseAst(readFileSync(absolutePath, 'utf-8')); + const jsx = findJsxElementAtNodePath(ast, sequenceNodePath); + if (!jsx) { + return { + canUpdate: false, + effectIndex, + factoryName, + reason: 'not-found', + }; + } + + return computeEffectPropStatus({ + jsx, + subscription: {effectIndex, factoryName}, + keys: getAllSchemaKeys(schema), + }); +}; diff --git a/packages/studio-server/src/preview-server/routes/save-sequence-props.ts b/packages/studio-server/src/preview-server/routes/save-sequence-props.ts index 93cd1567a82..7920b4f6d4b 100644 --- a/packages/studio-server/src/preview-server/routes/save-sequence-props.ts +++ b/packages/studio-server/src/preview-server/routes/save-sequence-props.ts @@ -1,8 +1,10 @@ import {readFileSync} from 'node:fs'; import path from 'node:path'; import {RenderInternals} from '@remotion/renderer'; -import type {SaveSequencePropsRequest} from '@remotion/studio-shared'; -import type {CanUpdateSequencePropsResponse} from 'remotion'; +import type { + SaveSequencePropsRequest, + SaveSequencePropsResponse, +} from '@remotion/studio-shared'; import {Internals} from 'remotion'; import {getAllSchemaKeys} from '../../codemods/get-all-schema-keys'; import {updateSequenceProps} from '../../codemods/update-sequence-props/update-sequence-props'; @@ -14,13 +16,13 @@ import { suppressUndoStackInvalidation, } from '../undo-stack'; import {suppressBundlerUpdateForFile} from '../watch-ignore-next-change'; -import {computeSequencePropsStatus} from './can-update-sequence-props'; +import {computeSequencePropsOnlyStatus} from './can-update-sequence-props'; import {formatPropChange} from './log-updates/format-prop-change'; import {logUpdate, normalizeQuotes} from './log-updates/log-update'; export const saveSequencePropsHandler: ApiHandler< SaveSequencePropsRequest, - CanUpdateSequencePropsResponse + SaveSequencePropsResponse > = async ({ input: {fileName, nodePath, key, value, defaultValue, schema}, remotionRoot, @@ -112,7 +114,7 @@ export const saveSequencePropsHandler: ApiHandler< printUndoHint(logLevel); - const newStatus = computeSequencePropsStatus({ + const newStatus = computeSequencePropsOnlyStatus({ fileName, keys: getAllSchemaKeys(schema), nodePath, diff --git a/packages/studio-server/src/preview-server/routes/subscribe-to-sequence-props.ts b/packages/studio-server/src/preview-server/routes/subscribe-to-sequence-props.ts index 14639193a02..e269c0da2ac 100644 --- a/packages/studio-server/src/preview-server/routes/subscribe-to-sequence-props.ts +++ b/packages/studio-server/src/preview-server/routes/subscribe-to-sequence-props.ts @@ -9,12 +9,16 @@ import {subscribeToSequencePropsWatchers} from '../sequence-props-watchers'; export const subscribeToSequenceProps: ApiHandler< SubscribeToSequencePropsRequest, SubscribeToSequencePropsResponse -> = ({input: {fileName, line, column, schema, clientId}, remotionRoot}) => { +> = ({ + input: {fileName, line, column, schema, effects, clientId}, + remotionRoot, +}) => { const result = subscribeToSequencePropsWatchers({ fileName, line, column, keys: getAllSchemaKeys(schema), + effects, remotionRoot, clientId, }); diff --git a/packages/studio-server/src/preview-server/sequence-props-watchers.ts b/packages/studio-server/src/preview-server/sequence-props-watchers.ts index 0d41a3eb1f7..cb848646a9e 100644 --- a/packages/studio-server/src/preview-server/sequence-props-watchers.ts +++ b/packages/studio-server/src/preview-server/sequence-props-watchers.ts @@ -1,5 +1,8 @@ import path from 'node:path'; -import type {SubscribeToSequencePropsResponse} from '@remotion/studio-shared'; +import type { + EffectSubscription, + SubscribeToSequencePropsResponse, +} from '@remotion/studio-shared'; import type {CanUpdateSequencePropsResponse, SequenceNodePath} from 'remotion'; import {installFileWatcher} from '../file-watcher'; import {waitForLiveEventsListener} from './live-events'; @@ -32,12 +35,14 @@ const getSequencePropsStatus = ({ line, column, keys, + effects, remotionRoot, }: { fileName: string; line: number; column: number; keys: string[]; + effects: EffectSubscription[]; remotionRoot: string; }): SubscribeToSequencePropsResponse => { // Try cached nodePath first (handles stale source maps after suppressed rebuilds) @@ -48,6 +53,7 @@ const getSequencePropsStatus = ({ fileName, nodePath: cachedNodePath, keys, + effects, remotionRoot, }); @@ -60,6 +66,7 @@ const getSequencePropsStatus = ({ fileName, line, keys, + effects, remotionRoot, }); @@ -71,6 +78,7 @@ export const subscribeToSequencePropsWatchers = ({ line, column, keys, + effects, remotionRoot, clientId, }: { @@ -78,6 +86,7 @@ export const subscribeToSequencePropsWatchers = ({ line: number; column: number; keys: string[]; + effects: EffectSubscription[]; remotionRoot: string; clientId: string; }): SubscribeToSequencePropsResponse => { @@ -86,6 +95,7 @@ export const subscribeToSequencePropsWatchers = ({ line, column, keys, + effects, remotionRoot, }); @@ -121,6 +131,7 @@ export const subscribeToSequencePropsWatchers = ({ event.content, nodePath, keys, + effects, ); } catch { return; diff --git a/packages/studio-server/src/preview-server/undo-stack.ts b/packages/studio-server/src/preview-server/undo-stack.ts index d3ebf9f7cee..c6b281ae0f4 100644 --- a/packages/studio-server/src/preview-server/undo-stack.ts +++ b/packages/studio-server/src/preview-server/undo-stack.ts @@ -20,6 +20,7 @@ type UndoEntryType = | 'visual-control' | 'default-props' | 'sequence-props' + | 'effect-props' | 'delete-jsx-node' | 'duplicate-jsx-node'; @@ -35,6 +36,7 @@ type UndoEntry = { | {entryType: 'visual-control'} | {entryType: 'default-props'} | {entryType: 'sequence-props'} + | {entryType: 'effect-props'} | {entryType: 'delete-jsx-node'} | {entryType: 'duplicate-jsx-node'} ); diff --git a/packages/studio-server/src/test/compute-effect-props-status.test.ts b/packages/studio-server/src/test/compute-effect-props-status.test.ts new file mode 100644 index 00000000000..8bfc3645a06 --- /dev/null +++ b/packages/studio-server/src/test/compute-effect-props-status.test.ts @@ -0,0 +1,144 @@ +import {expect, test} from 'bun:test'; +import {parseAst} from '../codemods/parse-ast'; +import {computeEffectPropStatus} from '../preview-server/routes/can-update-effect-props'; +import {findJsxElementAtNodePath} from '../preview-server/routes/can-update-sequence-props'; +import {lineColumnToNodePath} from './test-utils'; + +const buildInput = ( + effects: string, +) => `import {HtmlInCanvas} from '@remotion/html-in-canvas'; +import {tint} from '@remotion/effects'; + +export const Comp = () => { +\treturn ( +\t\t +\t\t\thi +\t\t +\t); +}; +`; + +const findJsx = (input: string) => { + const ast = parseAst(input); + const jsx = findJsxElementAtNodePath(ast, lineColumnToNodePath(input, 6)); + if (!jsx) { + throw new Error('JSX not found'); + } + + return jsx; +}; + +test('computeEffectPropStatus reports static props as canUpdate=true with codeValue', () => { + const input = buildInput('[tint({color: "red", opacity: 0.5})]'); + const result = computeEffectPropStatus({ + jsx: findJsx(input), + subscription: {effectIndex: 0, factoryName: 'tint'}, + keys: ['color', 'opacity'], + }); + + expect(result.canUpdate).toBe(true); + if (!result.canUpdate) { + throw new Error('expected canUpdate true'); + } + + expect(result.props.color).toEqual({canUpdate: true, codeValue: 'red'}); + expect(result.props.opacity).toEqual({canUpdate: true, codeValue: 0.5}); +}); + +test('computeEffectPropStatus reports computed props', () => { + const input = buildInput('[tint({color: getColor(), opacity: 0.5})]'); + const result = computeEffectPropStatus({ + jsx: findJsx(input), + subscription: {effectIndex: 0, factoryName: 'tint'}, + keys: ['color', 'opacity'], + }); + + expect(result.canUpdate).toBe(true); + if (!result.canUpdate) { + throw new Error('expected canUpdate true'); + } + + expect(result.props.color).toEqual({canUpdate: false, reason: 'computed'}); + expect(result.props.opacity).toEqual({canUpdate: true, codeValue: 0.5}); +}); + +test('computeEffectPropStatus reports unset props as undefined codeValue', () => { + const input = buildInput('[tint({color: "red"})]'); + const result = computeEffectPropStatus({ + jsx: findJsx(input), + subscription: {effectIndex: 0, factoryName: 'tint'}, + keys: ['color', 'opacity'], + }); + + expect(result.canUpdate).toBe(true); + if (!result.canUpdate) { + throw new Error('expected canUpdate true'); + } + + expect(result.props.color).toEqual({canUpdate: true, codeValue: 'red'}); + expect(result.props.opacity).toEqual({canUpdate: true, codeValue: undefined}); +}); + +test('computeEffectPropStatus flags reordered effect (factoryName mismatch)', () => { + const input = buildInput('[tint({color: "red"}), halftone({})]'); + const result = computeEffectPropStatus({ + jsx: findJsx(input), + subscription: {effectIndex: 1, factoryName: 'tint'}, + keys: ['color'], + }); + + expect(result.canUpdate).toBe(false); + if (result.canUpdate) { + throw new Error('expected canUpdate false'); + } + + expect(result.reason).toBe('effect-reordered'); +}); + +test('computeEffectPropStatus flags non-call expressions', () => { + const input = buildInput('[someEffect, tint({color: "red"})]'); + const result = computeEffectPropStatus({ + jsx: findJsx(input), + subscription: {effectIndex: 0, factoryName: 'tint'}, + keys: ['color'], + }); + + expect(result.canUpdate).toBe(false); + if (result.canUpdate) { + throw new Error('expected canUpdate false'); + } + + expect(result.reason).toBe('not-call-expression'); +}); + +test('computeEffectPropStatus flags out-of-range effect indices', () => { + const input = buildInput('[tint({color: "red"})]'); + const result = computeEffectPropStatus({ + jsx: findJsx(input), + subscription: {effectIndex: 5, factoryName: 'tint'}, + keys: ['color'], + }); + + expect(result.canUpdate).toBe(false); + if (result.canUpdate) { + throw new Error('expected canUpdate false'); + } + + expect(result.reason).toBe('not-found'); +}); + +test('computeEffectPropStatus flags non-object first arg', () => { + const input = buildInput('[tint(getParams())]'); + const result = computeEffectPropStatus({ + jsx: findJsx(input), + subscription: {effectIndex: 0, factoryName: 'tint'}, + keys: ['color'], + }); + + expect(result.canUpdate).toBe(false); + if (result.canUpdate) { + throw new Error('expected canUpdate false'); + } + + expect(result.reason).toBe('no-args-object'); +}); diff --git a/packages/studio-server/src/test/compute-sequence-props-status.test.ts b/packages/studio-server/src/test/compute-sequence-props-status.test.ts index 00b1a8fdcc5..d3273722075 100644 --- a/packages/studio-server/src/test/compute-sequence-props-status.test.ts +++ b/packages/studio-server/src/test/compute-sequence-props-status.test.ts @@ -24,6 +24,7 @@ test('canUpdateSequenceProps should flag computed props', () => { fileName: filePath, nodePath: getNodePath(filePath, 8), keys: ['durationInFrames', 'seed', 'hueShift', 'nonExistentProp'], + effects: [], remotionRoot: '/', }); @@ -50,6 +51,7 @@ test('computeSequencePropsStatus should detect static nested props', () => { fileName: filePath, nodePath: getNodePath(filePath, 7), keys: ['style.opacity', 'style.scale'], + effects: [], remotionRoot: '/', }); @@ -72,6 +74,7 @@ test('computeSequencePropsStatus should flag computed nested props', () => { fileName: filePath, nodePath: getNodePath(filePath, 8), keys: ['style.opacity', 'style.scale'], + effects: [], remotionRoot: '/', }); @@ -96,6 +99,7 @@ test('computeSequencePropsStatus should flag computed when parent is not an obje fileName: filePath, nodePath: getNodePath(filePath, 9), keys: ['style.opacity'], + effects: [], remotionRoot: '/', }); @@ -115,6 +119,7 @@ test('computeSequencePropsStatus should report unset nested props as undefined', fileName: filePath, nodePath: getNodePath(filePath, 7), keys: ['style.rotate'], + effects: [], remotionRoot: '/', }); @@ -133,6 +138,7 @@ test('computeSequencePropsStatus should report unset when parent attribute missi fileName: filePath, nodePath: getNodePath(filePath, 10), keys: ['style.opacity'], + effects: [], remotionRoot: '/', }); diff --git a/packages/studio-server/src/test/unsupported-translate.test.ts b/packages/studio-server/src/test/unsupported-translate.test.ts index 1cbfe5d8240..379c2f3509b 100644 --- a/packages/studio-server/src/test/unsupported-translate.test.ts +++ b/packages/studio-server/src/test/unsupported-translate.test.ts @@ -30,6 +30,7 @@ const getTranslateStatus = (translateValue: string) => { input, lineColumnToNodePath(input, 7), ['style.translate'], + [], ); assert(result.canUpdate); diff --git a/packages/studio-server/src/test/update-effect-props.test.ts b/packages/studio-server/src/test/update-effect-props.test.ts new file mode 100644 index 00000000000..4af42e2c78a --- /dev/null +++ b/packages/studio-server/src/test/update-effect-props.test.ts @@ -0,0 +1,115 @@ +import {expect, test} from 'bun:test'; +import {updateEffectPropsAst} from '../codemods/update-effect-props/update-effect-props'; +import {lineColumnToNodePath} from './test-utils'; + +const buildInput = ( + effects: string, +) => `import {HtmlInCanvas} from '@remotion/html-in-canvas'; +import {tint} from '@remotion/effects'; + +export const Comp = () => { +\treturn ( +\t\t +\t\t\thi +\t\t +\t); +}; +`; + +test('updateEffectProps updates an existing prop on the right effect', () => { + const input = buildInput('[tint({color: "red", opacity: 0.5})]'); + const {serialized} = updateEffectPropsAst({ + input, + sequenceNodePath: lineColumnToNodePath(input, 6), + effectIndex: 0, + factoryName: 'tint', + update: {key: 'opacity', value: 0.8, defaultValue: null}, + }); + + expect(serialized).toContain('opacity: 0.8'); + expect(serialized).not.toContain('opacity: 0.5'); +}); + +test('updateEffectProps adds a missing prop', () => { + const input = buildInput('[tint({color: "red"})]'); + const {serialized} = updateEffectPropsAst({ + input, + sequenceNodePath: lineColumnToNodePath(input, 6), + effectIndex: 0, + factoryName: 'tint', + update: {key: 'opacity', value: 0.25, defaultValue: null}, + }); + + expect(serialized).toContain('opacity: 0.25'); + expect(serialized).toContain('color: "red"'); +}); + +test('updateEffectProps removes a prop equal to default', () => { + const input = buildInput('[tint({color: "red", opacity: 0.5})]'); + const {serialized} = updateEffectPropsAst({ + input, + sequenceNodePath: lineColumnToNodePath(input, 6), + effectIndex: 0, + factoryName: 'tint', + update: {key: 'opacity', value: 1, defaultValue: 1}, + }); + + expect(serialized).not.toContain('opacity:'); + expect(serialized).toContain('color: "red"'); +}); + +test('updateEffectProps targets the correct effect by index when there are multiple', () => { + const input = buildInput( + '[tint({color: "red"}), tint({color: "green", opacity: 0.4})]', + ); + const {serialized} = updateEffectPropsAst({ + input, + sequenceNodePath: lineColumnToNodePath(input, 6), + effectIndex: 1, + factoryName: 'tint', + update: {key: 'opacity', value: 0.9, defaultValue: null}, + }); + + expect(serialized).toContain('opacity: 0.9'); + expect(serialized).toContain('color: "red"'); + expect(serialized).toContain('color: "green"'); +}); + +test('updateEffectProps throws on factoryName mismatch (effect-reordered)', () => { + const input = buildInput('[tint({color: "red"}), halftone({})]'); + expect(() => { + updateEffectPropsAst({ + input, + sequenceNodePath: lineColumnToNodePath(input, 6), + effectIndex: 1, + factoryName: 'tint', + update: {key: 'opacity', value: 0.5, defaultValue: null}, + }); + }).toThrow(/effect-reordered/); +}); + +test('updateEffectProps throws when effect index is out of range', () => { + const input = buildInput('[tint({color: "red"})]'); + expect(() => { + updateEffectPropsAst({ + input, + sequenceNodePath: lineColumnToNodePath(input, 6), + effectIndex: 5, + factoryName: 'tint', + update: {key: 'opacity', value: 0.5, defaultValue: null}, + }); + }).toThrow(/not-found/); +}); + +test('updateEffectProps throws when first arg is not an object literal (no-args-object)', () => { + const input = buildInput('[tint(getParams())]'); + expect(() => { + updateEffectPropsAst({ + input, + sequenceNodePath: lineColumnToNodePath(input, 6), + effectIndex: 0, + factoryName: 'tint', + update: {key: 'opacity', value: 0.5, defaultValue: null}, + }); + }).toThrow(/no-args-object/); +}); diff --git a/packages/studio-shared/src/api-requests.ts b/packages/studio-shared/src/api-requests.ts index a7c0accd82c..2d2b3839462 100644 --- a/packages/studio-shared/src/api-requests.ts +++ b/packages/studio-shared/src/api-requests.ts @@ -12,11 +12,12 @@ import type { import type {HardwareAccelerationOption} from '@remotion/renderer/client'; import type { _InternalTypes, + CanUpdateEffectPropsResponse, CanUpdateSequencePropsResponseFalse, CanUpdateSequencePropsResponseTrue, + CanUpdateSequencePropStatus, SequenceSchema, } from 'remotion'; -import type {CanUpdateSequencePropsResponse} from 'remotion'; import type {SequenceNodePath} from 'remotion'; import type {RecastCodemod, VisualControlChange} from './codemods'; import type {PackageManager} from './package-manager'; @@ -214,11 +215,18 @@ export type CanUpdateSequencePropsRequest = { keys: string[]; }; +export type EffectSubscription = { + effectIndex: number; + factoryName: string; + schema: SequenceSchema; +}; + export type SubscribeToSequencePropsRequest = { fileName: string; line: number; column: number; schema: SequenceSchema; + effects: EffectSubscription[]; clientId: string; }; @@ -248,6 +256,29 @@ export type SaveSequencePropsRequest = { schema: SequenceSchema; }; +export type SaveSequencePropsResponse = + | { + canUpdate: true; + props: Record; + } + | { + canUpdate: false; + reason: string; + }; + +export type SaveEffectPropsRequest = { + fileName: string; + sequenceNodePath: SequenceNodePath; + effectIndex: number; + factoryName: string; + key: string; + value: string; + defaultValue: string | null; + schema: SequenceSchema; +}; + +export type SaveEffectPropsResponse = CanUpdateEffectPropsResponse; + export type DeleteJsxNodeRequest = { fileName: string; nodePath: SequenceNodePath; @@ -359,7 +390,11 @@ export type ApiRoutes = { >; '/api/save-sequence-props': ReqAndRes< SaveSequencePropsRequest, - CanUpdateSequencePropsResponse + SaveSequencePropsResponse + >; + '/api/save-effect-props': ReqAndRes< + SaveEffectPropsRequest, + SaveEffectPropsResponse >; '/api/delete-jsx-node': ReqAndRes< DeleteJsxNodeRequest, diff --git a/packages/studio-shared/src/index.ts b/packages/studio-shared/src/index.ts index f0503289d73..689b3f85207 100644 --- a/packages/studio-shared/src/index.ts +++ b/packages/studio-shared/src/index.ts @@ -9,6 +9,7 @@ export { CanUpdateDefaultPropsResponse, CanUpdateSequencePropsRequest, CancelRenderRequest, + EffectSubscription, SubscribeToSequencePropsRequest, SubscribeToSequencePropsResponse, UnsubscribeFromSequencePropsRequest, @@ -30,7 +31,10 @@ export { RemoveRenderRequest, RestartStudioRequest, RestartStudioResponse, + SaveEffectPropsRequest, + SaveEffectPropsResponse, SaveSequencePropsRequest, + SaveSequencePropsResponse, SimpleDiff, SubscribeToDefaultPropsRequest, SubscribeToDefaultPropsResponse, @@ -95,13 +99,17 @@ export type {CompletedClientRender} from './render-job'; export { SCHEMA_FIELD_ROW_HEIGHT, UNSUPPORTED_FIELD_ROW_HEIGHT, + getEffectFieldsToShow, getFieldsToShow, } from './schema-field-info'; export type { + AnySchemaFieldInfo, CodeValues, DragOverrides, + EffectSchemaFieldInfo, SchemaFieldInfo, SequenceControls, + SequenceSchemaFieldInfo, } from './schema-field-info'; export { ScriptLine, @@ -113,3 +121,4 @@ export {EnumPath, stringifyDefaultProps} from './stringify-default-props'; export type {VisualControlChange} from './codemods'; export {optimisticUpdateForCodeValues} from './optimistic-update-for-code-values'; +export {optimisticUpdateForEffectCodeValues} from './optimistic-update-for-effect-code-values'; diff --git a/packages/studio-shared/src/optimistic-update-for-code-values.ts b/packages/studio-shared/src/optimistic-update-for-code-values.ts index b17f76c6a24..8bfcc9762a1 100644 --- a/packages/studio-shared/src/optimistic-update-for-code-values.ts +++ b/packages/studio-shared/src/optimistic-update-for-code-values.ts @@ -39,5 +39,6 @@ export const optimisticUpdateForCodeValues = ({ return { canUpdate: true, props, + effects: previous.effects, }; }; diff --git a/packages/studio-shared/src/optimistic-update-for-effect-code-values.ts b/packages/studio-shared/src/optimistic-update-for-effect-code-values.ts new file mode 100644 index 00000000000..86ab01474f6 --- /dev/null +++ b/packages/studio-shared/src/optimistic-update-for-effect-code-values.ts @@ -0,0 +1,66 @@ +import { + type CanUpdateEffectPropsResponse, + type CanUpdateSequencePropsResponse, + type CanUpdateSequencePropStatus, + type SequenceSchema, +} from 'remotion'; +import {NoReactInternals} from 'remotion/no-react'; + +export const optimisticUpdateForEffectCodeValues = ({ + previous, + effectIndex, + fieldKey, + value, + schema, +}: { + previous: CanUpdateSequencePropsResponse; + effectIndex: number; + fieldKey: string; + value: unknown; + schema: SequenceSchema; +}): CanUpdateSequencePropsResponse => { + if (!previous.canUpdate) { + return previous; + } + + const targetIndex = previous.effects.findIndex( + (e) => e.effectIndex === effectIndex, + ); + if (targetIndex === -1) { + return previous; + } + + const target = previous.effects[targetIndex]; + if (!target.canUpdate) { + return previous; + } + + const props: Record = { + ...target.props, + [fieldKey]: {canUpdate: true, codeValue: value}, + }; + + if (schema[fieldKey]?.type === 'enum') { + const propsToDelete = NoReactInternals.findPropsToDelete({ + schema, + key: fieldKey, + value, + }); + for (const propToDelete of propsToDelete) { + delete props[propToDelete]; + } + } + + const updatedEffect: CanUpdateEffectPropsResponse = { + ...target, + props, + }; + + const effects = [...previous.effects]; + effects[targetIndex] = updatedEffect; + + return { + ...previous, + effects, + }; +}; diff --git a/packages/studio-shared/src/schema-field-info.ts b/packages/studio-shared/src/schema-field-info.ts index 19ee5c423b3..27cbfa0ab26 100644 --- a/packages/studio-shared/src/schema-field-info.ts +++ b/packages/studio-shared/src/schema-field-info.ts @@ -1,6 +1,7 @@ import type { CodeValues, DragOverrides, + EffectDefinitionAndStack, SequenceControls, VisibleFieldSchema, SequenceSchema, @@ -23,6 +24,21 @@ export type SchemaFieldInfo = { fieldSchema: VisibleFieldSchema; }; +export type SequenceSchemaFieldInfo = SchemaFieldInfo & { + readonly kind: 'sequence-field'; +}; + +export type EffectSchemaFieldInfo = SchemaFieldInfo & { + readonly kind: 'effect-field'; + readonly effectIndex: number; + readonly factoryName: string; + readonly effectSchema: SequenceSchema; +}; + +export type AnySchemaFieldInfo = + | SequenceSchemaFieldInfo + | EffectSchemaFieldInfo; + export const SCHEMA_FIELD_ROW_HEIGHT = 22; export const UNSUPPORTED_FIELD_ROW_HEIGHT = 22; @@ -46,7 +62,7 @@ export const getFieldsToShow = ({ getDragOverrides: GetDragOverrides; getCodeValues: GetCodeValues; nodePath: SequenceNodePath; -}): SchemaFieldInfo[] | null => { +}): SequenceSchemaFieldInfo[] | null => { const {merged: valuesDotNotation} = Internals.computeEffectiveSchemaValuesDotNotation({ schema, @@ -61,7 +77,7 @@ export const getFieldsToShow = ({ ); return Object.entries(activeSchema) - .map(([key, fieldSchema]) => { + .map(([key, fieldSchema]): SequenceSchemaFieldInfo | null => { const typeName = fieldSchema.type; const supported = SUPPORTED_SCHEMA_TYPES.has(typeName); if (typeName === 'hidden') { @@ -69,6 +85,7 @@ export const getFieldsToShow = ({ } return { + kind: 'sequence-field', key, description: fieldSchema.description, typeName, @@ -82,3 +99,41 @@ export const getFieldsToShow = ({ }) .filter(NoReactInternals.truthy); }; + +export const getEffectFieldsToShow = ( + effect: EffectDefinitionAndStack, +): EffectSchemaFieldInfo[] => { + const effectSchema = effect.definition.schema; + if (!effectSchema) { + return []; + } + + const params = (effect.params ?? {}) as Record; + + return Object.entries(effectSchema) + .map(([key, fieldSchema]): EffectSchemaFieldInfo | null => { + const typeName = fieldSchema.type; + if (typeName === 'hidden') { + return null; + } + + const supported = SUPPORTED_SCHEMA_TYPES.has(typeName); + + return { + kind: 'effect-field', + key, + description: fieldSchema.description, + typeName, + supported, + rowHeight: supported + ? SCHEMA_FIELD_ROW_HEIGHT + : UNSUPPORTED_FIELD_ROW_HEIGHT, + currentRuntimeValue: params[key], + fieldSchema, + effectIndex: effect.sourceIndex, + factoryName: effect.definition.factoryName, + effectSchema, + }; + }) + .filter(NoReactInternals.truthy); +}; diff --git a/packages/studio-shared/src/test/optimistic-update-for-code-values.test.ts b/packages/studio-shared/src/test/optimistic-update-for-code-values.test.ts index bb9e90b4cdf..a1f1551caf3 100644 --- a/packages/studio-shared/src/test/optimistic-update-for-code-values.test.ts +++ b/packages/studio-shared/src/test/optimistic-update-for-code-values.test.ts @@ -11,6 +11,7 @@ test('optimisticUpdateForCodeValues should return the correct response', () => { codeValue: 0.5, }, }, + effects: [], }; const updated = optimisticUpdateForCodeValues({ previous, @@ -27,6 +28,7 @@ test('optimisticUpdateForCodeValues should return the correct response', () => { codeValue: 0.6, }, }, + effects: [], }); const layout = optimisticUpdateForCodeValues({ @@ -43,5 +45,6 @@ test('optimisticUpdateForCodeValues should return the correct response', () => { codeValue: 'none', }, }, + effects: [], }); }); diff --git a/packages/studio-shared/src/test/optimistic-update-for-effect-code-values.test.ts b/packages/studio-shared/src/test/optimistic-update-for-effect-code-values.test.ts new file mode 100644 index 00000000000..4f3d0ff9fdd --- /dev/null +++ b/packages/studio-shared/src/test/optimistic-update-for-effect-code-values.test.ts @@ -0,0 +1,101 @@ +import {expect, test} from 'bun:test'; +import type {CanUpdateSequencePropsResponse} from 'remotion'; +import {optimisticUpdateForEffectCodeValues} from '../optimistic-update-for-effect-code-values'; + +test('optimisticUpdateForEffectCodeValues updates the matching effect prop', () => { + const previous: CanUpdateSequencePropsResponse = { + canUpdate: true, + props: {}, + effects: [ + { + canUpdate: true, + effectIndex: 0, + factoryName: 'tint', + props: { + color: {canUpdate: true, codeValue: 'red'}, + opacity: {canUpdate: true, codeValue: 0.5}, + }, + }, + ], + }; + + const updated = optimisticUpdateForEffectCodeValues({ + previous, + effectIndex: 0, + fieldKey: 'opacity', + value: 0.8, + schema: {opacity: {type: 'number', default: 1}}, + }); + + if (!updated.canUpdate) { + throw new Error('expected canUpdate true'); + } + + const effect = updated.effects[0]; + if (!effect.canUpdate) { + throw new Error('expected effect canUpdate true'); + } + + expect(effect.props.opacity).toEqual({canUpdate: true, codeValue: 0.8}); + expect(effect.props.color).toEqual({canUpdate: true, codeValue: 'red'}); +}); + +test('optimisticUpdateForEffectCodeValues is a no-op when sequence is not updateable', () => { + const previous: CanUpdateSequencePropsResponse = { + canUpdate: false, + reason: 'something', + }; + + const result = optimisticUpdateForEffectCodeValues({ + previous, + effectIndex: 0, + fieldKey: 'opacity', + value: 0.8, + schema: {opacity: {type: 'number', default: 1}}, + }); + + expect(result).toBe(previous); +}); + +test('optimisticUpdateForEffectCodeValues is a no-op when effect index not found', () => { + const previous: CanUpdateSequencePropsResponse = { + canUpdate: true, + props: {}, + effects: [], + }; + + const result = optimisticUpdateForEffectCodeValues({ + previous, + effectIndex: 0, + fieldKey: 'opacity', + value: 0.8, + schema: {opacity: {type: 'number', default: 1}}, + }); + + expect(result).toBe(previous); +}); + +test('optimisticUpdateForEffectCodeValues is a no-op when effect status is non-updateable', () => { + const previous: CanUpdateSequencePropsResponse = { + canUpdate: true, + props: {}, + effects: [ + { + canUpdate: false, + effectIndex: 0, + factoryName: 'tint', + reason: 'effect-reordered', + }, + ], + }; + + const result = optimisticUpdateForEffectCodeValues({ + previous, + effectIndex: 0, + fieldKey: 'opacity', + value: 0.8, + schema: {opacity: {type: 'number', default: 1}}, + }); + + expect(result).toBe(previous); +}); diff --git a/packages/studio/src/components/Timeline/SubscribeToNodePaths.tsx b/packages/studio/src/components/Timeline/SubscribeToNodePaths.tsx index f7fffa0962e..ebc329ab6bb 100644 --- a/packages/studio/src/components/Timeline/SubscribeToNodePaths.tsx +++ b/packages/studio/src/components/Timeline/SubscribeToNodePaths.tsx @@ -1,18 +1,38 @@ -import type React from 'react'; -import type {SequenceSchema} from 'remotion'; +import type {EffectSubscription} from '@remotion/studio-shared'; +import {useMemo, type FC} from 'react'; +import type {EffectDefinitionAndStack, SequenceSchema} from 'remotion'; +import {NoReactInternals} from 'remotion/no-react'; import {useResolvedStack} from './use-resolved-stack'; import {useSequencePropsSubscription} from './use-sequence-props-subscription'; -export const SubscribeToNodePaths: React.FC<{ +export const SubscribeToNodePaths: FC<{ readonly overrideId: string; readonly schema: SequenceSchema; readonly stack: string; -}> = ({overrideId, schema, stack}) => { + readonly effects: EffectDefinitionAndStack[]; +}> = ({overrideId, schema, stack, effects}) => { const originalLocation = useResolvedStack(stack); + const effectSubscriptions = useMemo(() => { + return effects + .map((effect): EffectSubscription | null => { + if (!effect.definition.schema) { + return null; + } + + return { + effectIndex: effect.sourceIndex, + factoryName: effect.definition.factoryName, + schema: effect.definition.schema, + }; + }) + .filter(NoReactInternals.truthy); + }, [effects]); + useSequencePropsSubscription({ overrideId, schema, + effects: effectSubscriptions, originalLocation, }); diff --git a/packages/studio/src/components/Timeline/Timeline.tsx b/packages/studio/src/components/Timeline/Timeline.tsx index 46a6b703332..dd221bff502 100644 --- a/packages/studio/src/components/Timeline/Timeline.tsx +++ b/packages/studio/src/components/Timeline/Timeline.tsx @@ -100,6 +100,7 @@ const TimelineInner: React.FC = () => { overrideId={sequence.controls.overrideId} schema={sequence.controls.schema} stack={sequence.stack} + effects={sequence.effects} /> ); })} diff --git a/packages/studio/src/components/Timeline/TimelineEffectFieldRow.tsx b/packages/studio/src/components/Timeline/TimelineEffectFieldRow.tsx new file mode 100644 index 00000000000..bb13e408a7e --- /dev/null +++ b/packages/studio/src/components/Timeline/TimelineEffectFieldRow.tsx @@ -0,0 +1,210 @@ +import {optimisticUpdateForEffectCodeValues} from '@remotion/studio-shared'; +import React, {useCallback, useContext, useMemo} from 'react'; +import type { + CanUpdateEffectPropsResponse, + CanUpdateSequencePropsResponse, + SequenceNodePath, +} from 'remotion'; +import {Internals} from 'remotion'; +import type {CodePosition} from '../../error-overlay/react-overlay/utils/get-source-map'; +import type {EffectSchemaFieldInfo} from '../../helpers/timeline-layout'; +import {EXPANDED_SECTION_PADDING_RIGHT} from '../../helpers/timeline-layout'; +import {callApi} from '../call-api'; +import {Padder} from './Padder'; +import {TimelineFieldValue} from './TimelineSchemaField'; + +const fieldRowBase: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 8, + paddingRight: EXPANDED_SECTION_PADDING_RIGHT, +}; + +const fieldName: React.CSSProperties = { + fontSize: 12, + color: 'rgba(255, 255, 255, 0.8)', + userSelect: 'none', +}; + +const fieldLabelRow: React.CSSProperties = { + flex: '0 0 50%', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 6, +}; + +export const TimelineEffectFieldRow: React.FC<{ + readonly field: EffectSchemaFieldInfo; + readonly validatedLocation: CodePosition | null; + readonly paddingLeft: number; + readonly nestedDepth: number; + readonly nodePath: SequenceNodePath; +}> = ({field, validatedLocation, paddingLeft, nestedDepth, nodePath}) => { + const {getCodeValues} = useContext(Internals.VisualModeCodeValuesContext); + const {getEffectCodeValues} = useContext( + Internals.VisualModeCodeValuesContext, + ); + const {getEffectDragOverrides} = useContext( + Internals.VisualModeDragOverridesContext, + ); + const {setEffectDragOverrides, clearEffectDragOverrides, setCodeValues} = + useContext(Internals.VisualModeSettersContext); + + const codeValuesForSequence = getCodeValues(nodePath); + const propStatus = + getEffectCodeValues(nodePath, field.effectIndex)?.[field.key] ?? null; + + if (propStatus === null) { + throw new Error( + 'Unexpectedly got null code value for effect field' + field.key, + ); + } + + const dragOverrideValue = useMemo(() => { + const overrides = getEffectDragOverrides(nodePath, field.effectIndex); + return overrides[field.key]; + }, [getEffectDragOverrides, nodePath, field.effectIndex, field.key]); + + const effectiveValue = Internals.getEffectiveVisualModeValue({ + codeValue: propStatus, + runtimeValue: field.currentRuntimeValue, + dragOverrideValue, + defaultValue: field.fieldSchema.default, + shouldResortToDefaultValueIfUndefined: true, + }); + + const onSave = useCallback( + (value: unknown) => { + if (!codeValuesForSequence || !validatedLocation || !nodePath) { + return Promise.reject(new Error('Cannot save')); + } + + if (!propStatus.canUpdate) { + return Promise.reject(new Error('Cannot save')); + } + + const defaultValue = + field.fieldSchema.default !== undefined + ? JSON.stringify(field.fieldSchema.default) + : null; + + const stringifiedValue = JSON.stringify(value); + + if (value === propStatus.codeValue) { + return Promise.resolve(); + } + + if ( + defaultValue === stringifiedValue && + propStatus.codeValue === undefined + ) { + return Promise.resolve(); + } + + let previousUpdate: CanUpdateSequencePropsResponse | undefined; + + setCodeValues(nodePath, (prev) => { + previousUpdate = prev; + return optimisticUpdateForEffectCodeValues({ + previous: prev, + effectIndex: field.effectIndex, + fieldKey: field.key, + value, + schema: field.effectSchema, + }); + }); + + return callApi('/api/save-effect-props', { + fileName: validatedLocation.source, + sequenceNodePath: nodePath, + effectIndex: field.effectIndex, + factoryName: field.factoryName, + key: field.key, + value: stringifiedValue, + defaultValue, + schema: field.effectSchema, + }) + .then((data: CanUpdateEffectPropsResponse) => { + setCodeValues(nodePath, (prev) => { + if (!prev.canUpdate) { + return prev; + } + + const idx = prev.effects.findIndex( + (e) => e.effectIndex === field.effectIndex, + ); + if (idx === -1) { + return { + ...prev, + effects: [...prev.effects, data], + }; + } + + const next = [...prev.effects]; + next[idx] = data; + return {...prev, effects: next}; + }); + }) + .catch(() => { + if (previousUpdate) { + setCodeValues(nodePath, (current) => { + if (previousUpdate) { + return previousUpdate; + } + + return current; + }); + } + }); + }, + [ + codeValuesForSequence, + field.effectIndex, + field.effectSchema, + field.factoryName, + field.fieldSchema.default, + field.key, + nodePath, + propStatus, + setCodeValues, + validatedLocation, + ], + ); + + const onDragValueChange = useCallback( + (value: unknown) => { + setEffectDragOverrides(nodePath, field.effectIndex, field.key, value); + }, + [setEffectDragOverrides, nodePath, field.effectIndex, field.key], + ); + + const onDragEnd = useCallback(() => { + clearEffectDragOverrides(nodePath, field.effectIndex); + }, [clearEffectDragOverrides, nodePath, field.effectIndex]); + + const style = useMemo(() => { + return { + ...fieldRowBase, + height: field.rowHeight, + paddingLeft, + }; + }, [field.rowHeight, paddingLeft]); + + return ( +

+ +
+ {field.description ?? field.key} +
+ +
+ ); +}; diff --git a/packages/studio/src/components/Timeline/TimelineExpandedRow.tsx b/packages/studio/src/components/Timeline/TimelineExpandedRow.tsx index a2b64f3ba67..e06a619a648 100644 --- a/packages/studio/src/components/Timeline/TimelineExpandedRow.tsx +++ b/packages/studio/src/components/Timeline/TimelineExpandedRow.tsx @@ -11,6 +11,7 @@ import { } from '../../helpers/timeline-layout'; import type {GetIsExpanded} from '../ExpandedTracksProvider'; import {Padder} from './Padder'; +import {TimelineEffectFieldRow} from './TimelineEffectFieldRow'; import {TimelineExpandArrowButton} from './TimelineExpandArrowButton'; import {TimelineFieldRow} from './TimelineFieldRow'; import {INDENT} from './TimelineListItem'; @@ -86,6 +87,18 @@ export const TimelineExpandedRow: React.FC<{ } if (node.field) { + if (node.field.kind === 'effect-field') { + return ( + + ); + } + return ( { - setCodeValues(nodePath, () => data); + setCodeValues(nodePath, (prev) => { + if (!data.canUpdate) { + return data; + } + + return { + canUpdate: true, + props: data.props, + effects: prev.canUpdate ? prev.effects : [], + }; + }); }) .catch(() => { // In case something went wrong, undo optimistic update diff --git a/packages/studio/src/components/Timeline/sequence-props-subscription-store.ts b/packages/studio/src/components/Timeline/sequence-props-subscription-store.ts index ddcdcb34cf8..4d52419396b 100644 --- a/packages/studio/src/components/Timeline/sequence-props-subscription-store.ts +++ b/packages/studio/src/components/Timeline/sequence-props-subscription-store.ts @@ -1,3 +1,4 @@ +import type {EffectSubscription} from '@remotion/studio-shared'; import type {SequenceSchema} from 'remotion'; import {Internals} from 'remotion'; import {callApi} from '../call-api'; @@ -28,6 +29,7 @@ export const acquireSequencePropsSubscription = ({ line, column, schema, + effects, clientId, applyOnce, applyEach, @@ -36,6 +38,7 @@ export const acquireSequencePropsSubscription = ({ line: number; column: number; schema: SequenceSchema; + effects: EffectSubscription[]; clientId: string; applyOnce: ApplyResult; applyEach: ApplyResult; @@ -49,6 +52,7 @@ export const acquireSequencePropsSubscription = ({ line, column, schema, + effects, clientId, }); const created: Entry = { diff --git a/packages/studio/src/components/Timeline/use-sequence-props-subscription.ts b/packages/studio/src/components/Timeline/use-sequence-props-subscription.ts index e4af90bddbb..2f4edb73d53 100644 --- a/packages/studio/src/components/Timeline/use-sequence-props-subscription.ts +++ b/packages/studio/src/components/Timeline/use-sequence-props-subscription.ts @@ -1,3 +1,4 @@ +import type {EffectSubscription} from '@remotion/studio-shared'; import {useContext, useEffect, useMemo} from 'react'; import {Internals} from 'remotion'; import type {SequenceSchema} from 'remotion'; @@ -9,9 +10,11 @@ export const useSequencePropsSubscription = ({ originalLocation, overrideId, schema, + effects, }: { overrideId: string; schema: SequenceSchema; + effects: EffectSubscription[]; originalLocation: OriginalPosition | null; }) => { const {setCodeValues} = useContext(Internals.VisualModeSettersContext); @@ -58,6 +61,7 @@ export const useSequencePropsSubscription = ({ line: locationLine, column: locationColumn, schema, + effects, clientId, applyOnce: (result) => { if (!result.success) { @@ -80,6 +84,7 @@ export const useSequencePropsSubscription = ({ }; }, [ clientId, + effects, locationColumn, locationLine, locationSource, diff --git a/packages/studio/src/helpers/timeline-layout.ts b/packages/studio/src/helpers/timeline-layout.ts index fba8ca55177..f218949c54f 100644 --- a/packages/studio/src/helpers/timeline-layout.ts +++ b/packages/studio/src/helpers/timeline-layout.ts @@ -1,24 +1,31 @@ import { + getEffectFieldsToShow, getFieldsToShow, + type AnySchemaFieldInfo, type CodeValues, type DragOverrides, + type EffectSchemaFieldInfo, type SchemaFieldInfo, type SequenceControls, + type SequenceSchemaFieldInfo, } from '@remotion/studio-shared'; -import type { - EffectDefinitionAndStack, - GetCodeValues, - GetDragOverrides, - TSequence, -} from 'remotion'; -import {NoReactInternals} from 'remotion/no-react'; +import type {GetCodeValues, GetDragOverrides, TSequence} from 'remotion'; import type {GetIsExpanded} from '../components/ExpandedTracksProvider'; import type {SequenceNodePathInfo} from './get-timeline-sequence-sort-key'; -export type {CodeValues, DragOverrides, SchemaFieldInfo, SequenceControls}; +export type { + AnySchemaFieldInfo, + CodeValues, + DragOverrides, + EffectSchemaFieldInfo, + SchemaFieldInfo, + SequenceControls, + SequenceSchemaFieldInfo, +}; export { SCHEMA_FIELD_ROW_HEIGHT, UNSUPPORTED_FIELD_ROW_HEIGHT, + getEffectFieldsToShow, getFieldsToShow, } from '@remotion/studio-shared'; @@ -35,32 +42,6 @@ export const EXPANDED_SECTION_PADDING_RIGHT = 10; export type TimelineFieldOnSave = (value: unknown) => Promise; export type TimelineFieldOnDragValueChange = (value: unknown) => void; -export type EffectSchemaFieldLabel = { - key: string; - description: string | undefined; -}; - -export const getEffectSchemaLabels = ( - effect: EffectDefinitionAndStack, -): EffectSchemaFieldLabel[] => { - if (!effect.definition.schema) { - return []; - } - - return Object.entries(effect.definition.schema) - .map(([key, fieldSchema]) => { - if (fieldSchema.type === 'hidden') { - return null; - } - - return { - key, - description: fieldSchema.description, - }; - }) - .filter(NoReactInternals.truthy); -}; - export type TimelineTreeNode = | { readonly kind: 'group'; @@ -72,7 +53,7 @@ export type TimelineTreeNode = readonly kind: 'field'; readonly nodePathInfo: SequenceNodePathInfo; readonly label: string; - readonly field: SchemaFieldInfo | null; + readonly field: AnySchemaFieldInfo | null; }; export const buildTimelineTree = ({ @@ -100,6 +81,7 @@ export const buildTimelineTree = ({ label: 'Effects', children: sequence.effects.map((effect, i): TimelineTreeNode => { const effectNodePath = [...nodePath, 'effects', i]; + const effectFields = getEffectFieldsToShow(effect); return { kind: 'group', nodePathInfo: { @@ -108,16 +90,16 @@ export const buildTimelineTree = ({ numberOfSequencesWithThisNodePath: 0, }, label: effect.definition.label, - children: getEffectSchemaLabels(effect).map( - (label): TimelineTreeNode => ({ + children: effectFields.map( + (f): TimelineTreeNode => ({ kind: 'field', nodePathInfo: { - nodePath: [...effectNodePath, label.key], + nodePath: [...effectNodePath, f.key], index, numberOfSequencesWithThisNodePath: 0, }, - label: label.description ?? label.key, - field: null, + label: f.description ?? f.key, + field: f, }), ), }; From dd6ec65182abbeed4a2e3dac41eabee200999b3a Mon Sep 17 00:00:00 2001 From: JonnyBurger Date: Thu, 14 May 2026 10:43:42 +0200 Subject: [PATCH 02/73] Add `hidden` prop to , persist via Studio timeline toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #7316 - Adds a `hidden` boolean field to the sequence schema, exposed as a new `hidden` prop on ``. When `true`, the sequence (and its children) unmount. - The Studio timeline's eye/speaker toggle now writes the `hidden` prop back to source via `save-sequence-props` instead of using the in-memory `SequenceVisibilityToggleContext`, which has been removed. - `getFieldsToShow` filters out the `hidden` field so it does not appear as a regular row in the expanded section — the toggle is the sole control. - For tracks without controls (e.g. ``, ``), the toggle is hidden entirely and a spacer keeps layout consistent. Co-authored-by: Cursor --- packages/core/src/Sequence.tsx | 12 +- packages/core/src/SequenceManager.tsx | 37 ++---- packages/core/src/audio/AudioForPreview.tsx | 9 +- packages/core/src/internals.ts | 2 - packages/core/src/sequence-field-schema.ts | 5 + .../src/test/wrap-in-schema-helpers.test.ts | 12 +- packages/core/src/version.ts | 2 +- packages/core/src/video/VideoForPreview.tsx | 10 +- packages/docs/docs/sequence.mdx | 7 ++ .../src/test/get-all-schema-keys.test.ts | 1 + .../studio-shared/src/schema-field-info.ts | 6 + .../components/Timeline/TimelineLayerEye.tsx | 19 ++- .../components/Timeline/TimelineListItem.tsx | 116 +++++++++++++++--- 13 files changed, 155 insertions(+), 83 deletions(-) diff --git a/packages/core/src/Sequence.tsx b/packages/core/src/Sequence.tsx index 40edcbcfe2a..a5f63e72a8e 100644 --- a/packages/core/src/Sequence.tsx +++ b/packages/core/src/Sequence.tsx @@ -15,10 +15,7 @@ import {PremountContext} from './PremountContext.js'; import {sequenceSchema} from './sequence-field-schema.js'; import type {SequenceContextType} from './SequenceContext.js'; import {SequenceContext} from './SequenceContext.js'; -import { - SequenceManager, - SequenceVisibilityToggleContext, -} from './SequenceManager.js'; +import {SequenceManager} from './SequenceManager.js'; import { useTimelineContext, useTimelinePosition, @@ -53,6 +50,7 @@ export type SequencePropsWithoutDuration = { readonly from?: number; readonly name?: string; readonly showInTimeline?: boolean; + readonly hidden?: boolean; readonly _experimentalControls?: SequenceControls; readonly _experimentalEffects?: EffectDefinitionAndStack[]; /** @@ -104,6 +102,7 @@ const RegularSequenceRefForwardingFunction: React.ForwardRefRenderFunction< height, width, showInTimeline = true, + hidden = false, _experimentalControls: controls, _experimentalEffects, _remotionInternalLoopDisplay: loopDisplay, @@ -175,7 +174,6 @@ const RegularSequenceRefForwardingFunction: React.ForwardRefRenderFunction< Math.min(videoConfig.durationInFrames - from, parentSequenceDuration), ); const {registerSequence, unregisterSequence} = useContext(SequenceManager); - const {hidden} = useContext(SequenceVisibilityToggleContext); const premounting = useMemo(() => { // || is intentional, ?? would not trigger on `false` @@ -333,9 +331,7 @@ const RegularSequenceRefForwardingFunction: React.ForwardRefRenderFunction< ); } - const isSequenceHidden = hidden[id] ?? false; - - if (isSequenceHidden) { + if (hidden) { return null; } diff --git a/packages/core/src/SequenceManager.tsx b/packages/core/src/SequenceManager.tsx index 40125ca3572..95913d66ed3 100644 --- a/packages/core/src/SequenceManager.tsx +++ b/packages/core/src/SequenceManager.tsx @@ -30,19 +30,6 @@ export const SequenceManager = React.createContext({ sequences: [], }); -export type SequenceVisibilityToggleState = { - hidden: Record; - setHidden: React.Dispatch>>; -}; - -export const SequenceVisibilityToggleContext = - React.createContext({ - hidden: {}, - setHidden: () => { - throw new Error('SequenceVisibilityToggle not initialized'); - }, - }); - export type VisualModeCodeValues = { getCodeValues: GetCodeValues; }; @@ -128,7 +115,6 @@ export const SequenceManagerProvider: React.FC<{ readonly children: React.ReactNode; }> = ({children}) => { const [sequences, setSequences] = useState([]); - const [hidden, setHidden] = useState>({}); const [dragOverrides, setControlOverrides] = useState({}); const controlOverridesRef = useRef(dragOverrides); controlOverridesRef.current = dragOverrides; @@ -201,13 +187,6 @@ export const SequenceManagerProvider: React.FC<{ }; }, [registerSequence, sequences, unregisterSequence]); - const hiddenContext: SequenceVisibilityToggleState = useMemo(() => { - return { - hidden, - setHidden, - }; - }, [hidden]); - const getDragOverrides = useCallback( (nodePath: SequenceNodePath) => { return dragOverrides[nodePathToString(nodePath)] ?? {}; @@ -244,15 +223,13 @@ export const SequenceManagerProvider: React.FC<{ return ( - - - - - {children} - - - - + + + + {children} + + + ); }; diff --git a/packages/core/src/audio/AudioForPreview.tsx b/packages/core/src/audio/AudioForPreview.tsx index f3216a60f9e..73a37bc55bf 100644 --- a/packages/core/src/audio/AudioForPreview.tsx +++ b/packages/core/src/audio/AudioForPreview.tsx @@ -13,7 +13,6 @@ import {useLogLevel} from '../log-level-context.js'; import {usePreload} from '../prefetch.js'; import {random} from '../random.js'; import {SequenceContext} from '../SequenceContext.js'; -import {SequenceVisibilityToggleContext} from '../SequenceManager.js'; import {useVolume} from '../use-amplification.js'; import {useMediaInTimeline} from '../use-media-in-timeline.js'; import {useMediaPlayback} from '../use-media-playback.js'; @@ -99,8 +98,6 @@ const AudioForDevelopmentForwardRefFunction: React.ForwardRefRenderFunction< loopVolumeCurveBehavior ?? 'repeat', ); - const {hidden} = useContext(SequenceVisibilityToggleContext); - if (!src) { throw new TypeError("No 'src' was passed to ."); } @@ -111,8 +108,6 @@ const AudioForDevelopmentForwardRefFunction: React.ForwardRefRenderFunction< const [timelineId] = useState(() => String(Math.random())); - const isSequenceHidden = hidden[timelineId] ?? false; - const userPreferredVolume = evaluateVolume({ frame: volumePropFrame, volume, @@ -129,8 +124,7 @@ const AudioForDevelopmentForwardRefFunction: React.ForwardRefRenderFunction< const propsToPass = useMemo((): AudioHTMLAttributes => { return { - muted: - muted || mediaMuted || isSequenceHidden || userPreferredVolume <= 0, + muted: muted || mediaMuted || userPreferredVolume <= 0, src: preloadedSrc, loop: _remotionInternalNativeLoopPassed, crossOrigin: crossOriginValue, @@ -138,7 +132,6 @@ const AudioForDevelopmentForwardRefFunction: React.ForwardRefRenderFunction< }; }, [ _remotionInternalNativeLoopPassed, - isSequenceHidden, mediaMuted, muted, nativeProps, diff --git a/packages/core/src/internals.ts b/packages/core/src/internals.ts index 90f79050913..9e4232930d2 100644 --- a/packages/core/src/internals.ts +++ b/packages/core/src/internals.ts @@ -126,7 +126,6 @@ import { VisualModeDragOverridesContext, VisualModeSettersContext, SequenceManager, - SequenceVisibilityToggleContext, } from './SequenceManager.js'; import {setupEnvVariables} from './setup-env-variables.js'; import * as TimelinePosition from './timeline-position-state.js'; @@ -231,7 +230,6 @@ export const Internals = { VisualModeSettersContext, SequenceManager, SequenceStackTracesUpdateContext, - SequenceVisibilityToggleContext, wrapInSchema, sequenceSchema, sequenceStyleSchema, diff --git a/packages/core/src/sequence-field-schema.ts b/packages/core/src/sequence-field-schema.ts index fcfcca78254..2a20999f266 100644 --- a/packages/core/src/sequence-field-schema.ts +++ b/packages/core/src/sequence-field-schema.ts @@ -102,6 +102,11 @@ export const sequenceStyleSchema = { } as const satisfies SequenceSchema; export const sequenceSchema = { + hidden: { + type: 'boolean', + default: false, + description: 'Hidden', + }, layout: { type: 'enum', default: 'absolute-fill', diff --git a/packages/core/src/test/wrap-in-schema-helpers.test.ts b/packages/core/src/test/wrap-in-schema-helpers.test.ts index 8ebd3cf7977..78c419e7b2b 100644 --- a/packages/core/src/test/wrap-in-schema-helpers.test.ts +++ b/packages/core/src/test/wrap-in-schema-helpers.test.ts @@ -12,6 +12,7 @@ test('getFlatSchema(sequenceSchema) exposes every variant key', () => { const flat = getFlatSchemaWithAllKeys(sequenceSchema); expect(Object.keys(flat).sort()).toEqual( [ + 'hidden', 'layout', 'style.translate', 'style.scale', @@ -38,12 +39,14 @@ test('readValuesFromProps reads dot-notation keys via getNestedValue', () => { expect(values['style.rotate']).toBeUndefined(); }); -test('selectActiveKeys returns only the layout key when layout=none', () => { +test('selectActiveKeys returns only the hidden + layout keys when layout=none', () => { const values = { layout: 'none', 'style.scale': 2, }; - expect(selectActiveKeys(sequenceSchema, values)).toEqual(['layout']); + expect(selectActiveKeys(sequenceSchema, values).sort()).toEqual( + ['hidden', 'layout'].sort(), + ); }); test('selectActiveKeys exposes style.* keys when layout=absolute-fill', () => { @@ -53,6 +56,7 @@ test('selectActiveKeys exposes style.* keys when layout=absolute-fill', () => { }; expect(selectActiveKeys(sequenceSchema, values).sort()).toEqual( [ + 'hidden', 'layout', 'style.translate', 'style.scale', @@ -67,7 +71,7 @@ test('selectActiveKeys exposes style.* keys when layout=absolute-fill', () => { 'style.scale': 2, }; expect(selectActiveKeys(sequenceSchema, values2).sort()).toEqual( - ['layout'].sort(), + ['hidden', 'layout'].sort(), ); }); @@ -95,7 +99,7 @@ test('end-to-end: layout=none drops style.scale from active props', () => { schemaKeys: activeKeys, propsToDelete: new Set(), }); - expect(activeKeys).toEqual(['layout']); + expect(activeKeys.sort()).toEqual(['hidden', 'layout'].sort()); // style.scale was not in activeKeys → original style preserved, not overwritten expect((merged.style as {scale: number}).scale).toBe(2); }); diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts index 6788ce61315..44d1671c75a 100644 --- a/packages/core/src/version.ts +++ b/packages/core/src/version.ts @@ -5,4 +5,4 @@ * @see [Documentation](https://remotion.dev/docs/version) * @returns {string} The current version of the remotion package */ -export const VERSION = '4.0.461'; +export const VERSION = '4.0.462'; diff --git a/packages/core/src/video/VideoForPreview.tsx b/packages/core/src/video/VideoForPreview.tsx index db80e5cf785..9fe57550843 100644 --- a/packages/core/src/video/VideoForPreview.tsx +++ b/packages/core/src/video/VideoForPreview.tsx @@ -16,7 +16,6 @@ import {useLogLevel, useMountTime} from '../log-level-context.js'; import {playbackLogging} from '../playback-logging.js'; import {usePreload} from '../prefetch.js'; import {SequenceContext} from '../SequenceContext.js'; -import {SequenceVisibilityToggleContext} from '../SequenceManager.js'; import {useVolume} from '../use-amplification.js'; import {useMediaInTimeline} from '../use-media-in-timeline.js'; import {useMediaPlayback} from '../use-media-playback.js'; @@ -133,12 +132,10 @@ const VideoForDevelopmentRefForwardingFunction: React.ForwardRefRenderFunction< ); const {fps, durationInFrames} = useVideoConfig(); const parentSequence = useContext(SequenceContext); - const {hidden} = useContext(SequenceVisibilityToggleContext); const logLevel = useLogLevel(); const mountTime = useMountTime(); const [timelineId] = useState(() => String(Math.random())); - const isSequenceHidden = hidden[timelineId] ?? false; if (typeof acceptableTimeShift !== 'undefined') { throw new Error( @@ -333,9 +330,8 @@ const VideoForDevelopmentRefForwardingFunction: React.ForwardRefRenderFunction< const actualStyle: React.CSSProperties = useMemo(() => { return { ...style, - opacity: isSequenceHidden ? 0 : (style?.opacity ?? 1), }; - }, [isSequenceHidden, style]); + }, [style]); const crossOriginValue = getCrossOriginValue({ crossOrigin, @@ -346,9 +342,7 @@ const VideoForDevelopmentRefForwardingFunction: React.ForwardRefRenderFunction< return (