+ const row = (
+
{result.type === 'composition' ? (
result.compositionType === 'still' ? (
)
+ ) : result.type === 'folder' ? (
+
) : result.type === 'asset' ? (
);
+
+ if (composition === null) {
+ return row;
+ }
+
+ return
{row};
};
diff --git a/packages/studio/src/components/QuickSwitcher/composition-search.ts b/packages/studio/src/components/QuickSwitcher/composition-search.ts
new file mode 100644
index 00000000000..fcef8f8f341
--- /dev/null
+++ b/packages/studio/src/components/QuickSwitcher/composition-search.ts
@@ -0,0 +1,215 @@
+import type {_InternalTypes} from 'remotion';
+import type {CompositionSelectorItemType} from '../CompositionSelectorItem';
+
+export type CompositionSearchResult =
+ | {
+ readonly type: 'folder';
+ readonly id: string;
+ readonly title: string;
+ readonly level: number;
+ }
+ | {
+ readonly type: 'composition';
+ readonly composition: _InternalTypes['AnyComposition'];
+ readonly level: number;
+ };
+
+type RankedCompositionSearchResult =
+ | {
+ readonly type: 'folder';
+ readonly id: string;
+ readonly title: string;
+ readonly level: number;
+ readonly score: number;
+ readonly children: RankedCompositionSearchResult[];
+ }
+ | {
+ readonly type: 'composition';
+ readonly composition: _InternalTypes['AnyComposition'];
+ readonly level: number;
+ readonly score: number;
+ };
+
+const normalizeSearchText = (text: string) => {
+ return text
+ .trim()
+ .toLowerCase()
+ .replace(/[\s/-]/g, '');
+};
+
+const getMatchScore = (query: string, candidate: string): number | null => {
+ const normalizedCandidate = normalizeSearchText(candidate);
+ if (normalizedCandidate === query) {
+ return 400_000 - normalizedCandidate.length;
+ }
+
+ if (normalizedCandidate.startsWith(query)) {
+ return 300_000 - (normalizedCandidate.length - query.length);
+ }
+
+ const substringIndex = normalizedCandidate.indexOf(query);
+ if (substringIndex !== -1) {
+ return (
+ 200_000 -
+ substringIndex * 100 -
+ (normalizedCandidate.length - query.length)
+ );
+ }
+
+ let previousIndex = -1;
+ let firstIndex = -1;
+ let gapCount = 0;
+ for (const character of query) {
+ const nextIndex = normalizedCandidate.indexOf(character, previousIndex + 1);
+ if (nextIndex === -1) {
+ return null;
+ }
+
+ if (firstIndex === -1) {
+ firstIndex = nextIndex;
+ } else {
+ gapCount += nextIndex - previousIndex - 1;
+ }
+
+ previousIndex = nextIndex;
+ }
+
+ return (
+ 100_000 -
+ gapCount * 100 -
+ firstIndex * 10 -
+ (normalizedCandidate.length - query.length)
+ );
+};
+
+export const searchCompositionTree = ({
+ items,
+ query,
+}: {
+ readonly items: CompositionSelectorItemType[];
+ readonly query: string;
+}): CompositionSearchResult[] => {
+ const normalizedQuery = normalizeSearchText(query);
+
+ const filterAndRank = (
+ treeItems: CompositionSelectorItemType[],
+ level: number,
+ inheritedFolderScore: number | null,
+ ): RankedCompositionSearchResult[] => {
+ const rankedItems = treeItems
+ .map((item, originalIndex) => {
+ if (item.type === 'composition') {
+ const compositionPath = [
+ item.composition.parentFolderName,
+ item.composition.folderName,
+ item.composition.id,
+ ]
+ .filter(Boolean)
+ .join('/');
+ const idScore =
+ normalizedQuery === ''
+ ? 0
+ : getMatchScore(normalizedQuery, item.composition.id);
+ const compositionPathScore =
+ normalizedQuery === ''
+ ? 0
+ : getMatchScore(normalizedQuery, compositionPath);
+ const directScore = Math.max(
+ idScore ?? -1,
+ compositionPathScore ?? -1,
+ );
+ const score = Math.max(directScore, inheritedFolderScore ?? -1);
+ if (score === -1) {
+ return null;
+ }
+
+ return {
+ result: {
+ type: 'composition' as const,
+ composition: item.composition,
+ level,
+ score,
+ },
+ originalIndex,
+ };
+ }
+
+ const fullPath = [item.parentName, item.folderName]
+ .filter(Boolean)
+ .join('/');
+ const nameScore =
+ normalizedQuery === ''
+ ? 0
+ : getMatchScore(normalizedQuery, item.folderName);
+ const pathScore =
+ normalizedQuery === '' ? 0 : getMatchScore(normalizedQuery, fullPath);
+ const directFolderScore = Math.max(nameScore ?? -1, pathScore ?? -1);
+ const folderScore = Math.max(
+ directFolderScore === -1 ? -1 : directFolderScore - 1,
+ inheritedFolderScore ?? -1,
+ );
+ const children = filterAndRank(
+ item.items,
+ level + 1,
+ folderScore === -1 ? null : folderScore,
+ );
+ if (children.length === 0) {
+ return null;
+ }
+
+ return {
+ result: {
+ type: 'folder' as const,
+ id: `folder-${fullPath}`,
+ title: item.folderName,
+ level,
+ score: Math.max(
+ folderScore,
+ ...children.map((child) => child.score),
+ ),
+ children,
+ },
+ originalIndex,
+ };
+ })
+ .filter((item) => item !== null);
+
+ if (normalizedQuery !== '') {
+ rankedItems.sort((a, b) => {
+ return (
+ b.result.score - a.result.score || a.originalIndex - b.originalIndex
+ );
+ });
+ }
+
+ return rankedItems.map((item) => item.result);
+ };
+
+ const flatten = (
+ rankedItems: RankedCompositionSearchResult[],
+ ): CompositionSearchResult[] => {
+ return rankedItems.flatMap((item): CompositionSearchResult[] => {
+ if (item.type === 'composition') {
+ return [
+ {
+ type: 'composition',
+ composition: item.composition,
+ level: item.level,
+ },
+ ];
+ }
+
+ return [
+ {
+ type: 'folder',
+ id: item.id,
+ title: item.title,
+ level: item.level,
+ },
+ ...flatten(item.children),
+ ];
+ });
+ };
+
+ return flatten(filterAndRank(items, 0, null));
+};
diff --git a/packages/studio/src/components/SelectedOutlineElement.tsx b/packages/studio/src/components/SelectedOutlineElement.tsx
index 494a062d79d..a3b35cba47f 100644
--- a/packages/studio/src/components/SelectedOutlineElement.tsx
+++ b/packages/studio/src/components/SelectedOutlineElement.tsx
@@ -12,7 +12,6 @@ import {
import {SetSelectedModalContext} from '../state/modals';
import {Transform3DModeStateContext} from '../state/transform-3d-mode';
import {useConfirmationDialog} from './ConfirmationDialog';
-import {deleteJsxNode} from './delete-jsx-node-api';
import {useSelectComposition} from './InitialCompositionLoader';
import {showNotification} from './Notifications/NotificationCenter';
import type {SelectedOutline} from './selected-outline-geometry';
@@ -37,6 +36,7 @@ import {
type TimelineSelectionInteraction,
} from './Timeline/TimelineSelection';
import {getOriginalLocationFromStack} from './Timeline/TimelineStack/get-stack';
+import {useDeleteTimelineItems} from './Timeline/use-delete-timeline-items';
import {
useDefaultCodingAgentInfo,
useEditorOpening,
@@ -99,6 +99,7 @@ const SelectedOutlineElementUnmemoized: React.FC<
Internals.SequenceStackTracesUpdateContext,
);
const confirm = useConfirmationDialog();
+ const deleteTimelineItems = useDeleteTimelineItems();
const selectAsset = useSelectAsset();
const selectComposition = useSelectComposition();
const {compositions} = useContext(Internals.CompositionManager);
@@ -244,42 +245,17 @@ const SelectedOutlineElementUnmemoized: React.FC<
});
}
: null,
- onDeleteSequenceFromSource: async () => {
+ onDeleteSequenceFromSource: () => {
if (sourceEditDisabled || previewServerState.type !== 'connected') {
return;
}
- if (
- contextMenuTarget.nodePathInfo.numberOfSequencesWithThisNodePath > 1
- ) {
- const shouldDelete = await confirm({
- title: 'Delete sequence?',
- message:
- 'This sequence is programmatically duplicated ' +
- contextMenuTarget.nodePathInfo.numberOfSequencesWithThisNodePath +
- ' times in the code. Deleting removes all instances. Continue?',
- confirmLabel: 'Delete',
- });
- if (!shouldDelete) {
- return;
- }
- }
-
- try {
- const result = await deleteJsxNode({
- nodes: [
- {
- fileName: nodePath.absolutePath,
- nodePath: nodePath.nodePath,
- },
- ],
- });
- if (!result.success) {
- showNotification(result.reason, 4000);
- }
- } catch (err) {
- showNotification((err as Error).message, 4000);
- }
+ deleteTimelineItems([
+ {
+ type: 'sequence',
+ nodePathInfo: contextMenuTarget.nodePathInfo,
+ },
+ ]);
},
onDisableSequenceInteractivity: () => {
if (
@@ -437,6 +413,7 @@ const SelectedOutlineElementUnmemoized: React.FC<
canConfigureApps,
codingAgentInfo,
confirm,
+ deleteTimelineItems,
editorInfo,
getTarget,
onSelect,
diff --git a/packages/studio/src/components/Timeline/Timeline.tsx b/packages/studio/src/components/Timeline/Timeline.tsx
index 78d47c3e68b..26928cf183d 100644
--- a/packages/studio/src/components/Timeline/Timeline.tsx
+++ b/packages/studio/src/components/Timeline/Timeline.tsx
@@ -6,7 +6,6 @@ import React, {
useMemo,
useRef,
useState,
- useSyncExternalStore,
} from 'react';
import {Internals} from 'remotion';
import {FastRefreshContext} from '../../fast-refresh-context';
@@ -25,6 +24,7 @@ import {useIsStill} from '../../helpers/is-current-selected-still';
import {useCachedCompositionComponentInfo} from '../../helpers/open-in-editor';
import {getStudioMaxTimelineTracks} from '../../helpers/studio-runtime-config';
import {timelineSequenceNodePathToKey} from '../../helpers/timeline-node-path-key';
+import {useSyncExternalStore} from '../../helpers/use-sync-external-store';
import {callApi} from '../call-api';
import {ContextMenu} from '../ContextMenu';
import {importAssets, pickFilesToImport} from '../import-assets';
diff --git a/packages/studio/src/components/Timeline/TimelineDeleteKeybindings.tsx b/packages/studio/src/components/Timeline/TimelineDeleteKeybindings.tsx
index 4ba1200a05b..a370549c56a 100644
--- a/packages/studio/src/components/Timeline/TimelineDeleteKeybindings.tsx
+++ b/packages/studio/src/components/Timeline/TimelineDeleteKeybindings.tsx
@@ -1,21 +1,11 @@
-import {LINEAR_KEYFRAME_EASING} from '@remotion/studio-shared';
import type React from 'react';
import {useContext, useEffect} from 'react';
import {Internals} from 'remotion';
import {StudioServerConnectionCtx} from '../../helpers/client-id';
import {useKeybinding} from '../../helpers/use-keybinding';
-import {
- EditorShowGuidesContext,
- persistGuidesList,
-} from '../../state/editor-guides';
import {useConfirmationDialog} from '../ConfirmationDialog';
-import {
- deleteSelectedTimelineItems,
- getTimelineSelectionAfterDeletingItems,
-} from './delete-selected-timeline-item';
import {duplicateSelectedTimelineItems} from './duplicate-selected-timeline-item';
import {getCurrentFrame} from './imperative-state';
-import {resetSelectedTimelineProps} from './reset-selected-timeline-props';
import {
shouldHandleTimelineDuplicateShortcut,
shouldHandleTimelineSplitShortcut,
@@ -25,10 +15,7 @@ import {
useCurrentTimelineSelectionStateAsRef,
useTimelineSelection,
} from './TimelineSelection';
-import {
- getEasingSelections,
- updateSelectedTimelineEasings,
-} from './update-selected-easing';
+import {useDeleteTimelineItems} from './use-delete-timeline-items';
export const TimelineDeleteKeybindings: React.FC = () => {
const keybindings = useKeybinding();
@@ -40,109 +27,19 @@ export const TimelineDeleteKeybindings: React.FC = () => {
const propStatusesRef = useContext(
Internals.VisualModePropStatusesRefContext,
);
- const {setPropStatuses} = useContext(Internals.VisualModeSettersContext);
- const {setGuidesList} = useContext(EditorShowGuidesContext);
const {canSelect} = useTimelineSelection();
const currentSelection = useCurrentTimelineSelectionStateAsRef();
const confirm = useConfirmationDialog();
+ const deleteTimelineItems = useDeleteTimelineItems();
useEffect(() => {
if (!canSelect || previewServerState.type !== 'connected') {
return;
}
- const {clientId} = previewServerState;
- const handleDelete = () => {
- const timelinePosition = getCurrentFrame();
- const {selectedItems, clearSelection, selectItems} =
- currentSelection.current;
- const sequences = sequencesRef.current;
- const propStatuses = propStatusesRef.current;
- if (selectedItems.length === 0) {
- return;
- }
-
- const selectedGuide = selectedItems.find((item) => item.type === 'guide');
- if (selectedGuide) {
- setGuidesList((prevGuides) => {
- const newGuides = prevGuides.filter(
- (guide) => guide.id !== selectedGuide.guideId,
- );
- persistGuidesList(newGuides);
- return newGuides;
- });
- clearSelection();
- return;
- }
-
- const deletePromise = deleteSelectedTimelineItems({
- selections: selectedItems,
- sequences,
- overrideIdsToNodePaths: overrideIdToNodePathMappings,
- setPropStatuses,
- clientId,
- confirm,
- propStatuses,
- timelinePosition,
- });
-
- if (deletePromise !== null) {
- deletePromise
- .then((deleted) => {
- if (deleted) {
- const nextSelection = getTimelineSelectionAfterDeletingItems({
- selections: selectedItems,
- sequences,
- overrideIdsToNodePaths: overrideIdToNodePathMappings,
- propStatuses,
- timelinePosition,
- });
- if (nextSelection.length === 0) {
- clearSelection();
- } else {
- selectItems(nextSelection);
- }
- }
- })
- .catch(() => undefined);
- return;
- }
-
- const easingSelections = getEasingSelections(selectedItems);
- if (easingSelections.length === selectedItems.length) {
- const resetEasingPromise = updateSelectedTimelineEasings({
- selections: easingSelections,
- sequences,
- overrideIdsToNodePaths: overrideIdToNodePathMappings,
- propStatuses,
- setPropStatuses,
- clientId,
- easing: LINEAR_KEYFRAME_EASING,
- });
-
- if (resetEasingPromise !== null) {
- resetEasingPromise.catch(() => undefined);
- return;
- }
- }
-
- const resetPromise = resetSelectedTimelineProps({
- selections: selectedItems,
- sequences,
- overrideIdsToNodePaths: overrideIdToNodePathMappings,
- propStatuses,
- setPropStatuses,
- clientId,
- });
-
- if (resetPromise !== null) {
- resetPromise.catch(() => undefined);
- }
- };
-
const backspace = keybindings.registerKeybinding({
event: 'keydown',
key: 'Backspace',
- callback: handleDelete,
+ callback: () => deleteTimelineItems(null),
commandCtrlKey: false,
preventDefault: true,
triggerIfInputFieldFocused: false,
@@ -151,7 +48,7 @@ export const TimelineDeleteKeybindings: React.FC = () => {
const deleteKey = keybindings.registerKeybinding({
event: 'keydown',
key: 'Delete',
- callback: handleDelete,
+ callback: () => deleteTimelineItems(null),
commandCtrlKey: false,
preventDefault: true,
triggerIfInputFieldFocused: false,
@@ -229,13 +126,12 @@ export const TimelineDeleteKeybindings: React.FC = () => {
canSelect,
confirm,
currentSelection,
+ deleteTimelineItems,
keybindings,
overrideIdToNodePathMappings,
propStatusesRef,
previewServerState,
sequencesRef,
- setGuidesList,
- setPropStatuses,
]);
return null;
diff --git a/packages/studio/src/components/Timeline/TimelinePlayCursorSyncer.tsx b/packages/studio/src/components/Timeline/TimelinePlayCursorSyncer.tsx
index 1c66e14e0c4..6d50312f36f 100644
--- a/packages/studio/src/components/Timeline/TimelinePlayCursorSyncer.tsx
+++ b/packages/studio/src/components/Timeline/TimelinePlayCursorSyncer.tsx
@@ -31,7 +31,7 @@ let lastTimelinePositionWhileScrolling: TimelinePosition | null = null;
export const TimelinePlayCursorSyncer: React.FC = () => {
const video = Internals.useVideo();
const timelinePosition = Internals.Timeline.useTimelinePosition();
- const [playing] = Internals.Timeline.usePlayingState();
+ const playing = Internals.usePlaying();
const {playbackRate} = Internals.usePlaybackRate();
const {canvasContent} = useContext(Internals.CompositionManager);
const {zoom: zoomMap} = useContext(TimelineZoomCtx);
diff --git a/packages/studio/src/components/Timeline/TimelineSequence.tsx b/packages/studio/src/components/Timeline/TimelineSequence.tsx
index 4688652372e..e567b8cf000 100644
--- a/packages/studio/src/components/Timeline/TimelineSequence.tsx
+++ b/packages/studio/src/components/Timeline/TimelineSequence.tsx
@@ -36,11 +36,8 @@ import {SetSelectedModalContext} from '../../state/modals';
import {AudioWaveform} from '../AudioWaveform';
import {useConfirmationDialog} from '../ConfirmationDialog';
import {ContextMenu} from '../ContextMenu';
-import {deleteJsxNode} from '../delete-jsx-node-api';
import {useSelectComposition} from '../InitialCompositionLoader';
-import {showNotification} from '../Notifications/NotificationCenter';
import {useSelectAsset} from '../use-select-asset';
-import {deleteSequencesFromSource} from './delete-selected-timeline-item';
import {disableSequenceInteractivity} from './disable-sequence-interactivity';
import {duplicateSequencesFromSource} from './duplicate-selected-timeline-item';
import {
@@ -57,7 +54,6 @@ import {
shouldSelectTimelineRowOnPointerDown,
TIMELINE_MARQUEE_ITEM_ATTR,
useTimelineMarqueeSelectableItem,
- useTimelineRowContainsSelection,
useTimelineRowSelection,
useTimelineSelection,
} from './TimelineSelection';
@@ -74,6 +70,7 @@ import {
import {TimelineVideoInfo} from './TimelineVideoInfo';
import {TimelineViewportContext} from './TimelineViewport';
import {TimelineWidthContext} from './TimelineWidthProvider';
+import {useDeleteTimelineItems} from './use-delete-timeline-items';
import {useOpenSequenceInApps} from './use-open-sequence-in-apps';
import {getSequenceFreezeFrameMenuItem} from './use-sequence-freeze-frame-menu-item';
@@ -209,8 +206,6 @@ const TimelineSequenceCurrentFrame: React.FC<{
const ref = useRef
(null);
const {onSelect, selectable, selected, selectionItem} =
useTimelineRowSelection(nodePathInfo);
- const containsSelection = useTimelineRowContainsSelection(nodePathInfo);
- const {selectedItems} = useTimelineSelection();
useTimelineMarqueeSelectableItem(selectionItem, ref);
const onPointerDown = useCallback(
@@ -261,17 +256,13 @@ const TimelineSequenceCurrentFrame: React.FC<{
: 0;
const actualStyle: React.CSSProperties = useMemo(() => {
- const hasSelectedTrack = selectedItems.some(
- (item) => item.type !== 'guide',
- );
-
return {
...style,
background: negativeStart ? TRANSPARENT : style.background,
border: negativeStart ? 'none' : style.border,
- opacity: hasSelectedTrack && !selected && !containsSelection ? 0.75 : 1,
+ opacity: selected ? 1 : 0.75,
};
- }, [containsSelection, negativeStart, selected, selectedItems, style]);
+ }, [negativeStart, selected, style]);
const content = (
<>
@@ -485,6 +476,7 @@ const TimelineSequenceInner: React.FC<{
const selectAsset = useSelectAsset();
const selectComposition = useSelectComposition();
const confirm = useConfirmationDialog();
+ const deleteTimelineItems = useDeleteTimelineItems();
const {onSelect, selectable, selected} =
useTimelineRowSelection(nodePathInfo);
const {selectedItems} = useTimelineSelection();
@@ -581,43 +573,20 @@ const TimelineSequenceInner: React.FC<{
() => undefined,
);
}, [confirm, previewInteractive, selectedSequenceNodePathInfos]);
- const onDeleteSequenceFromSource = useCallback(async () => {
- if (!validatedLocation?.source || !nodePath || deleteDisabled) {
+ const onDeleteSequenceFromSource = useCallback(() => {
+ if (
+ !validatedLocation?.source ||
+ !nodePath ||
+ !nodePathInfo ||
+ deleteDisabled
+ ) {
return;
}
- if (nodePathInfo && nodePathInfo.numberOfSequencesWithThisNodePath > 1) {
- const shouldDelete = await confirm({
- title: 'Delete sequence?',
- message:
- 'This sequence is programmatically duplicated ' +
- nodePathInfo.numberOfSequencesWithThisNodePath +
- ' times in the code. Deleting removes all instances. Continue?',
- confirmLabel: 'Delete',
- });
- if (!shouldDelete) {
- return;
- }
- }
-
- try {
- const result = await deleteJsxNode({
- nodes: [
- {
- fileName: validatedLocation.source,
- nodePath: nodePath.nodePath,
- },
- ],
- });
- if (!result.success) {
- showNotification(result.reason, 4000);
- }
- } catch (err) {
- showNotification((err as Error).message, 4000);
- }
+ deleteTimelineItems([{type: 'sequence', nodePathInfo}]);
}, [
- confirm,
deleteDisabled,
+ deleteTimelineItems,
nodePath,
nodePathInfo,
validatedLocation?.source,
@@ -627,10 +596,13 @@ const TimelineSequenceInner: React.FC<{
return;
}
- deleteSequencesFromSource(selectedSequenceNodePathInfos, confirm).catch(
- () => undefined,
+ deleteTimelineItems(
+ selectedSequenceNodePathInfos.map((selectedNodePathInfo) => ({
+ type: 'sequence',
+ nodePathInfo: selectedNodePathInfo,
+ })),
);
- }, [confirm, previewInteractive, selectedSequenceNodePathInfos]);
+ }, [deleteTimelineItems, previewInteractive, selectedSequenceNodePathInfos]);
const onDisableSequenceInteractivity = useCallback(() => {
if (
disableInteractivityDisabled ||
diff --git a/packages/studio/src/components/Timeline/TimelineSequenceItem.tsx b/packages/studio/src/components/Timeline/TimelineSequenceItem.tsx
index b18fa33d996..19f56333782 100644
--- a/packages/studio/src/components/Timeline/TimelineSequenceItem.tsx
+++ b/packages/studio/src/components/Timeline/TimelineSequenceItem.tsx
@@ -41,7 +41,6 @@ import {Transform3DModeStateContext} from '../../state/transform-3d-mode';
import {CompositionOrStillIcon} from '../CompositionOrStillIcon';
import {useConfirmationDialog} from '../ConfirmationDialog';
import {ContextMenu} from '../ContextMenu';
-import {deleteJsxNode} from '../delete-jsx-node-api';
import {
addEffectFromDragData,
getEffectDragData,
@@ -62,7 +61,6 @@ import {
rotateFieldKey,
} from '../selected-outline-types';
import {useSelectAsset} from '../use-select-asset';
-import {deleteSequencesFromSource} from './delete-selected-timeline-item';
import {disableSequenceInteractivity} from './disable-sequence-interactivity';
import {duplicateSequencesFromSource} from './duplicate-selected-timeline-item';
import {
@@ -90,6 +88,7 @@ import {
useTimelineSelection,
} from './TimelineSelection';
import {TimelineSequenceName} from './TimelineSequenceName';
+import {useDeleteTimelineItems} from './use-delete-timeline-items';
import {useOpenSequenceInApps} from './use-open-sequence-in-apps';
import {useRenameSequence} from './use-rename-sequence';
import {getSequenceFreezeFrameMenuItem} from './use-sequence-freeze-frame-menu-item';
@@ -304,6 +303,7 @@ const TimelineSequenceItemInner: React.FC<{
const {isHighestContext} = useKeybinding();
const selectAsset = useSelectAsset();
const selectComposition = useSelectComposition();
+ const deleteTimelineItems = useDeleteTimelineItems();
const {onSelect, selectable, selected} =
useTimelineRowSelection(nodePathInfo);
const {selectItem, selectedItems} = useTimelineSelection();
@@ -422,43 +422,20 @@ const TimelineSequenceItemInner: React.FC<{
);
}, [confirm, previewInteractive, selectedSequenceNodePathInfos]);
- const onDeleteSequenceFromSource = useCallback(async () => {
- if (deleteDisabled || !validatedLocation?.source || !nodePath) {
+ const onDeleteSequenceFromSource = useCallback(() => {
+ if (
+ deleteDisabled ||
+ !validatedLocation?.source ||
+ !nodePath ||
+ !nodePathInfo
+ ) {
return;
}
- if (nodePathInfo && nodePathInfo.numberOfSequencesWithThisNodePath > 1) {
- const shouldDelete = await confirm({
- title: 'Delete sequence?',
- message:
- 'This sequence is programmatically duplicated ' +
- nodePathInfo.numberOfSequencesWithThisNodePath +
- ' times in the code. Deleting removes all instances. Continue?',
- confirmLabel: 'Delete',
- });
- if (!shouldDelete) {
- return;
- }
- }
-
- try {
- const result = await deleteJsxNode({
- nodes: [
- {
- fileName: validatedLocation.source,
- nodePath: nodePath.nodePath,
- },
- ],
- });
- if (!result.success) {
- showNotification(result.reason, 4000);
- }
- } catch (err) {
- showNotification((err as Error).message, 4000);
- }
+ deleteTimelineItems([{type: 'sequence', nodePathInfo}]);
}, [
- confirm,
deleteDisabled,
+ deleteTimelineItems,
nodePath,
validatedLocation?.source,
nodePathInfo,
@@ -468,10 +445,13 @@ const TimelineSequenceItemInner: React.FC<{
return;
}
- deleteSequencesFromSource(selectedSequenceNodePathInfos, confirm).catch(
- () => undefined,
+ deleteTimelineItems(
+ selectedSequenceNodePathInfos.map((selectedNodePathInfo) => ({
+ type: 'sequence',
+ nodePathInfo: selectedNodePathInfo,
+ })),
);
- }, [confirm, previewInteractive, selectedSequenceNodePathInfos]);
+ }, [deleteTimelineItems, previewInteractive, selectedSequenceNodePathInfos]);
const onDisableSequenceInteractivity = useCallback(() => {
if (
diff --git a/packages/studio/src/components/Timeline/delete-selected-timeline-item.ts b/packages/studio/src/components/Timeline/delete-selected-timeline-item.ts
index 61e76e14909..42fb7b59f1e 100644
--- a/packages/studio/src/components/Timeline/delete-selected-timeline-item.ts
+++ b/packages/studio/src/components/Timeline/delete-selected-timeline-item.ts
@@ -73,11 +73,11 @@ export const deleteSequencesFromSource = async (
showNotification(result.reason, 4000);
}
- return true;
+ return result.success;
})
.catch((err) => {
showNotification((err as Error).message, 4000);
- return true;
+ return false;
});
};
diff --git a/packages/studio/src/components/Timeline/use-delete-timeline-items.ts b/packages/studio/src/components/Timeline/use-delete-timeline-items.ts
new file mode 100644
index 00000000000..043f3f11323
--- /dev/null
+++ b/packages/studio/src/components/Timeline/use-delete-timeline-items.ts
@@ -0,0 +1,149 @@
+import {LINEAR_KEYFRAME_EASING} from '@remotion/studio-shared';
+import {useCallback, useContext} from 'react';
+import {Internals} from 'remotion';
+import {StudioServerConnectionCtx} from '../../helpers/client-id';
+import {
+ EditorShowGuidesContext,
+ persistGuidesList,
+} from '../../state/editor-guides';
+import {useConfirmationDialog} from '../ConfirmationDialog';
+import {
+ deleteSelectedTimelineItems,
+ getTimelineSelectionAfterDeletingItems,
+} from './delete-selected-timeline-item';
+import {getCurrentFrame} from './imperative-state';
+import {resetSelectedTimelineProps} from './reset-selected-timeline-props';
+import {
+ type TimelineSelection,
+ useCurrentTimelineSelectionStateAsRef,
+} from './TimelineSelection';
+import {
+ getEasingSelections,
+ updateSelectedTimelineEasings,
+} from './update-selected-easing';
+
+export const useDeleteTimelineItems = () => {
+ const {previewServerState} = useContext(StudioServerConnectionCtx);
+ const sequencesRef = useContext(Internals.SequenceManagerRefContext);
+ const {overrideIdToNodePathMappings} = useContext(
+ Internals.OverrideIdsToNodePathsGettersContext,
+ );
+ const propStatusesRef = useContext(
+ Internals.VisualModePropStatusesRefContext,
+ );
+ const {setPropStatuses} = useContext(Internals.VisualModeSettersContext);
+ const {setGuidesList} = useContext(EditorShowGuidesContext);
+ const currentSelection = useCurrentTimelineSelectionStateAsRef();
+ const confirm = useConfirmationDialog();
+
+ return useCallback(
+ (selectedItemsOverride: readonly TimelineSelection[] | null) => {
+ if (previewServerState.type !== 'connected') {
+ return;
+ }
+
+ const {clientId} = previewServerState;
+ const {
+ selectedItems: currentSelectedItems,
+ clearSelection,
+ selectItems,
+ } = currentSelection.current;
+ const selectedItems = selectedItemsOverride ?? currentSelectedItems;
+ const sequences = sequencesRef.current;
+ const propStatuses = propStatusesRef.current;
+ const timelinePosition = getCurrentFrame();
+ if (selectedItems.length === 0) {
+ return;
+ }
+
+ const selectedGuide = selectedItems.find((item) => item.type === 'guide');
+ if (selectedGuide) {
+ setGuidesList((prevGuides) => {
+ const newGuides = prevGuides.filter(
+ (guide) => guide.id !== selectedGuide.guideId,
+ );
+ persistGuidesList(newGuides);
+ return newGuides;
+ });
+ clearSelection();
+ return;
+ }
+
+ const deletePromise = deleteSelectedTimelineItems({
+ selections: selectedItems,
+ sequences,
+ overrideIdsToNodePaths: overrideIdToNodePathMappings,
+ setPropStatuses,
+ clientId,
+ confirm,
+ propStatuses,
+ timelinePosition,
+ });
+
+ if (deletePromise !== null) {
+ deletePromise
+ .then((deleted) => {
+ if (!deleted) {
+ return;
+ }
+
+ const nextSelection = getTimelineSelectionAfterDeletingItems({
+ selections: selectedItems,
+ sequences,
+ overrideIdsToNodePaths: overrideIdToNodePathMappings,
+ propStatuses,
+ timelinePosition,
+ });
+ if (nextSelection.length === 0) {
+ clearSelection();
+ } else {
+ selectItems(nextSelection);
+ }
+ })
+ .catch(() => undefined);
+ return;
+ }
+
+ const easingSelections = getEasingSelections(selectedItems);
+ if (easingSelections.length === selectedItems.length) {
+ const resetEasingPromise = updateSelectedTimelineEasings({
+ selections: easingSelections,
+ sequences,
+ overrideIdsToNodePaths: overrideIdToNodePathMappings,
+ propStatuses,
+ setPropStatuses,
+ clientId,
+ easing: LINEAR_KEYFRAME_EASING,
+ });
+
+ if (resetEasingPromise !== null) {
+ resetEasingPromise.catch(() => undefined);
+ return;
+ }
+ }
+
+ const resetPromise = resetSelectedTimelineProps({
+ selections: selectedItems,
+ sequences,
+ overrideIdsToNodePaths: overrideIdToNodePathMappings,
+ propStatuses,
+ setPropStatuses,
+ clientId,
+ });
+
+ if (resetPromise !== null) {
+ resetPromise.catch(() => undefined);
+ }
+ },
+ [
+ confirm,
+ currentSelection,
+ overrideIdToNodePathMappings,
+ previewServerState,
+ propStatusesRef,
+ sequencesRef,
+ setGuidesList,
+ setPropStatuses,
+ ],
+ );
+};
diff --git a/packages/studio/src/components/WebMcp.tsx b/packages/studio/src/components/WebMcp.tsx
index 5132946e60f..e06d8d66aba 100644
--- a/packages/studio/src/components/WebMcp.tsx
+++ b/packages/studio/src/components/WebMcp.tsx
@@ -3,6 +3,7 @@ import {Internals} from 'remotion';
import {pause} from '../api/pause';
import {play} from '../api/play';
import {seek} from '../api/seek';
+import {getCurrentError} from '../error-overlay/current-error';
import {calculateTimeline} from '../helpers/calculate-timeline';
import {createFolderTree} from '../helpers/create-folder-tree';
import {
@@ -129,7 +130,7 @@ export const WebMcp: FC = () => {
useContext(Internals.CompositionManager);
const {playbackRate: currentPlaybackRate, setPlaybackRate} =
Internals.usePlaybackRate();
- const [, , imperativePlaying] = Internals.Timeline.usePlayingState();
+ const {isPlaying} = Internals.Timeline.useTimelineContext();
const {mediaVolume, playerMuted} = useContext(Internals.MediaVolumeContext);
const {setPlayerMuted} = useContext(Internals.SetMediaVolumeContext);
const {setZoom: setTimelineZoom, zoom: timelineZoomMap} =
@@ -296,6 +297,42 @@ export const WebMcp: FC = () => {
};
Promise.all([
+ modelContext.registerTool(
+ {
+ name: 'get_current_error',
+ title: 'Get current Studio error',
+ description:
+ 'Read the error currently shown in the Remotion Studio error overlay. Returns null when the overlay is not visible and includes symbolicated stack frames when symbolication succeeds.',
+ inputSchema: {
+ type: 'object',
+ properties: {},
+ additionalProperties: false,
+ },
+ annotations: {readOnlyHint: true},
+ execute: async () => {
+ const currentError = getCurrentError();
+ if (currentError === null) {
+ return null;
+ }
+
+ let symbolicatedStackFrames = null;
+ try {
+ const record = await currentError.symbolication;
+ symbolicatedStackFrames = record?.stackFrames ?? null;
+ } catch {
+ // Fall back to the original stack if symbolication fails.
+ }
+
+ return {
+ name: currentError.error.name,
+ message: currentError.error.message,
+ stack: currentError.error.stack ?? null,
+ symbolicatedStackFrames,
+ };
+ },
+ },
+ {signal: controller.signal},
+ ),
modelContext.registerTool(
{
name: 'get_compositions',
@@ -688,7 +725,7 @@ export const WebMcp: FC = () => {
return Promise.resolve({
currentComposition: compositionId,
currentFrame: getCurrentFrame(),
- playing: imperativePlaying.current,
+ playing: isPlaying(),
muted: playerMutedRef.current,
volume: mediaVolumeRef.current,
playbackRate: playbackRateRef.current,
@@ -1218,7 +1255,7 @@ export const WebMcp: FC = () => {
};
}, [
clearSelection,
- imperativePlaying,
+ isPlaying,
selectComposition,
selectItems,
setEditorShowGuides,
diff --git a/packages/studio/src/components/ZoomPersistor.tsx b/packages/studio/src/components/ZoomPersistor.tsx
index 523412594e6..89de1eeaa53 100644
--- a/packages/studio/src/components/ZoomPersistor.tsx
+++ b/packages/studio/src/components/ZoomPersistor.tsx
@@ -18,7 +18,7 @@ export const getZoomFromLocalStorage = (): Record => {
};
export const ZoomPersistor: React.FC = () => {
- const [playing] = Internals.Timeline.usePlayingState();
+ const playing = Internals.usePlaying();
const {zoom} = useContext(TimelineZoomCtx);
const {canvasContent} = useContext(Internals.CompositionManager);
diff --git a/packages/studio/src/components/composition-drag-data.ts b/packages/studio/src/components/composition-drag-data.ts
new file mode 100644
index 00000000000..f3bad86d198
--- /dev/null
+++ b/packages/studio/src/components/composition-drag-data.ts
@@ -0,0 +1,312 @@
+import type {SymbolicatedStackFrame} from '@remotion/studio-shared';
+import * as z from 'zod/mini';
+
+const REMOTION_DRAG_MIME_TYPE = 'application/vnd.remotion.drag+json';
+const DRAG_MIME_VERSION = 1;
+const MAX_MIME_TYPE_LENGTH = 512;
+const MAX_DIMENSION = 100_000;
+const MAX_DURATION_IN_FRAMES = 100_000_000;
+
+export type CompositionDragData = {
+ readonly type: 'remotion-composition';
+ readonly version: 1;
+ readonly compositionId: string;
+ readonly compositionFile: string | null;
+};
+
+export type CompositionDragPreviewMetadata = {
+ readonly type: 'composition';
+ readonly width: number | null;
+ readonly height: number | null;
+ readonly durationInFrames: number | null;
+ readonly mimeType: string;
+};
+
+type MakeCompositionDragDataInput = {
+ readonly compositionFile: string | null;
+ readonly compositionId: string;
+ readonly width: number | null;
+ readonly height: number | null;
+ readonly durationInFrames: number | null;
+};
+
+type SerializedCompositionDragData = {
+ readonly mimeType: string;
+ readonly payload: string;
+};
+
+type CompositionDragDataTransfer = {
+ readonly types: ArrayLike;
+ readonly getData: (mimeType: string) => string;
+};
+
+const compositionIdSchema = z
+ .string()
+ .check(
+ z.refine(
+ (value) =>
+ value.length > 0 &&
+ value.length < 500 &&
+ /^([a-zA-Z0-9-\u4E00-\u9FFF])+$/.test(value),
+ ),
+ );
+const compositionFileSchema = z.nullable(
+ z
+ .string()
+ .check(
+ z.refine(
+ (value) =>
+ value.length > 0 &&
+ value.length < 2000 &&
+ !value.includes('\0') &&
+ !value.includes('\\') &&
+ !value.startsWith('/') &&
+ !value.split('/').includes('..'),
+ ),
+ ),
+);
+const compositionDragDataSchema = z.object({
+ type: z.literal('remotion-composition'),
+ version: z.literal(1),
+ compositionId: compositionIdSchema,
+ compositionFile: compositionFileSchema,
+});
+
+const parsePositiveNumber = (value: string, max: number): number | null => {
+ if (!/^(?:[1-9]\d*|0\.\d+|[1-9]\d*\.\d+)$/.test(value)) {
+ return null;
+ }
+
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed > 0 && parsed <= max ? parsed : null;
+};
+
+const parsePositiveInteger = (value: string, max: number): number | null => {
+ if (!/^[1-9]\d*$/.test(value)) {
+ return null;
+ }
+
+ const parsed = Number(value);
+ return Number.isInteger(parsed) && parsed <= max ? parsed : null;
+};
+
+const parseCompositionDragMimeType = (
+ mimeType: string,
+): CompositionDragPreviewMetadata | null => {
+ if (
+ mimeType.length > MAX_MIME_TYPE_LENGTH ||
+ !mimeType.startsWith(`${REMOTION_DRAG_MIME_TYPE};`)
+ ) {
+ return null;
+ }
+
+ const segments = mimeType.split(';');
+ if (segments.shift() !== REMOTION_DRAG_MIME_TYPE) {
+ return null;
+ }
+
+ const values = new Map();
+ for (const segment of segments) {
+ const separator = segment.indexOf('=');
+ if (separator === -1) {
+ return null;
+ }
+
+ const key = segment.slice(0, separator);
+ const value = segment.slice(separator + 1);
+ if (
+ !['v', 'type', 'width', 'height', 'duration'].includes(key) ||
+ value === '' ||
+ values.has(key)
+ ) {
+ return null;
+ }
+
+ values.set(key, value);
+ }
+
+ if (
+ values.get('v') !== String(DRAG_MIME_VERSION) ||
+ values.get('type') !== 'composition'
+ ) {
+ return null;
+ }
+
+ const widthValue = values.get('width');
+ const heightValue = values.get('height');
+ if ((widthValue === undefined) !== (heightValue === undefined)) {
+ return null;
+ }
+
+ const width =
+ widthValue === undefined
+ ? null
+ : parsePositiveNumber(widthValue, MAX_DIMENSION);
+ const height =
+ heightValue === undefined
+ ? null
+ : parsePositiveNumber(heightValue, MAX_DIMENSION);
+ const durationValue = values.get('duration');
+ const durationInFrames =
+ durationValue === undefined
+ ? null
+ : parsePositiveInteger(durationValue, MAX_DURATION_IN_FRAMES);
+ if (widthValue !== undefined && width === null) {
+ return null;
+ }
+
+ if (heightValue !== undefined && height === null) {
+ return null;
+ }
+
+ if (durationValue !== undefined && durationInFrames === null) {
+ return null;
+ }
+
+ return {
+ type: 'composition',
+ width,
+ height,
+ durationInFrames,
+ mimeType,
+ };
+};
+
+export const makeCompositionDragData = ({
+ compositionFile,
+ compositionId,
+ width,
+ height,
+ durationInFrames,
+}: MakeCompositionDragDataInput): SerializedCompositionDragData & {
+ readonly data: CompositionDragData;
+} => {
+ if ((width === null) !== (height === null)) {
+ throw new TypeError(
+ 'width and height must either both be numbers or both be null',
+ );
+ }
+
+ if (
+ width !== null &&
+ (!Number.isFinite(width) || width <= 0 || width > MAX_DIMENSION)
+ ) {
+ throw new TypeError(
+ `width and height must be numbers between 0 and ${MAX_DIMENSION}`,
+ );
+ }
+
+ if (
+ height !== null &&
+ (!Number.isFinite(height) || height <= 0 || height > MAX_DIMENSION)
+ ) {
+ throw new TypeError(
+ `width and height must be numbers between 0 and ${MAX_DIMENSION}`,
+ );
+ }
+
+ if (
+ durationInFrames !== null &&
+ (!Number.isInteger(durationInFrames) ||
+ durationInFrames <= 0 ||
+ durationInFrames > MAX_DURATION_IN_FRAMES)
+ ) {
+ throw new TypeError(
+ `durationInFrames must be an integer between 1 and ${MAX_DURATION_IN_FRAMES}`,
+ );
+ }
+
+ const data: CompositionDragData = {
+ type: 'remotion-composition',
+ version: 1,
+ compositionFile,
+ compositionId,
+ };
+ const segments = [
+ REMOTION_DRAG_MIME_TYPE,
+ `v=${DRAG_MIME_VERSION}`,
+ 'type=composition',
+ ];
+ if (width !== null && height !== null) {
+ segments.push(`width=${width}`, `height=${height}`);
+ }
+
+ if (durationInFrames !== null) {
+ segments.push(`duration=${durationInFrames}`);
+ }
+
+ return {
+ mimeType: segments.join(';'),
+ payload: JSON.stringify(data),
+ data,
+ };
+};
+
+export const getCompositionDragPreviewMetadata = (
+ mimeTypes: ArrayLike,
+): CompositionDragPreviewMetadata | null => {
+ for (let index = 0; index < mimeTypes.length; index++) {
+ const parsed = parseCompositionDragMimeType(mimeTypes[index]);
+ if (parsed !== null) {
+ return parsed;
+ }
+ }
+
+ return null;
+};
+
+export const parseCompositionDragData = (
+ source: SerializedCompositionDragData | CompositionDragDataTransfer,
+): CompositionDragData | null => {
+ const serialized =
+ 'types' in source
+ ? (() => {
+ const preview = getCompositionDragPreviewMetadata(source.types);
+ return preview === null
+ ? null
+ : {
+ mimeType: preview.mimeType,
+ payload: source.getData(preview.mimeType),
+ };
+ })()
+ : source;
+ if (
+ serialized === null ||
+ parseCompositionDragMimeType(serialized.mimeType) === null
+ ) {
+ return null;
+ }
+
+ try {
+ const parsed = z.safeParse(
+ compositionDragDataSchema,
+ JSON.parse(serialized.payload),
+ );
+ return parsed.success
+ ? {
+ type: 'remotion-composition',
+ version: 1,
+ compositionFile: parsed.data.compositionFile,
+ compositionId: parsed.data.compositionId,
+ }
+ : null;
+ } catch {
+ return null;
+ }
+};
+
+export const compositionDragDataToSymbolicatedStack = (
+ dragData: CompositionDragData,
+): SymbolicatedStackFrame | null => {
+ if (dragData.compositionFile === null) {
+ return null;
+ }
+
+ return {
+ originalColumnNumber: null,
+ originalFileName: dragData.compositionFile,
+ originalFunctionName: null,
+ originalLineNumber: null,
+ originalScriptCode: null,
+ };
+};
diff --git a/packages/studio/src/components/drop-handler-data.ts b/packages/studio/src/components/drop-handler-data.ts
index 6354c0d007d..bebd7e21071 100644
--- a/packages/studio/src/components/drop-handler-data.ts
+++ b/packages/studio/src/components/drop-handler-data.ts
@@ -1,10 +1,14 @@
import {
StudioProtocolInternals,
type ComponentDragData,
- type CompositionDragData,
type DragPreviewMetadata,
} from '@remotion/studio-protocol';
import {hasRemoteAssetDragData} from '../helpers/remote-asset-drag';
+import {
+ getCompositionDragPreviewMetadata,
+ parseCompositionDragData,
+ type CompositionDragData,
+} from './composition-drag-data';
export const isFileDragEvent = (event: DragEvent): boolean => {
return Array.from(event.dataTransfer?.types ?? []).includes('Files');
@@ -30,7 +34,9 @@ export const isComponentDragEvent = (event: DragEvent) => {
};
export const isCompositionDragEvent = (event: DragEvent) => {
- return isRemotionDragEvent(event, 'composition');
+ return (
+ getCompositionDragPreviewMetadata(event.dataTransfer?.types ?? []) !== null
+ );
};
export const isElementDragEvent = (event: DragEvent) => {
@@ -75,10 +81,9 @@ export const getAssetDragPath = (event: DragEvent): string | null => {
export const getCompositionDragData = (
event: DragEvent,
): CompositionDragData | null => {
- const parsed = event.dataTransfer
- ? StudioProtocolInternals.parseDragData(event.dataTransfer)
+ return event.dataTransfer
+ ? parseCompositionDragData(event.dataTransfer)
: null;
- return parsed?.type === 'composition' ? parsed.data : null;
};
export const getComponentDragData = (
diff --git a/packages/studio/src/components/effect-drag-and-drop.ts b/packages/studio/src/components/effect-drag-and-drop.ts
index 7885b1693ea..50f8d12c578 100644
--- a/packages/studio/src/components/effect-drag-and-drop.ts
+++ b/packages/studio/src/components/effect-drag-and-drop.ts
@@ -2,7 +2,10 @@ import {
StudioProtocolInternals,
type EffectDragData,
} from '@remotion/studio-protocol';
-import {getRequiredPackageForEffectImportPath} from '@remotion/studio-shared';
+import {
+ type EffectDefinition,
+ getRequiredPackageForEffectImportPath,
+} from '@remotion/studio-shared';
import type {SequencePropsSubscriptionKey} from 'remotion';
import {getBrowserStudioEffectOperations} from '../helpers/browser-studio-operations';
import {installRequiredPackages} from '../helpers/install-required-package';
@@ -53,7 +56,7 @@ export const addEffectToSequence = async ({
nodePath,
}: {
readonly clientId: string;
- readonly effect: EffectDragData['effect'];
+ readonly effect: EffectDefinition;
readonly fileName: string;
readonly nodePath: SequencePropsSubscriptionKey;
}) => {
diff --git a/packages/studio/src/components/handle-drop.ts b/packages/studio/src/components/handle-drop.ts
index c21412aea4b..7f7de6890cd 100644
--- a/packages/studio/src/components/handle-drop.ts
+++ b/packages/studio/src/components/handle-drop.ts
@@ -137,7 +137,11 @@ export const handleDrop = async ({
createdAt: Date.now(),
compositionFile,
compositionId,
- element: element.element,
+ element: {
+ ...element.element,
+ durationInFrames: element.element.durationInFrames ?? null,
+ installationMode: element.element.installationMode ?? null,
+ },
from: getFromForDrop({
durationInFrames: element.element.durationInFrames,
from,
diff --git a/packages/studio/src/components/import-assets.ts b/packages/studio/src/components/import-assets.ts
index 196160cba36..9b6b5425e10 100644
--- a/packages/studio/src/components/import-assets.ts
+++ b/packages/studio/src/components/import-assets.ts
@@ -1,18 +1,15 @@
-import {
- type ComponentDragData,
- type CompositionDragData,
- type ComponentProp,
- type ElementDragData,
-} from '@remotion/studio-protocol';
+import type {ComponentDragData} from '@remotion/studio-protocol';
import {
detectFileType,
getRequiredPackageForInsertableElement,
isUrl,
+ type ComponentProp,
type DownloadRemoteAssetResponse,
type ElementInstallExpectedFileState,
type FileType,
type InsertableCompositionElement,
type InsertableCompositionElementPosition,
+ type InstallableElement,
} from '@remotion/studio-shared';
import {Internals, staticFile} from 'remotion';
import {NoReactInternals} from 'remotion/no-react';
@@ -25,6 +22,7 @@ import {installRequiredPackages} from '../helpers/install-required-package';
import type {Dimensions} from '../helpers/is-current-selected-still';
import {getMediaMetadata} from '../helpers/use-media-metadata';
import {callApi} from './call-api';
+import type {CompositionDragData} from './composition-drag-data';
import {installElement} from './element-install-api';
import {showNotification} from './Notifications/NotificationCenter';
@@ -1330,7 +1328,7 @@ export const insertElement = async ({
}: {
compositionFile: string;
compositionId: string;
- element: ElementDragData['element'];
+ element: InstallableElement;
expectedFileState: ElementInstallExpectedFileState;
position: InsertableCompositionElementPosition | null;
from: number | null;
diff --git a/packages/studio/src/error-overlay/current-error.ts b/packages/studio/src/error-overlay/current-error.ts
new file mode 100644
index 00000000000..784b9b23ed9
--- /dev/null
+++ b/packages/studio/src/error-overlay/current-error.ts
@@ -0,0 +1,29 @@
+import type {ErrorRecord} from './react-overlay/listen-to-runtime-errors';
+
+type CurrentError = {
+ readonly error: Error;
+ readonly symbolication: Promise;
+};
+
+declare global {
+ interface Window {
+ remotion_currentErrors: CurrentError[] | undefined;
+ }
+}
+
+export const registerCurrentError = (error: CurrentError) => {
+ window.remotion_currentErrors = [
+ ...(window.remotion_currentErrors ?? []),
+ error,
+ ];
+
+ return () => {
+ window.remotion_currentErrors = window.remotion_currentErrors?.filter(
+ (candidate) => candidate !== error,
+ );
+ };
+};
+
+export const getCurrentError = (): CurrentError | null => {
+ return window.remotion_currentErrors?.[0] ?? null;
+};
diff --git a/packages/studio/src/error-overlay/remotion-overlay/ErrorLoader.tsx b/packages/studio/src/error-overlay/remotion-overlay/ErrorLoader.tsx
index 1472d2c255c..c9a136c8382 100644
--- a/packages/studio/src/error-overlay/remotion-overlay/ErrorLoader.tsx
+++ b/packages/studio/src/error-overlay/remotion-overlay/ErrorLoader.tsx
@@ -1,5 +1,6 @@
import {getLocationFromBuildError} from '@remotion/studio-shared';
import React, {useEffect, useState} from 'react';
+import {registerCurrentError} from '../current-error';
import {wasErrorLoggedByServer} from '../error-origin';
import type {ErrorRecord} from '../react-overlay/listen-to-runtime-errors';
import {getErrorRecord} from '../react-overlay/listen-to-runtime-errors';
@@ -93,7 +94,9 @@ export const ErrorLoader: React.FC<{
});
useEffect(() => {
- getErrorRecord(error)
+ const symbolication = getErrorRecord(error);
+ const unregister = registerCurrentError({error, symbolication});
+ symbolication
.then((record) => {
if (record) {
if (shouldLogError(error)) {
@@ -124,6 +127,8 @@ export const ErrorLoader: React.FC<{
type: 'error',
});
});
+
+ return unregister;
}, [error]);
if (state.type === 'loading') {
diff --git a/packages/studio/src/helpers/document-title.ts b/packages/studio/src/helpers/document-title.ts
index 633e9be7dad..6385f6f20fc 100644
--- a/packages/studio/src/helpers/document-title.ts
+++ b/packages/studio/src/helpers/document-title.ts
@@ -1,8 +1,11 @@
import {NoReactInternals} from 'remotion/no-react';
import type {AnyRenderJob} from '../components/RenderQueue/context';
import {isClientRenderJob} from '../components/RenderQueue/context';
+import type {ModalState} from '../state/modals';
+import {getNavigationWindow} from './url-state';
let currentItemName: string | null = null;
+let currentModal: ModalState | null = null;
let renderJobs: AnyRenderJob[] = [];
export const setCurrentCanvasContentId = (id: string | null) => {
@@ -22,18 +25,28 @@ export const setRenderJobs = (jobs: AnyRenderJob[]) => {
updateTitle();
};
+export const setCurrentModal = (modal: ModalState | null) => {
+ currentModal = modal;
+ updateTitle();
+};
+
const productName = 'Remotion Studio';
const suffix = `- ${productName}`;
const updateTitle = () => {
+ if (currentModal?.type === 'element-install') {
+ getNavigationWindow().document.title = `📦 Install ${currentModal.request.element.displayName} - ${productName}`;
+ return;
+ }
+
if (!currentItemName) {
- document.title = productName;
+ getNavigationWindow().document.title = productName;
return;
}
const currentCompTitle = `${currentItemName} / ${window.remotion_projectName}`;
- document.title = [
+ getNavigationWindow().document.title = [
getProgressInBrackets(currentItemName, renderJobs),
`${currentCompTitle} ${suffix}`,
]
diff --git a/packages/studio/src/helpers/open-in-editor.ts b/packages/studio/src/helpers/open-in-editor.ts
index 0b1d4f20ea6..51dc3cc68d0 100644
--- a/packages/studio/src/helpers/open-in-editor.ts
+++ b/packages/studio/src/helpers/open-in-editor.ts
@@ -6,7 +6,7 @@ import type {
OpenInTerminalResponse,
SymbolicatedStackFrame,
} from '@remotion/studio-shared';
-import {useEffect, useSyncExternalStore} from 'react';
+import {useEffect} from 'react';
import {callApi} from '../components/call-api';
import {getSourceMapFilesForSource} from '../components/Timeline/TimelineStack/get-stack';
import type {
@@ -14,6 +14,7 @@ import type {
OriginalPosition,
} from '../error-overlay/react-overlay/utils/get-source-map';
import {getBrowserStudioOperations} from './browser-studio-operations';
+import {useSyncExternalStore} from './use-sync-external-store';
export const openInEditor = (
stack: SymbolicatedStackFrame,
diff --git a/packages/studio/src/helpers/use-runtime-values.ts b/packages/studio/src/helpers/use-runtime-values.ts
index 956a550bd41..3ce91f2d95a 100644
--- a/packages/studio/src/helpers/use-runtime-values.ts
+++ b/packages/studio/src/helpers/use-runtime-values.ts
@@ -1,5 +1,9 @@
-import {useCallback, useMemo, useSyncExternalStore} from 'react';
-import type {RuntimeValueStore, SequenceRegistrationControls} from 'remotion';
+import {useCallback, useMemo} from 'react';
+import {
+ type RuntimeValueStore,
+ type SequenceRegistrationControls,
+} from 'remotion';
+import {useSyncExternalStore} from './use-sync-external-store';
const EMPTY_RUNTIME_VALUES: Readonly> = {};
const EMPTY_RUNTIME_VALUE_STORE = {
diff --git a/packages/studio/src/helpers/use-sync-external-store.ts b/packages/studio/src/helpers/use-sync-external-store.ts
new file mode 100644
index 00000000000..7f15652aebe
--- /dev/null
+++ b/packages/studio/src/helpers/use-sync-external-store.ts
@@ -0,0 +1,11 @@
+import {Internals} from 'remotion';
+
+type UseSyncExternalStore = (
+ subscribe: (onStoreChange: () => void) => () => void,
+ getSnapshot: () => TSnapshot,
+ getServerSnapshot?: () => TSnapshot,
+) => TSnapshot;
+
+export const {useSyncExternalStore} = Internals as typeof Internals & {
+ useSyncExternalStore: UseSyncExternalStore;
+};
diff --git a/packages/studio/src/state/modals.ts b/packages/studio/src/state/modals.ts
index 0204642e573..3ed3f2eb570 100644
--- a/packages/studio/src/state/modals.ts
+++ b/packages/studio/src/state/modals.ts
@@ -11,7 +11,12 @@ import type {
X264Preset,
} from '@remotion/renderer';
import type {HardwareAccelerationOption} from '@remotion/renderer/client';
-import type {CanvasCaptureData, RenderDefaults} from '@remotion/studio-shared';
+import type {
+ CanvasCaptureData,
+ ElementInstallExpectedFileState,
+ ElementInstallRequest,
+ RenderDefaults,
+} from '@remotion/studio-shared';
import type {
RenderStillOnWebImageFormat,
WebRendererAudioCodec,
@@ -143,6 +148,24 @@ export type CanvasCaptureImport = {
readonly width: number;
};
+export type ElementInstallPlan = {
+ readonly compositionFile: string;
+ readonly filePath: string;
+ readonly expectedFileState: ElementInstallExpectedFileState;
+};
+
+export type ElementInstallModalState = {
+ readonly type: 'element-install';
+ readonly currentPlan: ElementInstallPlan | null;
+ readonly dependenciesToReview: string[];
+ readonly missingPackages: string[];
+ readonly newPlan: ElementInstallPlan;
+ readonly onClose: () => void;
+ readonly request: ElementInstallRequest;
+ readonly sourceIsUnverified: boolean;
+ readonly sourceLabel: string;
+};
+
export type ModalState =
| {
type: 'new-comp';
@@ -232,6 +255,7 @@ export type ModalState =
name: string;
url: string;
}
+ | ElementInstallModalState
| AddEffectModalState
| ConfirmationDialogState
| SvgImportDialogState;
diff --git a/packages/studio/src/state/timeline-sequence-hover.ts b/packages/studio/src/state/timeline-sequence-hover.ts
index f71140d8546..9d9cd57d638 100644
--- a/packages/studio/src/state/timeline-sequence-hover.ts
+++ b/packages/studio/src/state/timeline-sequence-hover.ts
@@ -1,16 +1,11 @@
import type {Dispatch, SetStateAction} from 'react';
-import {
- createContext,
- useCallback,
- useContext,
- useMemo,
- useSyncExternalStore,
-} from 'react';
+import {createContext, useCallback, useContext, useMemo} from 'react';
import type {SequenceNodePathInfo} from '../helpers/get-timeline-sequence-sort-key';
import {
timelineNodePathInfoToKey,
timelineSequenceNodePathToKey,
} from '../helpers/timeline-node-path-key';
+import {useSyncExternalStore} from '../helpers/use-sync-external-store';
export type TimelineSequenceHover = {
readonly key: string;
diff --git a/packages/studio/src/test/composition-drag-data.test.ts b/packages/studio/src/test/composition-drag-data.test.ts
new file mode 100644
index 00000000000..1d1ce48ab13
--- /dev/null
+++ b/packages/studio/src/test/composition-drag-data.test.ts
@@ -0,0 +1,145 @@
+import {expect, test} from 'bun:test';
+import {
+ compositionDragDataToSymbolicatedStack,
+ getCompositionDragPreviewMetadata,
+ makeCompositionDragData,
+ parseCompositionDragData,
+} from '../components/composition-drag-data';
+import {
+ getCompositionDragData,
+ isCompositionDragEvent,
+} from '../components/drop-handler-data';
+
+const constructed = makeCompositionDragData({
+ compositionFile: 'src/Root.tsx',
+ compositionId: 'MyVideo',
+ width: 1920,
+ height: 1080,
+ durationInFrames: 150,
+});
+
+const makeDataTransfer = ({
+ mimeType,
+ payload,
+}: {
+ mimeType: string;
+ payload: string;
+}) => ({
+ types: ['text/plain', mimeType],
+ getData: (requestedMimeType: string) =>
+ requestedMimeType === mimeType ? payload : '',
+});
+
+test('constructs and parses Studio composition drag data', () => {
+ expect(constructed.mimeType).toBe(
+ 'application/vnd.remotion.drag+json;v=1;type=composition;width=1920;height=1080;duration=150',
+ );
+ expect(getCompositionDragPreviewMetadata(['text/plain'])).toBe(null);
+ expect(
+ getCompositionDragPreviewMetadata(['text/plain', constructed.mimeType]),
+ ).toEqual({
+ type: 'composition',
+ mimeType: constructed.mimeType,
+ width: 1920,
+ height: 1080,
+ durationInFrames: 150,
+ });
+ expect(parseCompositionDragData(makeDataTransfer(constructed))).toEqual({
+ type: 'remotion-composition',
+ version: 1,
+ compositionFile: 'src/Root.tsx',
+ compositionId: 'MyVideo',
+ });
+
+ const dragEvent = {
+ dataTransfer: makeDataTransfer(constructed),
+ } as unknown as DragEvent;
+ expect(isCompositionDragEvent(dragEvent)).toBe(true);
+ expect(getCompositionDragData(dragEvent)).toEqual(constructed.data);
+});
+
+test('preserves unresolved composition metadata', () => {
+ const unresolved = makeCompositionDragData({
+ compositionFile: null,
+ compositionId: 'UnresolvedVideo',
+ width: null,
+ height: null,
+ durationInFrames: null,
+ });
+
+ expect(unresolved.mimeType).toBe(
+ 'application/vnd.remotion.drag+json;v=1;type=composition',
+ );
+ expect(getCompositionDragPreviewMetadata([unresolved.mimeType])).toEqual({
+ type: 'composition',
+ mimeType: unresolved.mimeType,
+ width: null,
+ height: null,
+ durationInFrames: null,
+ });
+ expect(parseCompositionDragData(unresolved)?.compositionFile).toBe(null);
+});
+
+test('rejects malformed composition drag data', () => {
+ const malformedMimeTypes = [
+ 'application/vnd.remotion.drag+json;v=1;type=composition;width=1920',
+ 'application/vnd.remotion.drag+json;v=1;type=composition;type=composition',
+ 'application/vnd.remotion.drag+json;v=1;type=composition;unknown=value',
+ 'application/vnd.remotion.drag+json;v=1;type=composition;duration=1.5',
+ `${'application/vnd.remotion.drag+json;v=1;type=composition;'}${'x'.repeat(512)}`,
+ ];
+ for (const mimeType of malformedMimeTypes) {
+ expect(getCompositionDragPreviewMetadata([mimeType])).toBe(null);
+ }
+
+ for (const payload of [
+ '',
+ '{',
+ JSON.stringify({
+ type: 'remotion-composition',
+ version: 1,
+ compositionFile: '/tmp/Root.tsx',
+ compositionId: 'MyVideo',
+ }),
+ JSON.stringify({
+ type: 'remotion-composition',
+ version: 1,
+ compositionFile: '../Root.tsx',
+ compositionId: 'MyVideo',
+ }),
+ JSON.stringify({
+ type: 'remotion-composition',
+ version: 1,
+ compositionFile: 'src/Root.tsx',
+ compositionId: 'Invalid id',
+ }),
+ ]) {
+ expect(
+ parseCompositionDragData({
+ mimeType: constructed.mimeType,
+ payload,
+ }),
+ ).toBe(null);
+ }
+});
+
+test('converts composition source locations to symbolicated stacks', () => {
+ expect(compositionDragDataToSymbolicatedStack(constructed.data)).toEqual({
+ originalColumnNumber: null,
+ originalFileName: 'src/Root.tsx',
+ originalFunctionName: null,
+ originalLineNumber: null,
+ originalScriptCode: null,
+ });
+ expect(
+ compositionDragDataToSymbolicatedStack(
+ makeCompositionDragData({
+ compositionFile: null,
+ compositionId: 'MyVideo',
+ width: null,
+ height: null,
+ durationInFrames: null,
+ }).data,
+ ),
+ ).toBe(null);
+});
diff --git a/packages/studio/src/test/effect-drag-and-drop.test.ts b/packages/studio/src/test/effect-drag-and-drop.test.ts
index 65b68803038..7eb774afd0f 100644
--- a/packages/studio/src/test/effect-drag-and-drop.test.ts
+++ b/packages/studio/src/test/effect-drag-and-drop.test.ts
@@ -3,14 +3,16 @@ import {
StudioProtocolInternals,
type MakeDragDataInput,
} from '@remotion/studio-protocol';
+import {makeCompositionDragData} from '../components/composition-drag-data';
import {
getEffectDragData,
hasEffectDragType,
} from '../components/effect-drag-and-drop';
-const makeDataTransfer = (
- constructed: ReturnType,
-): DataTransfer => {
+const makeDataTransfer = (constructed: {
+ readonly mimeType: string;
+ readonly payload: string;
+}): DataTransfer => {
return {
types: [constructed.mimeType],
getData: (type: string) =>
@@ -46,14 +48,6 @@ const nonEffectInputs: MakeDragDataInput[] = [
importPath: '@remotion/shapes',
props: [],
},
- {
- type: 'composition',
- compositionFile: null,
- compositionId: 'Comp',
- width: null,
- height: null,
- durationInFrames: null,
- },
{
type: 'element',
dependencies: [],
@@ -76,6 +70,18 @@ for (const input of nonEffectInputs) {
});
}
+test('does not treat composition drags as effect drags', () => {
+ const composition = makeCompositionDragData({
+ compositionFile: null,
+ compositionId: 'Comp',
+ width: null,
+ height: null,
+ durationInFrames: null,
+ });
+
+ expect(hasEffectDragType(makeDataTransfer(composition))).toBe(false);
+});
+
test('does not treat remote asset imports as effect drags', () => {
expect(
hasEffectDragType({
diff --git a/packages/studio/src/test/quick-switcher-composition-search.test.ts b/packages/studio/src/test/quick-switcher-composition-search.test.ts
new file mode 100644
index 00000000000..faae343ffbf
--- /dev/null
+++ b/packages/studio/src/test/quick-switcher-composition-search.test.ts
@@ -0,0 +1,80 @@
+import {expect, test} from 'bun:test';
+import type {_InternalTypes} from 'remotion';
+import type {CompositionSelectorItemType} from '../components/CompositionSelectorItem';
+import {searchCompositionTree} from '../components/QuickSwitcher/composition-search';
+
+const composition = (
+ id: string,
+ folderName: string | null,
+ parentFolderName: string | null,
+): CompositionSelectorItemType => {
+ return {
+ type: 'composition',
+ key: id,
+ composition: {
+ id,
+ folderName,
+ parentFolderName,
+ } as _InternalTypes['AnyComposition'],
+ };
+};
+
+const tree: CompositionSelectorItemType[] = [
+ {
+ type: 'folder',
+ key: 'marketing',
+ folder: {} as _InternalTypes['TFolder'],
+ folderName: 'Marketing',
+ parentName: null,
+ expanded: false,
+ items: [
+ composition('LaunchTrailer', 'Marketing', null),
+ composition('Credits', 'Marketing', null),
+ {
+ type: 'folder',
+ key: 'social',
+ folder: {} as _InternalTypes['TFolder'],
+ folderName: 'Social',
+ parentName: 'Marketing',
+ expanded: false,
+ items: [composition('SquareCut', 'Social', 'Marketing')],
+ },
+ ],
+ },
+ composition('LaunchOverview', null, null),
+];
+
+const summarize = (query: string) => {
+ return searchCompositionTree({items: tree, query}).map((result) => {
+ return result.type === 'folder'
+ ? `folder:${result.level}:${result.title}`
+ : `composition:${result.level}:${result.composition.id}`;
+ });
+};
+
+test('searches compositions as a ranked, pruned folder tree', () => {
+ expect(summarize('LaunchTrailer')).toEqual([
+ 'folder:0:Marketing',
+ 'composition:1:LaunchTrailer',
+ ]);
+
+ expect(summarize('Marketing')).toEqual([
+ 'folder:0:Marketing',
+ 'composition:1:LaunchTrailer',
+ 'composition:1:Credits',
+ 'folder:1:Social',
+ 'composition:2:SquareCut',
+ ]);
+
+ expect(summarize('Launch')).toEqual([
+ 'folder:0:Marketing',
+ 'composition:1:LaunchTrailer',
+ 'composition:0:LaunchOverview',
+ ]);
+
+ expect(summarize('Marketing Square')).toEqual([
+ 'folder:0:Marketing',
+ 'folder:1:Social',
+ 'composition:2:SquareCut',
+ ]);
+});
diff --git a/packages/template-music-visualization/package.json b/packages/template-music-visualization/package.json
index 9407a128096..6cd256b5d73 100644
--- a/packages/template-music-visualization/package.json
+++ b/packages/template-music-visualization/package.json
@@ -17,7 +17,7 @@
"@remotion/media-utils": "workspace:*",
"@remotion/google-fonts": "workspace:*",
"@remotion/zod-types": "workspace:*",
- "mediabunny": "1.55.1",
+ "mediabunny": "1.55.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"remotion": "workspace:*",
diff --git a/packages/template-recorder/package.json b/packages/template-recorder/package.json
index df6470623e7..c540f94ec72 100644
--- a/packages/template-recorder/package.json
+++ b/packages/template-recorder/package.json
@@ -39,7 +39,7 @@
"connect": "3.7.0",
"eslint": "9.19.0",
"lucide-react": "0.516.0",
- "mediabunny": "1.55.1",
+ "mediabunny": "1.55.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "1.14.0",
diff --git a/packages/template-three/package.json b/packages/template-three/package.json
index f13b091c210..1a387874ab5 100644
--- a/packages/template-three/package.json
+++ b/packages/template-three/package.json
@@ -21,7 +21,7 @@
"remotion": "workspace:*",
"three": "0.178.0",
"zod": "4.4.3",
- "mediabunny": "1.55.1"
+ "mediabunny": "1.55.4"
},
"devDependencies": {
"@remotion/eslint-config-flat": "workspace:*",
diff --git a/packages/test-utils/src/timeline-context.ts b/packages/test-utils/src/timeline-context.ts
index 87be200f7a1..a1ddcdfe5a1 100644
--- a/packages/test-utils/src/timeline-context.ts
+++ b/packages/test-utils/src/timeline-context.ts
@@ -6,10 +6,7 @@ export const makeTimelineContext = (frame: number): TimelineContextValue => {
frame: {
[ID]: frame,
},
- playing: false,
- imperativePlaying: {
- current: false,
- },
+ isPlaying: () => false,
audioAndVideoTags: {current: []},
};
};
diff --git a/packages/transitions/src/html-in-canvas-presentation.tsx b/packages/transitions/src/html-in-canvas-presentation.tsx
index c977c0c8cd7..fe94431c1ad 100644
--- a/packages/transitions/src/html-in-canvas-presentation.tsx
+++ b/packages/transitions/src/html-in-canvas-presentation.tsx
@@ -103,7 +103,7 @@ export const HtmlInCanvasPresentation = <
}, [instance]);
const chainState = Internals.useEffectChainState();
- const {delayRender, continueRender} = useDelayRender();
+ const {delayRender, continueRender, cancelRender} = useDelayRender();
const draw: DrawFunction = useCallback(
async (prevImage, nextImage, progress) => {
@@ -113,55 +113,66 @@ export const HtmlInCanvasPresentation = <
}
const handle = delayRender('onPaint');
- const clearOutput = () => {
- const context = outputCanvas.getContext('2d');
- if (!context) {
- throw new Error('Failed to create output canvas context');
+ try {
+ const clearOutput = () => {
+ const context = outputCanvas.getContext('2d');
+ if (!context) {
+ throw new Error('Failed to create output canvas context');
+ }
+
+ context.clearRect(0, 0, outputCanvas.width, outputCanvas.height);
+ };
+
+ if (!prevImage && !nextImage) {
+ instance.clear();
+ clearOutput();
+ continueRender(handle);
+ return;
}
- context.clearRect(0, 0, outputCanvas.width, outputCanvas.height);
- };
+ const width = prevImage?.width ?? nextImage?.width ?? 0;
+ const height = prevImage?.height ?? nextImage?.height ?? 0;
- if (!prevImage && !nextImage) {
- instance.clear();
- clearOutput();
- continueRender(handle);
- return;
- }
-
- const width = prevImage?.width ?? nextImage?.width ?? 0;
- const height = prevImage?.height ?? nextImage?.height ?? 0;
+ if (width === 0 || height === 0) {
+ instance.clear();
+ clearOutput();
+ continueRender(handle);
+ return;
+ }
- if (width === 0 || height === 0) {
- instance.clear();
- clearOutput();
+ shaderCanvas.width = width;
+ shaderCanvas.height = height;
+
+ instance.draw({
+ prevImage,
+ nextImage,
+ width,
+ height,
+ time: progress,
+ passedProps: passedPropsRef.current,
+ });
+
+ await Internals.runEffectChain({
+ state: chainState.get(width, height)!,
+ source: shaderCanvas,
+ effects: effectsRef.current ?? [],
+ width,
+ height,
+ output: outputCanvas,
+ });
continueRender(handle);
- return;
+ } catch (error) {
+ cancelRender(error);
}
-
- shaderCanvas.width = width;
- shaderCanvas.height = height;
-
- instance.draw({
- prevImage,
- nextImage,
- width,
- height,
- time: progress,
- passedProps: passedPropsRef.current,
- });
-
- await Internals.runEffectChain({
- state: chainState.get(width, height)!,
- source: shaderCanvas,
- effects: effectsRef.current ?? [],
- width,
- height,
- output: outputCanvas,
- });
- continueRender(handle);
},
- [chainState, continueRender, delayRender, instance, shaderCanvas],
+ [
+ cancelRender,
+ chainState,
+ continueRender,
+ delayRender,
+ instance,
+ shaderCanvas,
+ ],
);
const passThrough =
diff --git a/packages/transitions/src/test/transition-series-stack.test.tsx b/packages/transitions/src/test/transition-series-stack.test.tsx
index eaf3cea390c..1285887281c 100644
--- a/packages/transitions/src/test/transition-series-stack.test.tsx
+++ b/packages/transitions/src/test/transition-series-stack.test.tsx
@@ -73,6 +73,7 @@ const SequenceTestWrapper: React.FC<{
() => ({
registerSequence,
unregisterSequence: () => undefined,
+ updateSequence: registerSequence,
sequences: [],
}),
[registerSequence],