diff --git a/bun.lock b/bun.lock index 4a8563dd92d..0e999f66cb5 100644 --- a/bun.lock +++ b/bun.lock @@ -466,6 +466,7 @@ "@remotion/skia": "workspace:*", "@remotion/starburst": "workspace:*", "@remotion/studio": "workspace:*", + "@remotion/studio-shared": "workspace:*", "@remotion/svg-3d-engine": "workspace:*", "@remotion/tailwind": "workspace:*", "@remotion/tailwind-v4": "workspace:*", diff --git a/packages/docs/components/effects-demos/index.tsx b/packages/docs/components/effects-demos/index.tsx index 34060e3ee72..d6a588cb890 100644 --- a/packages/docs/components/effects-demos/index.tsx +++ b/packages/docs/components/effects-demos/index.tsx @@ -1,5 +1,9 @@ import {useColorMode} from '@docusaurus/theme-common'; import {Player} from '@remotion/player'; +import { + EFFECT_DRAG_MIME_TYPE, + type EffectDragData, +} from '@remotion/studio-shared'; import React, {useCallback, useMemo, useState} from 'react'; import {AbsoluteFill} from 'remotion'; import { @@ -19,6 +23,18 @@ const container: React.CSSProperties = { marginBottom: 40, }; +const dragHandle: React.CSSProperties = { + alignItems: 'center', + borderBottom: '1px solid var(--ifm-color-emphasis-300)', + cursor: 'grab', + display: 'flex', + fontSize: 13, + fontWeight: 600, + gap: 6, + padding: '8px 10px', + userSelect: 'none', +}; + export const EffectsDemo: React.FC<{ readonly type: string; }> = ({type}) => { @@ -37,7 +53,9 @@ export const EffectsDemo: React.FC<{ }); }, [demo.initialValues, demo.schema]); - const [state, setState] = useState>(() => initialState); + const [state, setState] = useState>( + () => initialState, + ); const activeFields = useMemo(() => { return getActiveSchemaFields({ @@ -51,6 +69,29 @@ export const EffectsDemo: React.FC<{ setKey((k) => k + 1); }, [initialState]); + const dragData = useMemo((): EffectDragData => { + return { + type: 'remotion-effect', + version: 1, + effect: { + name: demo.effectName, + importPath: demo.effectImportPath, + config: state, + }, + }; + }, [demo.effectImportPath, demo.effectName, state]); + + const onDragStart = useCallback( + (e: React.DragEvent) => { + const serialized = JSON.stringify(dragData); + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData(EFFECT_DRAG_MIME_TYPE, serialized); + e.dataTransfer.setData('application/json', serialized); + e.dataTransfer.setData('text/plain', serialized); + }, + [dragData], + ); + return (
+
+ + Drag current effect into a layer in the Studio +
{activeFields.map(([fieldKey, field]) => { return ( diff --git a/packages/docs/components/effects-demos/registry.ts b/packages/docs/components/effects-demos/registry.ts index 5b743f5ea8b..5940eac9c38 100644 --- a/packages/docs/components/effects-demos/registry.ts +++ b/packages/docs/components/effects-demos/registry.ts @@ -79,66 +79,88 @@ export const effectsDemos: EffectsDemoType[] = [ { ...defaults, id: 'effects-brightness', + effectName: 'brightness', + effectImportPath: '@remotion/effects/brightness', comp: EffectsBrightnessPreview, schema: brightness().definition.schema, }, { ...defaults, id: 'effects-contrast', + effectName: 'contrast', + effectImportPath: '@remotion/effects/contrast', comp: EffectsContrastPreview, schema: contrast().definition.schema, }, { ...defaults, id: 'effects-duotone', + effectName: 'duotone', + effectImportPath: '@remotion/effects/duotone', comp: EffectsDuotonePreview, schema: duotone().definition.schema, }, { ...defaults, id: 'effects-evolve', + effectName: 'evolve', + effectImportPath: '@remotion/effects/evolve', comp: EffectsEvolvePreview, schema: evolve().definition.schema, }, { ...defaults, id: 'effects-drop-shadow', + effectName: 'dropShadow', + effectImportPath: '@remotion/effects/drop-shadow', comp: EffectsDropShadowPreview, schema: dropShadow().definition.schema, }, { ...defaults, id: 'effects-glow', + effectName: 'glow', + effectImportPath: '@remotion/effects/glow', comp: EffectsGlowPreview, schema: glow().definition.schema, }, { ...defaults, id: 'effects-grayscale', + effectName: 'grayscale', + effectImportPath: '@remotion/effects/grayscale', comp: EffectsGrayscalePreview, schema: grayscale().definition.schema, }, { ...defaults, id: 'effects-hue', + effectName: 'hue', + effectImportPath: '@remotion/effects/hue', comp: EffectsHuePreview, schema: hue().definition.schema, }, { ...defaults, id: 'effects-invert', + effectName: 'invert', + effectImportPath: '@remotion/effects/invert', comp: EffectsInvertPreview, schema: invert().definition.schema, }, { ...defaults, id: 'effects-saturation', + effectName: 'saturation', + effectImportPath: '@remotion/effects/saturation', comp: EffectsSaturationPreview, schema: saturation().definition.schema, }, { ...defaults, id: 'effects-tint', + effectName: 'tint', + effectImportPath: '@remotion/effects/tint', comp: EffectsTintPreview, schema: tint({color: '#1ec8ff'}).definition.schema, initialValues: { @@ -148,84 +170,112 @@ export const effectsDemos: EffectsDemoType[] = [ { ...defaults, id: 'effects-shine', + effectName: 'shine', + effectImportPath: '@remotion/effects/shine', comp: EffectsShinePreview, schema: shine().definition.schema, }, { ...defaults, id: 'effects-speckle', + effectName: 'speckle', + effectImportPath: '@remotion/effects/speckle', comp: EffectsSpecklePreview, schema: speckle().definition.schema, }, { ...defaults, id: 'effects-mirror', + effectName: 'mirror', + effectImportPath: '@remotion/effects/mirror', comp: EffectsMirrorPreview, schema: mirror().definition.schema, }, { ...defaults, id: 'effects-noise', + effectName: 'noise', + effectImportPath: '@remotion/effects/noise', comp: EffectsNoisePreview, schema: noise().definition.schema, }, { ...defaults, id: 'effects-white-noise', + effectName: 'whiteNoise', + effectImportPath: '@remotion/effects/white-noise', comp: EffectsWhiteNoisePreview, schema: whiteNoise().definition.schema, }, { ...defaults, id: 'effects-scanlines', + effectName: 'scanlines', + effectImportPath: '@remotion/effects/scanlines', comp: EffectsScanlinesPreview, schema: scanlines().definition.schema, }, { ...defaults, id: 'effects-lines', + effectName: 'lines', + effectImportPath: '@remotion/effects/lines', comp: EffectsLinesPreview, schema: lines().definition.schema, }, { ...defaults, id: 'effects-scale', + effectName: 'scale', + effectImportPath: '@remotion/effects/scale', comp: EffectsScalePreview, schema: scale({scale: 1}).definition.schema, }, { ...defaults, id: 'effects-xy-translate', + effectName: 'xyTranslate', + effectImportPath: '@remotion/effects/translate', comp: EffectsXyTranslatePreview, schema: xyTranslate().definition.schema, }, { ...defaults, id: 'effects-uv-translate', + effectName: 'uvTranslate', + effectImportPath: '@remotion/effects/translate', comp: EffectsUvTranslatePreview, schema: uvTranslate().definition.schema, }, { ...defaults, id: 'effects-barrel-distortion', + effectName: 'barrelDistortion', + effectImportPath: '@remotion/effects/barrel-distortion', comp: EffectsBarrelDistortionPreview, schema: barrelDistortion().definition.schema, }, { ...defaults, id: 'effects-fisheye', + effectName: 'fisheye', + effectImportPath: '@remotion/effects/fisheye', comp: EffectsFisheyePreview, schema: fisheye().definition.schema, }, { ...defaults, id: 'effects-vignette', + effectName: 'vignette', + effectImportPath: '@remotion/effects/vignette', comp: EffectsVignettePreview, schema: vignette().definition.schema, }, { ...defaults, id: 'effects-blur', + effectName: 'blur', + effectImportPath: '@remotion/effects/blur', comp: EffectsBlurPreview, schema: blur({radius: 40}).definition.schema, initialValues: { @@ -235,36 +285,48 @@ export const effectsDemos: EffectsDemoType[] = [ { ...defaults, id: 'effects-chromatic-aberration', + effectName: 'chromaticAberration', + effectImportPath: '@remotion/effects/chromatic-aberration', comp: EffectsChromaticAberrationPreview, schema: chromaticAberration().definition.schema, }, { ...defaults, id: 'effects-wave', + effectName: 'wave', + effectImportPath: '@remotion/effects/wave', comp: EffectsWavePreview, schema: wave().definition.schema, }, { ...defaults, id: 'effects-halftone', + effectName: 'halftone', + effectImportPath: '@remotion/effects/halftone', comp: EffectsHalftonePreview, schema: halftone().definition.schema, }, { ...defaults, id: 'effects-halftone-linear-gradient', + effectName: 'halftoneLinearGradient', + effectImportPath: '@remotion/effects/halftone-linear-gradient', comp: EffectsHalftoneLinearGradientPreview, schema: halftoneLinearGradient().definition.schema, }, { ...defaults, id: 'effects-dot-grid', + effectName: 'dotGrid', + effectImportPath: '@remotion/effects/dot-grid', comp: EffectsDotGridPreview, schema: dotGrid().definition.schema, }, { ...defaults, id: 'effects-starburst', + effectName: 'starburst', + effectImportPath: '@remotion/starburst', comp: EffectsStarburstPreview, schema: starburstEffectSchema, initialValues: { @@ -274,6 +336,8 @@ export const effectsDemos: EffectsDemoType[] = [ { ...defaults, id: 'effects-light-leak', + effectName: 'lightLeak', + effectImportPath: '@remotion/light-leaks', comp: EffectsLightLeakPreview, schema: lightLeakEffectSchema, }, diff --git a/packages/docs/components/effects-demos/types.ts b/packages/docs/components/effects-demos/types.ts index ee17fc8d277..56fdbf86afb 100644 --- a/packages/docs/components/effects-demos/types.ts +++ b/packages/docs/components/effects-demos/types.ts @@ -3,6 +3,8 @@ import type {LogLevel, SequenceSchema} from 'remotion'; export type EffectsDemoType = { id: string; + effectName: string; + effectImportPath: string; comp: React.FC; schema: SequenceSchema; initialValues?: Record; diff --git a/packages/docs/package.json b/packages/docs/package.json index ea27938c15f..936aa1926cd 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -76,6 +76,7 @@ "@remotion/skia": "workspace:*", "@remotion/starburst": "workspace:*", "@remotion/studio": "workspace:*", + "@remotion/studio-shared": "workspace:*", "@remotion/svg-3d-engine": "workspace:*", "@remotion/tailwind": "workspace:*", "@remotion/tailwind-v4": "workspace:*", diff --git a/packages/studio-server/src/codemods/add-effect.ts b/packages/studio-server/src/codemods/add-effect.ts new file mode 100644 index 00000000000..29c154766c3 --- /dev/null +++ b/packages/studio-server/src/codemods/add-effect.ts @@ -0,0 +1,322 @@ +import type { + ArrayExpression, + ClassDeclaration, + Expression, + File, + FunctionDeclaration, + ImportDeclaration, + ImportSpecifier, + JSXAttribute, + JSXOpeningElement, + ObjectExpression, + ObjectProperty, + VariableDeclaration, +} from '@babel/types'; +import {stringifyDefaultProps} from '@remotion/studio-shared'; +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'; +import {findEffectsAttr} from './update-effect-props/update-effect-props'; + +const b = recast.types.builders; + +const identifierRegex = /^[A-Za-z_$][0-9A-Za-z_$]*$/; + +const assertValidEffect = ({ + effectName, + effectImportPath, +}: { + effectName: string; + effectImportPath: string; +}) => { + if (!identifierRegex.test(effectName)) { + throw new Error(`Invalid effect name "${effectName}"`); + } + + const allowedImport = + effectImportPath.startsWith('@remotion/effects/') || + effectImportPath === '@remotion/light-leaks' || + effectImportPath === '@remotion/starburst'; + + if (!allowedImport) { + throw new Error(`Unsupported effect import "${effectImportPath}"`); + } +}; + +const parseValueExpression = (value: unknown): Expression => { + 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 config value'); + } + + return stmt.expression.right as Expression; +}; + +const getImportedName = (specifier: ImportSpecifier) => { + if (specifier.imported.type === 'Identifier') { + return specifier.imported.name; + } + + return specifier.imported.value; +}; + +const declarationBindsName = ( + declaration: ClassDeclaration | FunctionDeclaration | VariableDeclaration, + name: string, +) => { + if (declaration.type === 'ClassDeclaration') { + return declaration.id?.name === name; + } + + if (declaration.type === 'FunctionDeclaration') { + return declaration.id?.name === name; + } + + return declaration.declarations.some((variableDeclaration) => { + return ( + variableDeclaration.id.type === 'Identifier' && + variableDeclaration.id.name === name + ); + }); +}; + +const hasTopLevelBinding = ({ast, name}: {ast: File; name: string}) => { + return ast.program.body.some((node) => { + if ( + node.type === 'FunctionDeclaration' || + node.type === 'ClassDeclaration' || + node.type === 'VariableDeclaration' + ) { + return declarationBindsName(node, name); + } + + if ( + node.type === 'ExportNamedDeclaration' && + node.declaration && + (node.declaration.type === 'FunctionDeclaration' || + node.declaration.type === 'ClassDeclaration' || + node.declaration.type === 'VariableDeclaration') + ) { + return declarationBindsName(node.declaration, name); + } + + if (node.type !== 'ImportDeclaration') { + return false; + } + + return node.specifiers?.some((specifier) => specifier.local?.name === name); + }); +}; + +const insertImportDeclaration = ( + ast: File, + importDeclaration: ImportDeclaration, +) => { + const {body} = ast.program; + let lastImportIndex = -1; + for (let i = 0; i < body.length; i++) { + if (body[i].type === 'ImportDeclaration') { + lastImportIndex = i; + } + } + + body.splice(lastImportIndex + 1, 0, importDeclaration); +}; + +const getAvailableLocalName = ({ + ast, + effectName, +}: { + ast: File; + effectName: string; +}) => { + if (!hasTopLevelBinding({ast, name: effectName})) { + return effectName; + } + + const base = `${effectName}Effect`; + if (!hasTopLevelBinding({ast, name: base})) { + return base; + } + + for (let i = 2; i < 100; i++) { + const candidate = `${base}${i}`; + if (!hasTopLevelBinding({ast, name: candidate})) { + return candidate; + } + } + + throw new Error(`Cannot find a local name for ${effectName}`); +}; + +const ensureEffectImport = ({ + ast, + effectName, + effectImportPath, +}: { + ast: File; + effectName: string; + effectImportPath: string; +}) => { + let importDeclaration: ImportDeclaration | null = null; + + for (const node of ast.program.body) { + if ( + node.type !== 'ImportDeclaration' || + node.source.value !== effectImportPath + ) { + continue; + } + + importDeclaration = node; + const matchingSpecifier = node.specifiers?.find((importSpecifier) => { + return ( + importSpecifier.type === 'ImportSpecifier' && + getImportedName(importSpecifier) === effectName + ); + }); + + if (matchingSpecifier?.local?.name) { + return matchingSpecifier.local.name; + } + } + + const localName = getAvailableLocalName({ast, effectName}); + const imported = b.identifier(effectName); + const local = localName === effectName ? null : b.identifier(localName); + const specifier = b.importSpecifier( + imported, + local, + ) as unknown as ImportSpecifier; + + if ( + importDeclaration && + !importDeclaration.specifiers?.some( + (importSpecifier) => importSpecifier.type === 'ImportNamespaceSpecifier', + ) + ) { + importDeclaration.specifiers = [ + ...(importDeclaration.specifiers ?? []), + specifier, + ]; + return localName; + } + + const newImport = b.importDeclaration( + [], + b.stringLiteral(effectImportPath), + ) as unknown as ImportDeclaration; + newImport.specifiers = [specifier]; + insertImportDeclaration(ast, newImport); + return localName; +}; + +const getEffectsArray = (attr: JSXAttribute): ArrayExpression => { + if (!attr.value || attr.value.type !== 'JSXExpressionContainer') { + throw new Error('Cannot add effect: effects prop is not an array'); + } + + const expr = attr.value.expression as Expression; + if (expr.type !== 'ArrayExpression') { + throw new Error('Cannot add effect: effects prop is not an array'); + } + + return expr; +}; + +const makeEffectsAttr = (array: ArrayExpression): JSXAttribute => { + return b.jsxAttribute( + b.jsxIdentifier('effects'), + b.jsxExpressionContainer(array as never), + ) as unknown as JSXAttribute; +}; + +const makeConfigObjectExpression = ( + config: Record, +): ObjectExpression => { + return b.objectExpression( + Object.entries(config).map(([key, value]) => { + const keyNode = identifierRegex.test(key) + ? b.identifier(key) + : b.stringLiteral(key); + return b.objectProperty( + keyNode as never, + parseValueExpression(value) as never, + ) as unknown as ObjectProperty; + }) as never, + ) as unknown as ObjectExpression; +}; + +const getJsxTagLabel = (name: JSXOpeningElement['name']) => { + if (name.type === 'JSXIdentifier') { + return `<${name.name}>`; + } + + return 'element'; +}; + +export const addEffect = async ({ + input, + sequenceNodePath, + effectName, + effectImportPath, + effectConfig, + prettierConfigOverride, +}: { + input: string; + sequenceNodePath: SequenceNodePath; + effectName: string; + effectImportPath: string; + effectConfig: Record; + prettierConfigOverride?: Record | null; +}): Promise<{ + output: string; + formatted: boolean; + effectLabel: string; + nodeLabel: string; + logLine: number; +}> => { + assertValidEffect({effectName, effectImportPath}); + + 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 add effect', + ); + } + + const localName = ensureEffectImport({ast, effectName, effectImportPath}); + const effectCall = b.callExpression(b.identifier(localName), [ + makeConfigObjectExpression(effectConfig) as never, + ]); + + const attr = findEffectsAttr(jsx.attributes ?? []); + if (attr) { + getEffectsArray(attr).elements.push(effectCall as never); + } else { + const effectsArray = b.arrayExpression([effectCall]) as ArrayExpression; + jsx.attributes.push(makeEffectsAttr(effectsArray)); + } + + const finalFile = serializeAst(ast); + const {output, formatted} = await formatFileContent({ + input: finalFile, + prettierConfigOverride, + }); + + return { + output, + formatted, + effectLabel: `${effectName}()`, + nodeLabel: getJsxTagLabel(jsx.name), + logLine: jsx.loc?.start.line ?? 1, + }; +}; diff --git a/packages/studio-server/src/preview-server/api-routes.ts b/packages/studio-server/src/preview-server/api-routes.ts index 9bc46bbfd86..8aa0380beee 100644 --- a/packages/studio-server/src/preview-server/api-routes.ts +++ b/packages/studio-server/src/preview-server/api-routes.ts @@ -1,5 +1,6 @@ import type {ApiRoutes} from '@remotion/studio-shared'; import type {ApiHandler} from './api-types'; +import {addEffectHandler} from './routes/add-effect'; import {addEffectKeyframeHandler} from './routes/add-effect-keyframe'; import {handleAddRender} from './routes/add-render'; import {addSequenceKeyframeHandler} from './routes/add-sequence-keyframe'; @@ -60,6 +61,7 @@ export const allApiRoutes: { '/api/unsubscribe-from-sequence-props': unsubscribeFromSequenceProps, '/api/save-sequence-props': saveSequencePropsHandler, '/api/save-effect-props': saveEffectPropsHandler, + '/api/add-effect': addEffectHandler, '/api/delete-sequence-keyframe': deleteSequenceKeyframeHandler, '/api/add-sequence-keyframe': addSequenceKeyframeHandler, '/api/delete-effect-keyframe': deleteEffectKeyframeHandler, diff --git a/packages/studio-server/src/preview-server/routes/add-effect.ts b/packages/studio-server/src/preview-server/routes/add-effect.ts new file mode 100644 index 00000000000..3ab33527951 --- /dev/null +++ b/packages/studio-server/src/preview-server/routes/add-effect.ts @@ -0,0 +1,104 @@ +import {readFileSync} from 'node:fs'; +import {RenderInternals} from '@remotion/renderer'; +import type { + AddEffectRequest, + AddEffectResponse, +} from '@remotion/studio-shared'; +import {addEffect} from '../../codemods/add-effect'; +import {writeFileAndNotifyFileWatchers} from '../../file-watcher'; +import {resolveFileInsideProject} from '../../helpers/resolve-file-inside-project'; +import type {ApiHandler} from '../api-types'; +import {formatLogFileLocation} from '../format-log-file-location'; +import { + printUndoHint, + pushToUndoStack, + suppressUndoStackInvalidation, +} from '../undo-stack'; +import {attrName} from './log-updates/formatting'; +import {warnAboutPrettierOnce} from './log-updates/log-update'; + +export const addEffectHandler: ApiHandler< + AddEffectRequest, + AddEffectResponse +> = async ({ + input: { + fileName, + sequenceNodePath, + effectName, + effectImportPath, + effectConfig, + clientId, + }, + remotionRoot, + logLevel, +}) => { + try { + RenderInternals.Log.trace( + {indent: false, logLevel}, + `[add-effect] Received request for fileName="${fileName}" effect="${effectName}"`, + ); + + const {absolutePath, fileRelativeToRoot} = resolveFileInsideProject({ + remotionRoot, + fileName, + action: 'modify', + }); + + const fileContents = readFileSync(absolutePath, 'utf-8'); + const {output, formatted, effectLabel, nodeLabel, logLine} = + await addEffect({ + input: fileContents, + sequenceNodePath: sequenceNodePath.nodePath, + effectName, + effectImportPath, + effectConfig, + }); + + pushToUndoStack({ + filePath: absolutePath, + oldContents: fileContents, + newContents: null, + logLevel, + remotionRoot, + logLine, + description: { + undoMessage: `↩️ Addition of ${effectLabel} to ${nodeLabel}`, + redoMessage: `↪️ Addition of ${effectLabel} to ${nodeLabel}`, + }, + entryType: 'add-effect', + suppressHmrOnFileRestore: false, + }); + suppressUndoStackInvalidation(absolutePath); + writeFileAndNotifyFileWatchers(absolutePath, output, clientId); + + const locationLabel = formatLogFileLocation({ + remotionRoot, + absolutePath, + line: logLine, + }); + RenderInternals.Log.info( + {indent: false, logLevel}, + `${RenderInternals.chalk.blueBright(`${locationLabel}`)} Added ${attrName(effectLabel)} to ${nodeLabel}`, + ); + if (!formatted) { + warnAboutPrettierOnce(logLevel); + } + + RenderInternals.Log.verbose( + {indent: false, logLevel}, + `[add-effect] Wrote ${fileRelativeToRoot}${formatted ? ' (formatted)' : ''}`, + ); + + printUndoHint(logLevel); + + return { + success: true, + }; + } catch (err) { + return { + success: false, + reason: (err as Error).message, + stack: (err as Error).stack as string, + }; + } +}; diff --git a/packages/studio-server/src/preview-server/undo-stack.ts b/packages/studio-server/src/preview-server/undo-stack.ts index 61ab5a46d32..f4fb3592ade 100644 --- a/packages/studio-server/src/preview-server/undo-stack.ts +++ b/packages/studio-server/src/preview-server/undo-stack.ts @@ -21,6 +21,7 @@ type UndoEntryType = | 'default-props' | 'sequence-props' | 'effect-props' + | 'add-effect' | 'delete-effect' | 'delete-jsx-node' | 'duplicate-jsx-node' @@ -50,6 +51,7 @@ type UndoEntry = { | {entryType: 'default-props'} | {entryType: 'sequence-props'} | {entryType: 'effect-props'} + | {entryType: 'add-effect'} | {entryType: 'delete-effect'} | {entryType: 'delete-jsx-node'} | {entryType: 'duplicate-jsx-node'} diff --git a/packages/studio-server/src/test/add-effect.test.ts b/packages/studio-server/src/test/add-effect.test.ts new file mode 100644 index 00000000000..72ae09a3c3c --- /dev/null +++ b/packages/studio-server/src/test/add-effect.test.ts @@ -0,0 +1,91 @@ +import {expect, test} from 'bun:test'; +import {addEffect} from '../codemods/add-effect'; +import {lineColumnToNodePath} from './test-utils'; + +const buildInput = (jsx: string) => `import {Solid} from 'remotion'; + +export const Comp = () => { +\treturn ${jsx}; +}; +`; + +test('addEffect adds an effects prop and import', async () => { + const input = buildInput(''); + const {output, effectLabel, nodeLabel} = await addEffect({ + input, + sequenceNodePath: lineColumnToNodePath(input, 4), + effectName: 'brightness', + effectImportPath: '@remotion/effects/brightness', + effectConfig: {amount: 1.2}, + }); + + expect(effectLabel).toBe('brightness()'); + expect(nodeLabel).toBe(''); + expect(output).toContain( + "import {brightness} from '@remotion/effects/brightness';", + ); + expect(output).toContain('effects={['); + expect(output).toContain('brightness({'); + expect(output).toContain('amount: 1.2'); +}); + +test('addEffect appends to an existing effects array', async () => { + const input = `import {Solid} from 'remotion'; +import {tint} from '@remotion/effects/tint'; + +export const Comp = () => { +\treturn ; +}; +`; + const {output} = await addEffect({ + input, + sequenceNodePath: lineColumnToNodePath(input, 5), + effectName: 'contrast', + effectImportPath: '@remotion/effects/contrast', + effectConfig: {amount: 0.8}, + }); + + expect(output).toContain( + "import {contrast} from '@remotion/effects/contrast';", + ); + expect(output).toContain('tint({'); + expect(output).toContain('contrast({'); + expect(output).toContain('amount: 0.8'); +}); + +test('addEffect aliases imports when the effect name is already bound', async () => { + const input = `import {Solid} from 'remotion'; + +const brightness = 1; + +export const Comp = () => { +\treturn ; +}; +`; + const {output} = await addEffect({ + input, + sequenceNodePath: lineColumnToNodePath(input, 6), + effectName: 'brightness', + effectImportPath: '@remotion/effects/brightness', + effectConfig: {amount: 0.5}, + }); + + expect(output).toContain( + "import {brightness as brightnessEffect} from '@remotion/effects/brightness';", + ); + expect(output).toContain('brightnessEffect({'); +}); + +test('addEffect rejects non-Remotion effect imports', async () => { + const input = buildInput(''); + + await expect( + addEffect({ + input, + sequenceNodePath: lineColumnToNodePath(input, 4), + effectName: 'brightness', + effectImportPath: 'third-party-effect', + effectConfig: {}, + }), + ).rejects.toThrow(/Unsupported effect import/); +}); diff --git a/packages/studio-shared/src/api-requests.ts b/packages/studio-shared/src/api-requests.ts index 3fe9cb7bb4c..afbd75c71ba 100644 --- a/packages/studio-shared/src/api-requests.ts +++ b/packages/studio-shared/src/api-requests.ts @@ -319,6 +319,25 @@ export type SaveEffectPropsRequest = { export type SaveEffectPropsResponse = CanUpdateEffectPropsResponse; +export type AddEffectRequest = { + fileName: string; + sequenceNodePath: SequencePropsSubscriptionKey; + effectName: string; + effectImportPath: string; + effectConfig: Record; + clientId: string; +}; + +export type AddEffectResponse = + | { + success: true; + } + | { + success: false; + reason: string; + stack: string; + }; + export type DeleteSequenceKeyframeRequest = { fileName: string; nodePath: SequencePropsSubscriptionKey; @@ -543,6 +562,7 @@ export type ApiRoutes = { SaveEffectPropsRequest, SaveEffectPropsResponse >; + '/api/add-effect': ReqAndRes; '/api/delete-sequence-keyframe': ReqAndRes< DeleteSequenceKeyframeRequest, DeleteSequenceKeyframeResponse diff --git a/packages/studio-shared/src/effect-drag-data.ts b/packages/studio-shared/src/effect-drag-data.ts new file mode 100644 index 00000000000..0874d01b05f --- /dev/null +++ b/packages/studio-shared/src/effect-drag-data.ts @@ -0,0 +1,53 @@ +export const EFFECT_DRAG_MIME_TYPE = 'application/vnd.remotion.effect+json'; + +export type EffectDragData = { + type: 'remotion-effect'; + version: 1; + effect: { + name: string; + importPath: string; + config: Record; + }; +}; + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}; + +export const parseEffectDragData = (value: string): EffectDragData | null => { + try { + const parsed: unknown = JSON.parse(value); + if (!isRecord(parsed)) { + return null; + } + + if (parsed.type !== 'remotion-effect' || parsed.version !== 1) { + return null; + } + + if (!isRecord(parsed.effect)) { + return null; + } + + const {name, importPath, config} = parsed.effect; + if ( + typeof name !== 'string' || + typeof importPath !== 'string' || + !isRecord(config) + ) { + return null; + } + + return { + type: 'remotion-effect', + version: 1, + effect: { + name, + importPath, + config, + }, + }; + } catch { + return null; + } +}; diff --git a/packages/studio-shared/src/index.ts b/packages/studio-shared/src/index.ts index 5f282fe5ce2..082ea0d6fb1 100644 --- a/packages/studio-shared/src/index.ts +++ b/packages/studio-shared/src/index.ts @@ -1,5 +1,7 @@ export {splitAnsi, stripAnsi} from './ansi'; export { + AddEffectRequest, + AddEffectResponse, AddEffectKeyframeRequest, AddEffectKeyframeResponse, AddRenderRequest, @@ -71,6 +73,11 @@ export { } from './api-requests'; export type {ApplyVisualControlCodemod, RecastCodemod} from './codemods'; export {DEFAULT_BUFFER_STATE_DELAY_IN_MILLISECONDS} from './default-buffer-state-delay-in-milliseconds'; +export { + EFFECT_DRAG_MIME_TYPE, + parseEffectDragData, + type EffectDragData, +} from './effect-drag-data'; export {EventSourceEvent} from './event-source-event'; export {formatBytes} from './format-bytes'; export {getAllSchemaKeys} from './get-all-keys'; diff --git a/packages/studio/src/components/Timeline/TimelineRowChrome.tsx b/packages/studio/src/components/Timeline/TimelineRowChrome.tsx index 3516468a4a1..a73c9915763 100644 --- a/packages/studio/src/components/Timeline/TimelineRowChrome.tsx +++ b/packages/studio/src/components/Timeline/TimelineRowChrome.tsx @@ -46,6 +46,9 @@ export const TimelineRowChrome: React.FC<{ // bottom track separator. The background highlight and click target span the // outer (used by sequence rows whose layer is taller than the chrome row). readonly outerHeight: number | null; + readonly onDragLeave?: (e: React.DragEvent) => void; + readonly onDragOver?: (e: React.DragEvent) => void; + readonly onDrop?: (e: React.DragEvent) => void; readonly onDoubleClick?: (e: React.MouseEvent) => void; }> = ({ depth, @@ -60,6 +63,9 @@ export const TimelineRowChrome: React.FC<{ showSelectedBackground, containsSelection, outerHeight, + onDragLeave, + onDragOver, + onDrop, onDoubleClick, }) => { const indentWidth = getTimelineRowIndentWidth(depth); @@ -146,6 +152,9 @@ export const TimelineRowChrome: React.FC<{ return (
{ + return Array.from(dataTransfer.types).some( + (type) => + type === EFFECT_DRAG_MIME_TYPE || + type === 'application/json' || + type === 'text/plain', + ); +}; + +const getEffectDragData = (dataTransfer: DataTransfer) => { + for (const type of [ + EFFECT_DRAG_MIME_TYPE, + 'application/json', + 'text/plain', + ]) { + const value = dataTransfer.getData(type); + if (!value) { + continue; + } + + const parsed = parseEffectDragData(value); + if (parsed) { + return parsed; + } + } + + return null; +}; + export const TimelineSequenceItem: React.FC<{ readonly sequence: TSequence; readonly nestedDepth: number; @@ -68,6 +107,7 @@ export const TimelineSequenceItem: React.FC<{ const {onSelect, selectable, selected} = useTimelineRowSelection(nodePathInfo); const containsSelection = useTimelineRowContainsSelection(nodePathInfo); + const [effectDropHovered, setEffectDropHovered] = useState(false); const {canOpenInEditor, openInEditor, originalLocation} = useOpenSequenceInEditor(sequence); @@ -410,6 +450,15 @@ export const TimelineSequenceItem: React.FC<{ }; }, []); + const rowStyle = useMemo((): React.CSSProperties => { + return effectDropHovered + ? { + ...inner, + ...effectDropHighlight, + } + : inner; + }, [effectDropHovered, inner]); + const hasExpandableContent = Boolean(sequence.controls) || sequence.effects.length > 0; @@ -422,6 +471,79 @@ export const TimelineSequenceItem: React.FC<{ codeHiddenStatus !== null && codeHiddenStatus.canUpdate; + const canDropEffect = + previewServerState.type === 'connected' && + nodePath !== null && + validatedLocation !== null && + sequence.type !== 'audio'; + + const onEffectDragOver = useCallback( + (e: React.DragEvent) => { + if (!canDropEffect || !hasEffectDragType(e.dataTransfer)) { + return; + } + + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + setEffectDropHovered(true); + }, + [canDropEffect], + ); + + const onEffectDragLeave = useCallback( + (e: React.DragEvent) => { + if (e.currentTarget.contains(e.relatedTarget as Node | null)) { + return; + } + + setEffectDropHovered(false); + }, + [], + ); + + const onEffectDrop = useCallback( + async (e: React.DragEvent) => { + if ( + !canDropEffect || + previewServerState.type !== 'connected' || + nodePath === null || + validatedLocation === null + ) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + setEffectDropHovered(false); + + const dragData = getEffectDragData(e.dataTransfer); + if (!dragData) { + showNotification('Could not read effect drag data', 3000); + return; + } + + try { + const result = await callApi('/api/add-effect', { + fileName: validatedLocation.source, + sequenceNodePath: nodePath, + effectName: dragData.effect.name, + effectImportPath: dragData.effect.importPath, + effectConfig: dragData.effect.config, + clientId: previewServerState.clientId, + }); + + if (result.success) { + showNotification(`Added ${dragData.effect.name}()`, 2000); + } else { + showNotification(result.reason, 4000); + } + } catch (err) { + showNotification((err as Error).message, 4000); + } + }, + [canDropEffect, nodePath, previewServerState, validatedLocation], + ); + const trackRow = ( ) } - style={inner} + style={rowStyle} selected={selected} selectable={selectable} onSelect={onSelect} showSelectedBackground containsSelection={containsSelection} outerHeight={outerHeight} + onDragLeave={canDropEffect ? onEffectDragLeave : undefined} + onDragOver={canDropEffect ? onEffectDragOver : undefined} + onDrop={canDropEffect ? onEffectDrop : undefined} onDoubleClick={ SELECTION_ENABLED && canOpenInEditor ? onShowInEditorDoubleClick