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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 37 additions & 8 deletions packages/core/src/effects/create-effect.ts
Original file line number Diff line number Diff line change
@@ -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<P> = {} extends P
? (params?: P) => EffectDescriptor<unknown>
: (params: P) => EffectDescriptor<unknown>;
// 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<BlurParams, BlurState>({ ... });
// // 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
Expand All @@ -17,12 +28,30 @@ type EffectFactory<P> = {} extends P
export const createEffect = <P, S>(
definition: EffectDefinition<P, S>,
): EffectFactory<P> => {
const widened = definition as unknown as EffectDefinition<unknown, unknown>;
const factory = (params: P = {} as P): EffectDescriptor<unknown> => ({
// 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<unknown, unknown> = {
...(definition as unknown as EffectDefinition<unknown, unknown>),
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<unknown> => ({
definition: widened,
params,
effectKey: widened.calculateKey(params),
memoized: false,
});
return factory as EffectFactory<P>;
return factory;
};
14 changes: 14 additions & 0 deletions packages/core/src/effects/effect-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,17 @@ export type EffectDefinitionAndStack<P = unknown> = BaseEffectDescriptor<P> & {
};

export type EffectsProp = ReadonlyArray<EffectDescriptor<unknown>>;

// `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<P> = {} extends P
? (params?: P & {readonly disabled?: boolean}) => EffectDescriptor<unknown>
: (params: P & {readonly disabled?: boolean}) => EffectDescriptor<unknown>;
1 change: 1 addition & 0 deletions packages/core/src/effects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export type {
EffectDescriptor,
EffectsProp,
EffectDefinition,
EffectFactory,
} from './effect-types.js';
9 changes: 8 additions & 1 deletion packages/core/src/effects/run-effect-chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export type {
EffectDescriptor,
EffectsProp,
EffectDefinition,
EffectFactory,
} from './effects/index.js';
export type {SolidProps} from './effects/Solid.js';
export {
Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/test/create-effect-disabled.test.ts
Original file line number Diff line number Diff line change
@@ -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<FooParams, null>({
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<FooParams, null>({
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',
});
});
19 changes: 19 additions & 0 deletions packages/core/src/test/effect-internals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>[] = [
{...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']);
});
10 changes: 10 additions & 0 deletions packages/effects/src/halftone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
6 changes: 6 additions & 0 deletions packages/studio-shared/src/schema-field-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,10 @@ export const TimelineColorField: React.FC<{
title="Pick color from screen"
aria-label="Pick color from screen"
>
<EyedropperIcon style={eyedropperIconStyle} />
<EyedropperIcon
style={eyedropperIconStyle}
color="rgba(255, 255, 255, 0.7)"
/>
</button>
) : null}
</span>
Expand Down
131 changes: 131 additions & 0 deletions packages/studio/src/components/Timeline/TimelineEffectGroupRow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={mergedStyle}>
<Padder depth={nestedDepth + 1} />
{canToggle ? (
<TimelineLayerEye type="eye" hidden={isDisabled} onInvoked={onToggle} />
) : (
<TimelineLayerEyeSpacer />
)}
<TimelineExpandArrowButton
isExpanded={isExpanded}
onClick={() => toggleTrack(nodePathInfo)}
label={`${label} section`}
disabled={false}
/>
<span style={rowLabel}>{label}</span>
</div>
);
};
Loading
Loading