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 ? (
+
+ ) : (
+
+ )}
+
toggleTrack(nodePathInfo)}
+ label={`${label} section`}
+ disabled={false}
+ />
+ {label}
+
+ );
+};
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',