diff --git a/packages/core/src/effects/create-effect.ts b/packages/core/src/effects/create-effect.ts index ffe1f3c35de..f2bcaafadb8 100644 --- a/packages/core/src/effects/create-effect.ts +++ b/packages/core/src/effects/create-effect.ts @@ -1,14 +1,25 @@ -import type {EffectDefinition, EffectDescriptor} from './effect-types.js'; +import type {SequenceFieldSchema} from '../sequence-field-schema.js'; +import type { + EffectDefinition, + EffectDescriptor, + EffectFactory, +} from './effect-types.js'; -type EffectFactory

= {} extends P - ? (params?: P) => EffectDescriptor - : (params: P) => EffectDescriptor; +// Framework-level field that every effect inherits. Mirrors `hidden` for +// sequences: rendered as the eye toggle on the timeline effect row and saved +// to source via `/api/save-effect-props`. `getEffectFieldsToShow` filters it +// out of the regular field list so the toggle is the only control. +export const disabledEffectField: SequenceFieldSchema = { + type: 'boolean', + default: false, + description: 'Disabled', +}; // Defines an effect and returns a factory that produces per-frame descriptors. // The returned function is the public API effect authors expose to users: // // export const blur = createEffect({ ... }); -// // usage: blur({ radius: 4 }) +// // usage: blur({ radius: 4, disabled: false }) // // The descriptor erases `P` and `S` to `unknown` so descriptors of different // effects can be freely composed inside `EffectsProp` arrays. Without that @@ -17,12 +28,30 @@ type EffectFactory

= {} extends P export const createEffect = ( definition: EffectDefinition, ): EffectFactory

=> { - const widened = definition as unknown as EffectDefinition; - const factory = (params: P = {} as P): EffectDescriptor => ({ + // Wrap `calculateKey` to fold the framework-level `disabled` flag into the + // memoization key. Without this, toggling `disabled` via code/drag overrides + // would not invalidate the cached `EffectDefinitionAndStack`. + const userCalculateKey = definition.calculateKey; + const widened: EffectDefinition = { + ...(definition as unknown as EffectDefinition), + calculateKey: (params: unknown) => { + const disabled = (params as {disabled?: boolean}).disabled ?? false; + return `${userCalculateKey(params as P)}-disabled-${disabled}`; + }, + schema: { + disabled: disabledEffectField, + ...(definition.schema ?? {}), + }, + }; + const factory = ( + params: P & {readonly disabled?: boolean} = {} as P & { + readonly disabled?: boolean; + }, + ): EffectDescriptor => ({ definition: widened, params, effectKey: widened.calculateKey(params), memoized: false, }); - return factory as EffectFactory

; + return factory; }; diff --git a/packages/core/src/effects/effect-types.ts b/packages/core/src/effects/effect-types.ts index 69461f76462..bfce97a029e 100644 --- a/packages/core/src/effects/effect-types.ts +++ b/packages/core/src/effects/effect-types.ts @@ -62,3 +62,17 @@ export type EffectDefinitionAndStack

= BaseEffectDescriptor

& { }; export type EffectsProp = ReadonlyArray>; + +// `disabled` is injected by the framework into every effect factory's +// parameter type. When truthy, `runEffectChain` bypasses the effect entirely. +// Defined here (rather than in `create-effect.ts`) so that the inferred type +// of factory exports in downstream packages is reachable through a path that +// is also referenced via `EffectDefinition` etc., avoiding TS2742 in `tsgo`. +// +// The `{} extends P` conditional preserves required-param enforcement: when +// the user's `P` has required fields (e.g. `TintParams.color`), the factory +// signature requires a params argument; when every field is optional, the +// argument is optional too. +export type EffectFactory

= {} extends P + ? (params?: P & {readonly disabled?: boolean}) => EffectDescriptor + : (params: P & {readonly disabled?: boolean}) => EffectDescriptor; diff --git a/packages/core/src/effects/index.ts b/packages/core/src/effects/index.ts index b4796bc7ef3..4fef757716f 100644 --- a/packages/core/src/effects/index.ts +++ b/packages/core/src/effects/index.ts @@ -4,4 +4,5 @@ export type { EffectDescriptor, EffectsProp, EffectDefinition, + EffectFactory, } from './effect-types.js'; diff --git a/packages/core/src/effects/run-effect-chain.ts b/packages/core/src/effects/run-effect-chain.ts index a7e8572bfab..880e2dc8e09 100644 --- a/packages/core/src/effects/run-effect-chain.ts +++ b/packages/core/src/effects/run-effect-chain.ts @@ -74,7 +74,14 @@ export const runEffectChain = async ({ const runId = ++state.currentRunId; const isCancelled = () => state.currentRunId !== runId; - const runs = groupByBackend(effects); + // Bypass any effect with `disabled: true` before grouping by backend, so + // disabled effects don't create empty runs or force unnecessary backend + // transitions. The `disabled` flag is injected by `createEffect` and lives + // on `params` so it flows through code/drag override merging. + const enabledEffects = effects.filter( + (e) => !(e.params as {disabled?: boolean}).disabled, + ); + const runs = groupByBackend(enabledEffects); let currentImage: CanvasImageSource = source; let lastTarget: HTMLCanvasElement | null = null; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2df639ba16d..4ff544eeee1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -136,6 +136,7 @@ export type { EffectDescriptor, EffectsProp, EffectDefinition, + EffectFactory, } from './effects/index.js'; export type {SolidProps} from './effects/Solid.js'; export { diff --git a/packages/core/src/test/create-effect-disabled.test.ts b/packages/core/src/test/create-effect-disabled.test.ts new file mode 100644 index 00000000000..d00abab3967 --- /dev/null +++ b/packages/core/src/test/create-effect-disabled.test.ts @@ -0,0 +1,78 @@ +import {expect, test} from 'bun:test'; +import {createEffect} from '../effects/create-effect.js'; + +type FooParams = { + readonly amount: number; +}; + +const makeFoo = () => + createEffect({ + type: 'test/foo', + label: 'Foo', + backend: '2d', + calculateKey: (p) => `foo-${p.amount}`, + setup: () => null, + apply: () => undefined, + cleanup: () => undefined, + schema: null, + }); + +test('createEffect factory accepts `disabled` without complaint', () => { + const foo = makeFoo(); + const desc = foo({amount: 1, disabled: true}); + expect((desc.params as {disabled?: boolean}).disabled).toBe(true); +}); + +test('effectKey changes when `disabled` toggles so memoization re-runs', () => { + const foo = makeFoo(); + const enabled = foo({amount: 1}); + const disabled = foo({amount: 1, disabled: true}); + const explicitlyEnabled = foo({amount: 1, disabled: false}); + + expect(enabled.effectKey).not.toBe(disabled.effectKey); + expect(enabled.effectKey).toBe(explicitlyEnabled.effectKey); +}); + +test('definition.calculateKey reflects `disabled` for override merge path', () => { + const foo = makeFoo(); + const desc = foo({amount: 1}); + const enabledKey = desc.definition.calculateKey({amount: 1}); + const disabledKey = desc.definition.calculateKey({amount: 1, disabled: true}); + expect(enabledKey).not.toBe(disabledKey); +}); + +test('createEffect injects `disabled` into the schema for save-effect-props', () => { + const foo = createEffect({ + type: 'test/foo', + label: 'Foo', + backend: '2d', + calculateKey: (p) => `foo-${p.amount}`, + setup: () => null, + apply: () => undefined, + cleanup: () => undefined, + schema: { + amount: {type: 'number', default: 0, description: 'Amount'}, + }, + }); + const desc = foo({amount: 1}); + expect(desc.definition.schema?.disabled).toEqual({ + type: 'boolean', + default: false, + description: 'Disabled', + }); + expect(desc.definition.schema?.amount).toEqual({ + type: 'number', + default: 0, + description: 'Amount', + }); +}); + +test('createEffect injects `disabled` even when the effect declares no schema', () => { + const foo = makeFoo(); + const desc = foo({amount: 1}); + expect(desc.definition.schema?.disabled).toEqual({ + type: 'boolean', + default: false, + description: 'Disabled', + }); +}); diff --git a/packages/core/src/test/effect-internals.test.ts b/packages/core/src/test/effect-internals.test.ts index c8c7236db40..b13049b1779 100644 --- a/packages/core/src/test/effect-internals.test.ts +++ b/packages/core/src/test/effect-internals.test.ts @@ -88,3 +88,22 @@ test('groupByBackend returns one run per backend transition', () => { expect(runs).toHaveLength(4); expect(runs.map((r) => r.backend)).toEqual(['2d', 'webgl2', '2d', 'webgl2']); }); + +test('runEffectChain filters disabled effects before grouping', () => { + // Mirror of the filter in `runEffectChain` — kept here as a regression + // guard so the behavior is asserted independently of the canvas-bound + // chain runner. + const all: EffectDescriptor[] = [ + {...makeDesc('a', '2d'), params: {disabled: false}}, + {...makeDesc('b', '2d'), params: {disabled: true}}, + {...makeDesc('c', 'webgl2'), params: {}}, + {...makeDesc('d', 'webgl2'), params: {disabled: true}}, + ]; + const enabled = all.filter( + (e) => !(e.params as {disabled?: boolean}).disabled, + ); + const runs = groupByBackend(memoizeEffects(enabled)); + expect(runs).toHaveLength(2); + expect(runs[0].effects.map((e) => e.definition.type)).toEqual(['a']); + expect(runs[1].effects.map((e) => e.definition.type)).toEqual(['c']); +}); diff --git a/packages/effects/src/halftone.ts b/packages/effects/src/halftone.ts index 8348da18bf7..27a4d65ca3d 100644 --- a/packages/effects/src/halftone.ts +++ b/packages/effects/src/halftone.ts @@ -42,6 +42,16 @@ export const halftoneSchema = { default: 0, description: 'Offset Y', }, + shape: { + type: 'enum', + variants: { + circle: {}, + square: {}, + line: {}, + }, + default: 'circle' as const, + description: 'Shape', + }, } as const satisfies SequenceSchema; export type HalftoneShape = 'circle' | 'square' | 'line'; diff --git a/packages/studio-shared/src/schema-field-info.ts b/packages/studio-shared/src/schema-field-info.ts index 0c8327e7bde..23f0da41933 100644 --- a/packages/studio-shared/src/schema-field-info.ts +++ b/packages/studio-shared/src/schema-field-info.ts @@ -120,6 +120,12 @@ export const getEffectFieldsToShow = ( return null; } + // `disabled` is represented as the eye icon on the effect timeline row, + // so we don't render it as a regular field in the expanded section. + if (key === 'disabled') { + return null; + } + if (SUPPORTED_SCHEMA_TYPES.indexOf(typeName) === -1) { throw new Error(`Unsupported field type: ${typeName}`); } diff --git a/packages/studio/src/components/Timeline/TimelineColorField.tsx b/packages/studio/src/components/Timeline/TimelineColorField.tsx index adc23ed8008..4c3e45e3be7 100644 --- a/packages/studio/src/components/Timeline/TimelineColorField.tsx +++ b/packages/studio/src/components/Timeline/TimelineColorField.tsx @@ -265,7 +265,10 @@ export const TimelineColorField: React.FC<{ title="Pick color from screen" aria-label="Pick color from screen" > - + ) : null} diff --git a/packages/studio/src/components/Timeline/TimelineEffectGroupRow.tsx b/packages/studio/src/components/Timeline/TimelineEffectGroupRow.tsx new file mode 100644 index 00000000000..eda28bbcee4 --- /dev/null +++ b/packages/studio/src/components/Timeline/TimelineEffectGroupRow.tsx @@ -0,0 +1,131 @@ +import React, {useCallback, useContext, useMemo} from 'react'; +import type {SequencePropsSubscriptionKey, SequenceSchema} from 'remotion'; +import {Internals} from 'remotion'; +import type {CodePosition} from '../../error-overlay/react-overlay/utils/get-source-map'; +import type {SequenceNodePathInfo} from '../../helpers/get-timeline-sequence-sort-key'; +import type {GetIsExpanded} from '../ExpandedTracksProvider'; +import {Padder} from './Padder'; +import {saveEffectProp} from './save-effect-prop'; +import {TimelineExpandArrowButton} from './TimelineExpandArrowButton'; +import {TimelineLayerEye, TimelineLayerEyeSpacer} from './TimelineLayerEye'; + +const groupRowBase: React.CSSProperties = { + display: 'flex', + alignItems: 'center', +}; + +const rowLabel: React.CSSProperties = { + fontSize: 12, + color: 'rgba(255, 255, 255, 0.8)', + userSelect: 'none', +}; + +export const TimelineEffectGroupRow: React.FC<{ + readonly label: string; + readonly nodePathInfo: SequenceNodePathInfo; + readonly effectIndex: number; + readonly effectSchema: SequenceSchema; + readonly nodePath: SequencePropsSubscriptionKey; + readonly validatedLocation: CodePosition; + readonly nestedDepth: number; + readonly style: React.CSSProperties; + readonly getIsExpanded: GetIsExpanded; + readonly toggleTrack: (nodePathInfo: SequenceNodePathInfo) => void; +}> = ({ + label, + nodePathInfo, + effectIndex, + effectSchema, + nodePath, + validatedLocation, + nestedDepth, + style, + getIsExpanded, + toggleTrack, +}) => { + const {codeValues} = useContext(Internals.VisualModeCodeValuesContext); + const {setCodeValues} = useContext(Internals.VisualModeSettersContext); + + const effectStatus = useMemo( + () => + Internals.getEffectCodeValuesCtx({ + codeValues, + nodePath, + effectIndex, + }), + [codeValues, nodePath, effectIndex], + ); + + const disabledStatus = + effectStatus.type === 'can-update-effect' + ? (effectStatus.props?.disabled ?? null) + : null; + + const isDisabled = useMemo(() => { + if (disabledStatus && disabledStatus.canUpdate) { + return Boolean(disabledStatus.codeValue); + } + + return false; + }, [disabledStatus]); + + const canToggle = disabledStatus !== null && disabledStatus.canUpdate; + + const onToggle = useCallback( + (type: 'enable' | 'disable') => { + if (!canToggle) { + return; + } + + const newValue = type !== 'enable'; + const fieldSchema = effectSchema.disabled; + const defaultValue = + fieldSchema && fieldSchema.type === 'boolean' + ? JSON.stringify(fieldSchema.default) + : null; + + saveEffectProp({ + fileName: validatedLocation.source, + nodePath, + effectIndex, + fieldKey: 'disabled', + value: newValue, + defaultValue, + schema: effectSchema, + setCodeValues, + }); + }, + [ + canToggle, + effectIndex, + effectSchema, + nodePath, + setCodeValues, + validatedLocation.source, + ], + ); + + const isExpanded = getIsExpanded(nodePathInfo); + const mergedStyle = useMemo( + (): React.CSSProperties => ({...groupRowBase, ...style}), + [style], + ); + + return ( +

+ + {canToggle ? ( +
+ ); +}; diff --git a/packages/studio/src/components/Timeline/TimelineExpandedRow.tsx b/packages/studio/src/components/Timeline/TimelineExpandedRow.tsx index cb3138ef8f9..61a8cd507d8 100644 --- a/packages/studio/src/components/Timeline/TimelineExpandedRow.tsx +++ b/packages/studio/src/components/Timeline/TimelineExpandedRow.tsx @@ -12,6 +12,7 @@ import { import type {GetIsExpanded} from '../ExpandedTracksProvider'; import {Padder} from './Padder'; import {TimelineEffectFieldRow} from './TimelineEffectFieldRow'; +import {TimelineEffectGroupRow} from './TimelineEffectGroupRow'; import {TimelineExpandArrowButton} from './TimelineExpandArrowButton'; import {TimelineFieldRow} from './TimelineFieldRow'; import {INDENT} from './TimelineListItem'; @@ -71,6 +72,23 @@ export const TimelineExpandedRow: React.FC<{ ); if (node.kind === 'group') { + if (node.effectInfo) { + return ( + + ); + } + const isExpanded = getIsExpanded(node.nodePathInfo); return (
diff --git a/packages/studio/src/components/Timeline/save-effect-prop.ts b/packages/studio/src/components/Timeline/save-effect-prop.ts new file mode 100644 index 00000000000..06ed865ae2d --- /dev/null +++ b/packages/studio/src/components/Timeline/save-effect-prop.ts @@ -0,0 +1,63 @@ +import {optimisticUpdateForEffectCodeValues} from '@remotion/studio-shared'; +import type {SequencePropsSubscriptionKey, SequenceSchema} from 'remotion'; +import {callApi} from '../call-api'; +import {enqueueSavePropChange} from './save-prop-queue'; +import type {SetCodeValues} from './save-sequence-prop'; + +export const saveEffectProp = ({ + fileName, + nodePath, + effectIndex, + fieldKey, + value, + defaultValue, + schema, + setCodeValues, +}: { + fileName: string; + nodePath: SequencePropsSubscriptionKey; + effectIndex: number; + fieldKey: string; + value: unknown; + defaultValue: string | null; + schema: SequenceSchema; + setCodeValues: SetCodeValues; +}): Promise => { + return enqueueSavePropChange({ + nodePath, + setCodeValues, + applyOptimistic: (prev) => + optimisticUpdateForEffectCodeValues({ + previous: prev, + effectIndex, + fieldKey, + value, + schema, + }), + apiCall: () => + callApi('/api/save-effect-props', { + fileName, + sequenceNodePath: nodePath, + effectIndex, + key: fieldKey, + value: JSON.stringify(value), + defaultValue, + schema, + }), + mergeServerResponse: (prev, data) => { + if (!prev.canUpdate) { + return prev; + } + + const idx = prev.effects.findIndex((e) => e.effectIndex === effectIndex); + if (idx === -1) { + return {...prev, effects: [...prev.effects, data]}; + } + + const nextEffects = [...prev.effects]; + nextEffects[idx] = data; + return {...prev, effects: nextEffects}; + }, + errorLabel: 'Could not save effect prop', + }); +}; diff --git a/packages/studio/src/components/Timeline/save-sequence-prop.ts b/packages/studio/src/components/Timeline/save-sequence-prop.ts index f16019480c5..6902818c26b 100644 --- a/packages/studio/src/components/Timeline/save-sequence-prop.ts +++ b/packages/studio/src/components/Timeline/save-sequence-prop.ts @@ -7,7 +7,7 @@ import type { import {callApi} from '../call-api'; import {enqueueSavePropChange} from './save-prop-queue'; -type SetCodeValues = ( +export type SetCodeValues = ( nodePath: SequencePropsSubscriptionKey, values: ( prev: CanUpdateSequencePropsResponse, diff --git a/packages/studio/src/helpers/timeline-layout.ts b/packages/studio/src/helpers/timeline-layout.ts index 7f9e6ad68f9..5313e212918 100644 --- a/packages/studio/src/helpers/timeline-layout.ts +++ b/packages/studio/src/helpers/timeline-layout.ts @@ -9,7 +9,11 @@ import { type SequenceControls, type SequenceSchemaFieldInfo, } from '@remotion/studio-shared'; -import type {GetDragOverrides, TSequence} from 'remotion'; +import type { + GetDragOverrides, + SequenceSchema as SequenceSchemaShape, + TSequence, +} from 'remotion'; import type {GetIsExpanded} from '../components/ExpandedTracksProvider'; import type {SequenceNodePathInfo} from './get-timeline-sequence-sort-key'; @@ -41,12 +45,21 @@ export const EXPANDED_SECTION_PADDING_RIGHT = 10; export type TimelineFieldOnSave = (value: unknown) => Promise; export type TimelineFieldOnDragValueChange = (value: unknown) => void; +export type TimelineEffectGroupInfo = { + readonly effectIndex: number; + readonly effectSchema: SequenceSchemaShape; +}; + export type TimelineTreeNode = | { readonly kind: 'group'; readonly nodePathInfo: SequenceNodePathInfo; readonly label: string; readonly children: TimelineTreeNode[]; + // Present when this group represents a single effect (not the outer + // "Effects" container). Lets the row component render the eye toggle and + // wire `disabled` saves without re-deriving the effect index. + readonly effectInfo: TimelineEffectGroupInfo | null; } | { readonly kind: 'field'; @@ -79,6 +92,7 @@ export const buildTimelineTree = ({ numberOfSequencesWithThisNodePath: 0, }, label: 'Effects', + effectInfo: null, children: sequence.effects.map((effect, i): TimelineTreeNode => { const effectFields = getEffectFieldsToShow(effect, i); return { @@ -90,6 +104,9 @@ export const buildTimelineTree = ({ numberOfSequencesWithThisNodePath: 0, }, label: effect.label, + effectInfo: effect.schema + ? {effectIndex: i, effectSchema: effect.schema} + : null, children: effectFields.map( (f): TimelineTreeNode => ({ kind: 'field',