Dimensions
diff --git a/packages/example/e2e/captions-inspector.test.mts b/packages/example/e2e/captions-inspector.test.mts
index 1aae3ad8366..48156f2638c 100644
--- a/packages/example/e2e/captions-inspector.test.mts
+++ b/packages/example/e2e/captions-inspector.test.mts
@@ -100,17 +100,78 @@ test.describe('captions inspector', () => {
}).toPass({timeout: 30_000});
await expect(defaultCaption).toBeEnabled();
- await defaultCaption.fill('Editable captions');
+ const importCaptionsButton = page.getByRole('button', {
+ name: 'Import',
+ exact: true,
+ });
+ const importCaptionsInput = page.getByLabel('Import captions file');
+ await expect(importCaptionsButton).toBeVisible();
+
+ const sourceBeforeFailedImport = fs.readFileSync(
+ elementCaptionsFile,
+ 'utf-8',
+ );
+ await importCaptionsInput.setInputFiles({
+ name: 'broken.json',
+ mimeType: 'application/json',
+ buffer: Buffer.from(
+ JSON.stringify([
+ {
+ text: 'Broken',
+ endMs: 1000,
+ timestampMs: 500,
+ confidence: null,
+ },
+ ]),
+ ),
+ });
+ await expect(
+ page.getByText(
+ /broken\.json:.*captions\[0\]\.startMs must be a finite, non-negative number/,
+ ),
+ ).toBeVisible();
+ expect(fs.readFileSync(elementCaptionsFile, 'utf-8')).toBe(
+ sourceBeforeFailedImport,
+ );
+
+ await importCaptionsInput.setInputFiles({
+ name: 'captions.json',
+ mimeType: 'application/json',
+ buffer: Buffer.from(
+ JSON.stringify([
+ {
+ text: 'Imported',
+ startMs: 100,
+ endMs: 400,
+ timestampMs: 250,
+ confidence: null,
+ },
+ {
+ text: ' captions',
+ startMs: 400,
+ endMs: 900,
+ timestampMs: 650,
+ confidence: null,
+ },
+ ]),
+ ),
+ });
+ await expect(defaultCaption).toHaveValue('Imported');
+ await expect
+ .poll(() => fs.readFileSync(elementCaptionsFile, 'utf-8'))
+ .toMatch(/text:\s*['"]Imported['"][\s\S]*startMs:\s*100/);
+ expect(fs.readFileSync(elementCallSiteFile, 'utf-8')).toBe(
+ elementCallSiteSourceBefore,
+ );
+
+ await defaultCaption.fill('Edited imported caption');
await defaultCaption.blur();
await expect
.poll(() => {
- return /text:\s*['"]Editable captions['"]/.test(
+ return /text:\s*['"]Edited imported caption['"]/.test(
fs.readFileSync(elementCaptionsFile, 'utf-8'),
);
})
.toBe(true);
- expect(fs.readFileSync(elementCallSiteFile, 'utf-8')).toBe(
- elementCallSiteSourceBefore,
- );
});
});
diff --git a/packages/example/e2e/studio-protocol.test.mts b/packages/example/e2e/studio-protocol.test.mts
index 00c1db8d86a..ab1d2a52623 100644
--- a/packages/example/e2e/studio-protocol.test.mts
+++ b/packages/example/e2e/studio-protocol.test.mts
@@ -222,7 +222,7 @@ const CloseupPlaceholder = () => {
}
});
await context.route(
- 'https://www.remotion.dev/elements?remotion-studio=true',
+ 'https://www.remotion.dev/elements?remotion-studio=true&docusaurus-theme=dark',
async (route) => {
officialLibraryRequests.push(route.request().url());
await route.fulfill({
@@ -232,7 +232,7 @@ const CloseupPlaceholder = () => {
},
);
await context.route(
- `${externalLibraryUrl}?remotion-studio=true`,
+ `${externalLibraryUrl}?remotion-studio=true&docusaurus-theme=dark`,
async (route) => {
externalLibraryRequests.push(route.request().url());
await route.fulfill({
@@ -311,7 +311,7 @@ const CloseupPlaceholder = () => {
);
await expect(officialElementsIframe).toBeVisible();
expect(officialLibraryRequests).toEqual([
- 'https://www.remotion.dev/elements?remotion-studio=true',
+ 'https://www.remotion.dev/elements?remotion-studio=true&docusaurus-theme=dark',
]);
expect(context.pages()).toHaveLength(2);
await studioPage.keyboard.press('Escape');
@@ -339,7 +339,7 @@ const CloseupPlaceholder = () => {
);
await expect(elementsIframe).toHaveAttribute('credentialless', '');
expect(externalLibraryRequests).toEqual([
- `${externalLibraryUrl}?remotion-studio=true`,
+ `${externalLibraryUrl}?remotion-studio=true&docusaurus-theme=dark`,
]);
expect(context.pages()).toHaveLength(2);
const elementsFrame = studioPage.frameLocator(
@@ -377,6 +377,14 @@ const CloseupPlaceholder = () => {
await expect(elementsIframe).toHaveCount(0);
expect(studioProtocolRequests).toEqual([]);
await dialog.getByRole('button', {name: /Install/}).click();
+ await expect(
+ studioPage
+ .getByRole('group', {name: 'Inspector source location'})
+ .first(),
+ ).toContainText('Protocol Element', {timeout: 30_000});
+ await expect(
+ studioPage.getByText('Installed Protocol Element', {exact: true}),
+ ).toBeVisible();
const elementFile = path.join(
temporaryProject,
@@ -502,9 +510,15 @@ const CloseupPlaceholder = () => {
await expect(studioPage).toHaveURL(/ProtocolElementScene/, {
timeout: 30_000,
});
+ await expect(
+ studioPage
+ .getByRole('group', {name: 'Inspector source location'})
+ .first(),
+ ).toContainText('Protocol Element', {timeout: 30_000});
await studioPage.bringToFront();
- await studioPage.mouse.click(500, 300);
+ await studioPage.keyboard.press('Escape');
+ await expect(browseElements).toBeVisible();
await expect
.poll(() =>
fetch(`${studioUrl}/api/studio-protocol`, {
diff --git a/packages/example/src/mirrored-spectrum.element.tsx b/packages/example/src/mirrored-spectrum.element.tsx
index e9632dd0d4b..dad57419138 100644
--- a/packages/example/src/mirrored-spectrum.element.tsx
+++ b/packages/example/src/mirrored-spectrum.element.tsx
@@ -24,6 +24,7 @@ const mirroredAudioSpectrumSchema = {
...Interactive.baseSchema,
audioSrc: {
type: 'asset',
+ assetType: 'audio',
default:
'https://remotion.media/elements/remotion-made-this-picture-move.mp3',
description: 'Audio source',
diff --git a/packages/example/src/oscilloscope.element.tsx b/packages/example/src/oscilloscope.element.tsx
index c11fcab332b..7fbb6c72a89 100644
--- a/packages/example/src/oscilloscope.element.tsx
+++ b/packages/example/src/oscilloscope.element.tsx
@@ -29,6 +29,7 @@ const audioOscilloscopeSchema = {
...Interactive.baseSchema,
audioSrc: {
type: 'asset',
+ assetType: 'audio',
default:
'https://remotion.media/elements/remotion-made-this-picture-move.mp3',
description: 'Audio source',
diff --git a/packages/media/src/audio/audio.tsx b/packages/media/src/audio/audio.tsx
index 102adcf324f..9f112992ce7 100644
--- a/packages/media/src/audio/audio.tsx
+++ b/packages/media/src/audio/audio.tsx
@@ -20,6 +20,7 @@ const {validateMediaProps} = Internals;
export const audioSchema: InteractivitySchema = {
src: {
type: 'asset',
+ assetType: 'audio',
default: undefined,
description: 'Source',
keyframable: false,
diff --git a/packages/media/src/video/video.tsx b/packages/media/src/video/video.tsx
index b1ec516317f..9046bf97ff1 100644
--- a/packages/media/src/video/video.tsx
+++ b/packages/media/src/video/video.tsx
@@ -26,6 +26,7 @@ const {
export const videoSchema: InteractivitySchema = {
src: {
type: 'asset',
+ assetType: 'video',
default: undefined,
description: 'Source',
keyframable: false,
diff --git a/packages/studio/src/components/Canvas.tsx b/packages/studio/src/components/Canvas.tsx
index f41e4819bfe..fa2215cc320 100644
--- a/packages/studio/src/components/Canvas.tsx
+++ b/packages/studio/src/components/Canvas.tsx
@@ -74,6 +74,7 @@ import {
} from './element-install-request';
import {handleDrop} from './handle-drop';
import {
+ getElementPositionForDrop,
getFromForDrop,
hasSvgFile,
importAssets,
@@ -925,23 +926,37 @@ export const Canvas: React.FC<{
useEffect(() => {
return subscribeToElementInstallRequests((request) => {
- const requestWithFrom =
- request.source.type === 'drag-and-drop' || request.from !== null
- ? request
- : {
- ...request,
- from: getFromForDrop({
+ const isDragAndDrop = request.source.type === 'drag-and-drop';
+ const requestWithDefaults = isDragAndDrop
+ ? request
+ : {
+ ...request,
+ from:
+ request.from ??
+ getFromForDrop({
durationInFrames: request.element.durationInFrames,
from: getCurrentFrame(),
preferCompositionStart: true,
}),
- };
+ position:
+ request.position ??
+ getElementPositionForDrop({
+ dimensions: request.element.dimensions,
+ dropPosition:
+ contentDimensions === null || contentDimensions === 'none'
+ ? null
+ : {
+ centerX: contentDimensions.width / 2,
+ centerY: contentDimensions.height / 2,
+ },
+ }),
+ };
setPendingElementInstallRequests((requests) => [
...requests,
- requestWithFrom,
+ requestWithDefaults,
]);
});
- }, []);
+ }, [contentDimensions]);
useEffect(() => {
if (
diff --git a/packages/studio/src/components/CaptionInspector.tsx b/packages/studio/src/components/CaptionInspector.tsx
index f312e7111bd..162ca36fcac 100644
--- a/packages/studio/src/components/CaptionInspector.tsx
+++ b/packages/studio/src/components/CaptionInspector.tsx
@@ -1,10 +1,18 @@
import type {Caption} from '@remotion/captions';
-import React from 'react';
-import {LIGHT_TEXT} from '../helpers/colors';
+import React, {useCallback, useRef, useState} from 'react';
+import {CURRENT_COLOR, LIGHT_TEXT} from '../helpers/colors';
+import {UploadIcon} from '../icons/upload';
+import {Button} from './Button';
import {CaptionTextEditor} from './CaptionTextEditor';
import {CollapsibleInspectorSectionHeader} from './InspectorPanel/CollapsibleInspectorSectionHeader';
import {InspectorSectionHeader} from './InspectorPanel/common';
import {sectionHeaderEnd} from './InspectorPanel/styles';
+import {showNotification} from './Notifications/NotificationCenter';
+import {parseCaptionFile} from './parse-caption-file';
+
+const importTooltip = `Import captions
+
+Supports Remotion Caption[] JSON. Files are processed locally.`;
const readOnlyStatus: React.CSSProperties = {
color: LIGHT_TEXT,
@@ -21,6 +29,7 @@ export const CaptionInspector: React.FC<{
readonly onTextChange: (captions: Caption[]) => void;
readonly onTextSave: ((captions: Caption[]) => void) | null;
readonly onTextCancel: (() => void) | null;
+ readonly onReplaceCaptions: ((captions: Caption[]) => void) | null;
readonly onToggle: () => void;
readonly readOnly: boolean;
readonly readOnlyTitle: string | null;
@@ -30,16 +39,79 @@ export const CaptionInspector: React.FC<{
onTextChange,
onTextSave,
onTextCancel,
+ onReplaceCaptions,
onToggle,
readOnly,
readOnlyTitle,
}) => {
+ const fileInput = useRef
(null);
+ const [isImporting, setIsImporting] = useState(false);
+
+ const importCaptions = useCallback(
+ async (event: React.ChangeEvent) => {
+ const file = event.currentTarget.files?.[0];
+ event.currentTarget.value = '';
+ if (!file || onReplaceCaptions === null) {
+ return;
+ }
+
+ setIsImporting(true);
+ try {
+ onReplaceCaptions(
+ parseCaptionFile({
+ fileName: file.name,
+ contents: await file.text(),
+ }),
+ );
+ } catch (error) {
+ showNotification(
+ `Could not import ${file.name}: ${error instanceof Error ? error.message : String(error)}`,
+ 5000,
+ );
+ } finally {
+ setIsImporting(false);
+ }
+ },
+ [onReplaceCaptions],
+ );
+
return (
<>
+ {onReplaceCaptions === null ? null : (
+ <>
+
+
+ >
+ )}
{readOnly ? (
Read only
diff --git a/packages/studio/src/components/ElementLibraryModal.tsx b/packages/studio/src/components/ElementLibraryModal.tsx
index 6fc9969e09f..4dfc4787326 100644
--- a/packages/studio/src/components/ElementLibraryModal.tsx
+++ b/packages/studio/src/components/ElementLibraryModal.tsx
@@ -13,6 +13,7 @@ const panelStyle: React.CSSProperties = {
const iframeStyle: React.CSSProperties = {
border: 0,
+ colorScheme: 'dark',
flex: 1,
minHeight: 0,
width: '100%',
@@ -35,6 +36,7 @@ export const ElementLibraryModal: React.FC<{
iframe.setAttribute('credentialless', '');
const iframeUrl = new URL(url);
iframeUrl.searchParams.set('remotion-studio', 'true');
+ iframeUrl.searchParams.set('docusaurus-theme', 'dark');
iframe.src = iframeUrl.toString();
}, [url]);
diff --git a/packages/studio/src/components/InlineCaptionInspector.tsx b/packages/studio/src/components/InlineCaptionInspector.tsx
index 2591d1fc1ec..28a5f8c87e4 100644
--- a/packages/studio/src/components/InlineCaptionInspector.tsx
+++ b/packages/studio/src/components/InlineCaptionInspector.tsx
@@ -15,7 +15,10 @@ import {Internals} from 'remotion';
import type {CodePosition} from '../error-overlay/react-overlay/utils/get-source-map';
import {StudioServerConnectionCtx} from '../helpers/client-id';
import {CaptionInspector} from './CaptionInspector';
-import {saveInlineCaptionPatches} from './Timeline/save-sequence-prop';
+import {
+ saveInlineCaptionPatches,
+ saveSequenceProps,
+} from './Timeline/save-sequence-prop';
const serializeCaptions = (captions: Caption[]): string => {
return JSON.stringify(captions);
@@ -171,6 +174,51 @@ export const InlineCaptionInspector: React.FC<{
],
);
+ const replaceCaptions = useCallback(
+ (nextCaptions: Caption[]) => {
+ if (!canSave || clientId === null) {
+ return;
+ }
+
+ setDraftCaptions(nextCaptions);
+ setDragOverrides(
+ nodePath,
+ 'captions',
+ Internals.makeStaticDragOverride(nextCaptions),
+ );
+ savedCaptions.current = nextCaptions;
+ saveSequenceProps({
+ changes: [
+ {
+ fileName: validatedLocation.source,
+ nodePath,
+ fieldKey: 'captions',
+ value: nextCaptions,
+ defaultValue: null,
+ schema: controls.schema,
+ },
+ ],
+ addedKeyframes: null,
+ movedKeyframes: null,
+ setPropStatuses,
+ clientId,
+ undoLabel: 'Import captions',
+ redoLabel: 'Import captions again',
+ });
+ clearDragOverrides(nodePath);
+ },
+ [
+ canSave,
+ clearDragOverrides,
+ clientId,
+ controls.schema,
+ nodePath,
+ setDragOverrides,
+ setPropStatuses,
+ validatedLocation.source,
+ ],
+ );
+
const readOnlyTitle = readOnlyStudio
? 'Caption editing is unavailable in read-only Studio'
: clientId === null
@@ -189,6 +237,7 @@ export const InlineCaptionInspector: React.FC<{
onToggle={onToggle}
readOnly={!canSave}
readOnlyTitle={canSave ? null : readOnlyTitle}
+ onReplaceCaptions={canSave ? replaceCaptions : null}
/>
);
};
diff --git a/packages/studio/src/components/InspectorSequenceSection.tsx b/packages/studio/src/components/InspectorSequenceSection.tsx
index 2c821bb69ba..4fbacc21486 100644
--- a/packages/studio/src/components/InspectorSequenceSection.tsx
+++ b/packages/studio/src/components/InspectorSequenceSection.tsx
@@ -36,7 +36,6 @@ import {
isSmartCollapsibleInspectorGroup,
} from './InspectorPanel/inspector-section-collapse';
import {sectionHeaderRow, sectionHeaderTitle} from './InspectorPanel/styles';
-import {getAssetSearchQueryForComponent} from './QuickSwitcher/asset-search';
import {
BORDER_RADIUS_SHORTHAND_KEY,
getBorderRadiusConversion,
@@ -355,9 +354,6 @@ export const InspectorSequenceSection: React.FC<{
sequence.controls,
runtimeValues,
);
- const assetSelectionInitialQuery = getAssetSearchQueryForComponent(
- sequence.controls.componentIdentity,
- );
const getSourceAction = useCallback(
(src: string): InspectorSourceAction | null => {
const linkInfo = getTimelineAssetLinkInfo(src);
@@ -406,10 +402,9 @@ export const InspectorSequenceSection: React.FC<{
const assetSelectionContextValue = useMemo(
() => ({
getSourceAction,
- initialQuery: assetSelectionInitialQuery,
sourceAction,
}),
- [assetSelectionInitialQuery, getSourceAction, sourceAction],
+ [getSourceAction, sourceAction],
);
const getIsExpanded = useCallback(
diff --git a/packages/studio/src/components/QuickSwitcher/asset-search.ts b/packages/studio/src/components/QuickSwitcher/asset-search.ts
index f0f0ffad747..e78f47e6f9f 100644
--- a/packages/studio/src/components/QuickSwitcher/asset-search.ts
+++ b/packages/studio/src/components/QuickSwitcher/asset-search.ts
@@ -1,8 +1,5 @@
import type {StaticFile} from '../../api/get-static-files';
-import {
- getPreviewFileType,
- type AssetFileType,
-} from '../../helpers/get-preview-file-type';
+import {getPreviewFileType} from '../../helpers/get-preview-file-type';
const typeFilterRegex = /(^|\s)type:([^\s]+)/gi;
@@ -39,22 +36,3 @@ export const filterAssetsByType = ({
query: queryWithoutTypeFilters,
};
};
-
-const componentAssetTypes: Record
= {
- 'dev.remotion.media.Audio': 'audio',
- 'dev.remotion.media.Video': 'video',
- 'dev.remotion.remotion.AnimatedImage': 'image',
- 'dev.remotion.remotion.CanvasImage': 'image',
- 'dev.remotion.remotion.Img': 'image',
-};
-
-export const getAssetSearchQueryForComponent = (
- componentIdentity: string | null,
-): string => {
- if (componentIdentity === null) {
- return '';
- }
-
- const assetType = componentAssetTypes[componentIdentity];
- return assetType ? `type:${assetType} ` : '';
-};
diff --git a/packages/studio/src/components/Timeline/Timeline.tsx b/packages/studio/src/components/Timeline/Timeline.tsx
index 26928cf183d..13b4948b579 100644
--- a/packages/studio/src/components/Timeline/Timeline.tsx
+++ b/packages/studio/src/components/Timeline/Timeline.tsx
@@ -368,10 +368,13 @@ const TimelineInner: React.FC = () => {
const matchesInsertedNodePath = (track: TimelineTrackData) =>
track.nodePathInfo !== null &&
- track.nodePathInfo.sequenceSubscriptionKey.absolutePath ===
- pendingInsertedElementSelection.nodePath.absolutePath &&
- JSON.stringify(track.nodePathInfo.sequenceSubscriptionKey.nodePath) ===
- JSON.stringify(pendingInsertedElementSelection.nodePath.nodePath);
+ (pendingInsertedElementSelection.nodePath === null ||
+ (track.nodePathInfo.sequenceSubscriptionKey.absolutePath ===
+ pendingInsertedElementSelection.nodePath.absolutePath &&
+ JSON.stringify(
+ track.nodePathInfo.sequenceSubscriptionKey.nodePath,
+ ) ===
+ JSON.stringify(pendingInsertedElementSelection.nodePath.nodePath)));
if (
pendingSelectionStart.current?.selection !==
@@ -418,6 +421,9 @@ const TimelineInner: React.FC = () => {
{reveal: true},
);
clearInsertedElementSelection(pendingInsertedElementSelection);
+ if (pendingInsertedElementSelection.notification !== null) {
+ showNotification(pendingInsertedElementSelection.notification, 3000);
+ }
}, [
canvasContent,
fastRefreshes,
diff --git a/packages/studio/src/components/Timeline/TimelineAssetField.tsx b/packages/studio/src/components/Timeline/TimelineAssetField.tsx
index ea851ca4258..26180110987 100644
--- a/packages/studio/src/components/Timeline/TimelineAssetField.tsx
+++ b/packages/studio/src/components/Timeline/TimelineAssetField.tsx
@@ -40,17 +40,21 @@ const standaloneSourceActionStyle: React.CSSProperties = {
width: 'auto',
};
+const assetTypeToAccept = {
+ audio: 'audio/*',
+ video: 'video/*',
+ image: 'image/*',
+} as const;
+
export type InspectorSourceAction = InspectorQuickActionProps;
type AssetSelectionContextValue = {
- readonly initialQuery: string;
readonly getSourceAction: (src: string) => InspectorSourceAction | null;
readonly sourceAction: InspectorSourceAction | null;
};
export const AssetSelectionContext = createContext({
getSourceAction: () => null,
- initialQuery: '',
sourceAction: null,
});
@@ -84,9 +88,9 @@ export const TimelineAssetField: React.FC = ({
const {setSelectedModal} = useContext(SetSelectedModalContext);
const staticFiles = useStaticFiles();
- const {getSourceAction, initialQuery, sourceAction} = useContext(
- AssetSelectionContext,
- );
+ const {getSourceAction, sourceAction} = useContext(AssetSelectionContext);
+ const {assetType} = field.fieldSchema;
+ const initialQuery = assetType ? `type:${assetType} ` : '';
const inlineSourceAction = useMemo(() => {
if (typeof effectiveValue === 'string') {
return getSourceAction(effectiveValue);
@@ -111,7 +115,10 @@ export const TimelineAssetField: React.FC = ({
);
const selectFile = useCallback(async () => {
- const [file] = await pickFilesToImport({multiple: false});
+ const [file] = await pickFilesToImport({
+ multiple: false,
+ accept: assetType ? assetTypeToAccept[assetType] : null,
+ });
if (!file) {
return;
}
@@ -147,7 +154,7 @@ export const TimelineAssetField: React.FC = ({
4000,
);
}
- }, [onSelect, staticFiles]);
+ }, [assetType, onSelect, staticFiles]);
const openAssetSelection = useCallback(() => {
setSelectedModal({
diff --git a/packages/studio/src/components/import-assets.ts b/packages/studio/src/components/import-assets.ts
index 9b6b5425e10..d6cf56d1718 100644
--- a/packages/studio/src/components/import-assets.ts
+++ b/packages/studio/src/components/import-assets.ts
@@ -616,15 +616,23 @@ const getAssetElementFromStaticAsset = async (
return getAssetElementFromPath(assetPath);
};
-export const pickFilesToImport = ({
- multiple = true,
-}: {
- readonly multiple?: boolean;
-} = {}): Promise => {
+export const pickFilesToImport = (
+ {
+ multiple = true,
+ accept,
+ }: {
+ readonly multiple?: boolean;
+ readonly accept: string | null;
+ } = {accept: null},
+): Promise => {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = multiple;
+ if (accept !== null) {
+ input.accept = accept;
+ }
+
input.style.display = 'none';
let didResolve = false;
@@ -697,6 +705,7 @@ const insertCompositionElement = async ({
requestInsertedElementSelection({
compositionId,
nodePath: result.insertedNodePath,
+ notification: null,
});
}
@@ -1355,7 +1364,14 @@ export const insertElement = async ({
? response.reason
: `Element file changed: ${response.conflict.filePath}`;
showNotification(`Could not add Element: ${reason}`, 4000);
+ return;
}
+
+ requestInsertedElementSelection({
+ compositionId,
+ nodePath: null,
+ notification: `Installed ${element.displayName}`,
+ });
} catch (error) {
showNotification(
`Could not add Element: ${
diff --git a/packages/studio/src/components/parse-caption-file.ts b/packages/studio/src/components/parse-caption-file.ts
new file mode 100644
index 00000000000..5aacaf660b1
--- /dev/null
+++ b/packages/studio/src/components/parse-caption-file.ts
@@ -0,0 +1,90 @@
+import type {Caption} from '@remotion/captions';
+
+const isObject = (value: unknown): value is Record => {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+};
+
+const isFiniteNumber = (value: unknown): value is number => {
+ return typeof value === 'number' && Number.isFinite(value);
+};
+
+export const parseCaptionFile = ({
+ fileName,
+ contents,
+}: {
+ fileName: string;
+ contents: string;
+}): Caption[] => {
+ if (!fileName.toLowerCase().endsWith('.json')) {
+ throw new Error('Unsupported caption file. Choose a .json file.');
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(contents);
+ } catch (error) {
+ throw new Error(
+ `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+
+ if (!Array.isArray(parsed)) {
+ throw new Error('Expected a Remotion Caption[] JSON array.');
+ }
+
+ let previousStart: number | null = null;
+ for (const [index, caption] of parsed.entries()) {
+ const path = `captions[${index}]`;
+ if (!isObject(caption)) {
+ throw new Error(`${path} must be an object.`);
+ }
+
+ if (typeof caption.text !== 'string') {
+ throw new Error(`${path}.text must be a string.`);
+ }
+
+ if (!isFiniteNumber(caption.startMs) || caption.startMs < 0) {
+ throw new Error(`${path}.startMs must be a finite, non-negative number.`);
+ }
+
+ if (!isFiniteNumber(caption.endMs) || caption.endMs < 0) {
+ throw new Error(`${path}.endMs must be a finite, non-negative number.`);
+ }
+
+ if (caption.endMs < caption.startMs) {
+ throw new Error(`${path}.endMs must not be earlier than startMs.`);
+ }
+
+ if (caption.timestampMs !== null && !isFiniteNumber(caption.timestampMs)) {
+ throw new Error(`${path}.timestampMs must be a finite number or null.`);
+ }
+
+ if (caption.confidence !== null && !isFiniteNumber(caption.confidence)) {
+ throw new Error(`${path}.confidence must be a finite number or null.`);
+ }
+
+ if (
+ typeof caption.confidence === 'number' &&
+ (caption.confidence < 0 || caption.confidence > 1)
+ ) {
+ throw new Error(`${path}.confidence must be between 0 and 1.`);
+ }
+
+ if (
+ caption.pageBreakAfter !== undefined &&
+ typeof caption.pageBreakAfter !== 'boolean'
+ ) {
+ throw new Error(
+ `${path}.pageBreakAfter must be a boolean when provided.`,
+ );
+ }
+
+ if (previousStart !== null && caption.startMs < previousStart) {
+ throw new Error(`${path}.startMs is out of timestamp order.`);
+ }
+
+ previousStart = caption.startMs;
+ }
+
+ return parsed as Caption[];
+};
diff --git a/packages/studio/src/helpers/get-preview-file-type.ts b/packages/studio/src/helpers/get-preview-file-type.ts
index 02ac4382678..8bf797c5f31 100644
--- a/packages/studio/src/helpers/get-preview-file-type.ts
+++ b/packages/studio/src/helpers/get-preview-file-type.ts
@@ -12,9 +12,28 @@ export const getPreviewFileType = (fileName: string | null): AssetFileType => {
return 'other';
}
- const audioExtensions = ['mp3', 'wav', 'ogg', 'aac'];
- const videoExtensions = ['mp4', 'avi', 'mkv', 'mov', 'webm'];
- const imageExtensions = ['jpg', 'jpeg', 'png', 'apng', 'gif', 'bmp', 'webp'];
+ const audioExtensions = ['mp3', 'wav', 'ogg', 'aac', 'm4a', 'flac'];
+ const videoExtensions = [
+ 'mp4',
+ 'avi',
+ 'mkv',
+ 'mov',
+ 'webm',
+ 'm4v',
+ 'ts',
+ 'm2ts',
+ 'm3u8',
+ ];
+ const imageExtensions = [
+ 'jpg',
+ 'jpeg',
+ 'png',
+ 'apng',
+ 'gif',
+ 'bmp',
+ 'webp',
+ 'svg',
+ ];
const fontExtensions = ['woff', 'woff2', 'ttf', 'otf', 'eot'];
const fileExtension = fileName.split('.').pop()?.toLowerCase();
diff --git a/packages/studio/src/helpers/inserted-element-selection.ts b/packages/studio/src/helpers/inserted-element-selection.ts
index 38144aace3c..52da93a412e 100644
--- a/packages/studio/src/helpers/inserted-element-selection.ts
+++ b/packages/studio/src/helpers/inserted-element-selection.ts
@@ -2,10 +2,11 @@ import type {SequenceNodePath} from 'remotion';
export type PendingInsertedElementSelection = {
compositionId: string;
+ notification: string | null;
nodePath: {
absolutePath: string;
nodePath: SequenceNodePath;
- };
+ } | null;
};
let pendingSelection: PendingInsertedElementSelection | null = null;
diff --git a/packages/studio/src/test/parse-caption-file.test.ts b/packages/studio/src/test/parse-caption-file.test.ts
new file mode 100644
index 00000000000..5265fdf004b
--- /dev/null
+++ b/packages/studio/src/test/parse-caption-file.test.ts
@@ -0,0 +1,83 @@
+import {expect, test} from 'bun:test';
+import {parseCaptionFile} from '../components/parse-caption-file';
+
+const parseJson = (value: unknown) => {
+ return parseCaptionFile({
+ fileName: 'captions.json',
+ contents: JSON.stringify(value),
+ });
+};
+
+test('imports Remotion Caption[] JSON', () => {
+ expect(
+ parseJson([
+ {
+ text: 'Hello',
+ startMs: 0,
+ endMs: 500,
+ timestampMs: 250,
+ confidence: null,
+ pageBreakAfter: true,
+ },
+ ]),
+ ).toEqual([
+ {
+ text: 'Hello',
+ startMs: 0,
+ endMs: 500,
+ timestampMs: 250,
+ confidence: null,
+ pageBreakAfter: true,
+ },
+ ]);
+});
+
+test.each([
+ {
+ name: 'non-JSON input',
+ value: '{',
+ error: 'Invalid JSON:',
+ },
+ {
+ name: 'non-array JSON',
+ value: '{}',
+ error: 'Expected a Remotion Caption[] JSON array',
+ },
+ {
+ name: 'missing field',
+ value: JSON.stringify([
+ {text: 'Hello', endMs: 500, timestampMs: 250, confidence: null},
+ ]),
+ error: 'captions[0].startMs must be a finite, non-negative number',
+ },
+ {
+ name: 'reversed timing',
+ value: JSON.stringify([
+ {
+ text: 'Hello',
+ startMs: 500,
+ endMs: 0,
+ timestampMs: 250,
+ confidence: null,
+ },
+ ]),
+ error: 'captions[0].endMs must not be earlier than startMs',
+ },
+ {
+ name: 'invalid confidence',
+ value: JSON.stringify([
+ {
+ text: 'Hello',
+ startMs: 0,
+ endMs: 500,
+ timestampMs: 250,
+ confidence: 2,
+ },
+ ]),
+ error: 'captions[0].confidence must be between 0 and 1',
+ },
+])('rejects $name', ({value, error}) => {
+ expect(() =>
+ parseCaptionFile({fileName: 'captions.json', contents: value}),
+ ).toThrow(error);
+});
diff --git a/packages/studio/src/test/quick-switcher-asset-search.test.ts b/packages/studio/src/test/quick-switcher-asset-search.test.ts
deleted file mode 100644
index a330f30af9d..00000000000
--- a/packages/studio/src/test/quick-switcher-asset-search.test.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import {expect, test} from 'bun:test';
-import type {StaticFile} from '../api/get-static-files';
-import {
- filterAssetsByType,
- getAssetSearchQueryForComponent,
-} from '../components/QuickSwitcher/asset-search';
-
-const assets: StaticFile[] = [
- {
- lastModified: 0,
- name: 'audio/theme.mp3',
- sizeInBytes: 1,
- src: '/audio/theme.mp3',
- },
- {
- lastModified: 0,
- name: 'images/logo.png',
- sizeInBytes: 1,
- src: '/images/logo.png',
- },
- {
- lastModified: 0,
- name: 'videos/intro.mp4',
- sizeInBytes: 1,
- src: '/videos/intro.mp4',
- },
-];
-
-test('filters quick switcher assets by type', () => {
- const result = filterAssetsByType({
- assets,
- query: 'intro type:video clip',
- });
-
- expect(result.query).toBe('intro clip');
- expect(result.assets.map((asset) => asset.name)).toEqual([
- 'videos/intro.mp4',
- ]);
-});
-
-test('supports multiple asset type filters', () => {
- const result = filterAssetsByType({
- assets,
- query: 'type:audio type:IMAGE',
- });
-
- expect(result.query).toBe('');
- expect(result.assets.map((asset) => asset.name)).toEqual([
- 'audio/theme.mp3',
- 'images/logo.png',
- ]);
-});
-
-test('returns no assets for an unknown asset type', () => {
- const result = filterAssetsByType({
- assets,
- query: 'type:spreadsheet',
- });
-
- expect(result.query).toBe('');
- expect(result.assets).toEqual([]);
-});
-
-test('gets an asset type query for interactive media components', () => {
- expect(getAssetSearchQueryForComponent('dev.remotion.media.Video')).toBe(
- 'type:video ',
- );
- expect(getAssetSearchQueryForComponent('dev.remotion.media.Audio')).toBe(
- 'type:audio ',
- );
- expect(getAssetSearchQueryForComponent('dev.remotion.remotion.Img')).toBe(
- 'type:image ',
- );
- expect(
- getAssetSearchQueryForComponent('dev.remotion.remotion.AnimatedImage'),
- ).toBe('type:image ');
- expect(
- getAssetSearchQueryForComponent('dev.remotion.remotion.CanvasImage'),
- ).toBe('type:image ');
- expect(getAssetSearchQueryForComponent('com.example.Custom')).toBe('');
- expect(getAssetSearchQueryForComponent(null)).toBe('');
-});
diff --git a/packages/studio/src/test/quick-switcher-asset-search.test.tsx b/packages/studio/src/test/quick-switcher-asset-search.test.tsx
new file mode 100644
index 00000000000..554f7f61f63
--- /dev/null
+++ b/packages/studio/src/test/quick-switcher-asset-search.test.tsx
@@ -0,0 +1,183 @@
+import {afterEach, expect, test} from 'bun:test';
+import {cleanup, fireEvent, render, screen} from '@testing-library/react';
+import type {AssetFieldSchema} from 'remotion';
+import type {StaticFile} from '../api/get-static-files';
+import {filterAssetsByType} from '../components/QuickSwitcher/asset-search';
+import {TimelineAssetField} from '../components/Timeline/TimelineAssetField';
+import {SetSelectedModalContext, type ModalState} from '../state/modals';
+
+afterEach(cleanup);
+
+const assets: StaticFile[] = [
+ {
+ lastModified: 0,
+ name: 'audio/theme.mp3',
+ sizeInBytes: 1,
+ src: '/audio/theme.mp3',
+ },
+ {
+ lastModified: 0,
+ name: 'audio/podcast.m4a',
+ sizeInBytes: 1,
+ src: '/audio/podcast.m4a',
+ },
+ {
+ lastModified: 0,
+ name: 'audio/lossless.flac',
+ sizeInBytes: 1,
+ src: '/audio/lossless.flac',
+ },
+ {
+ lastModified: 0,
+ name: 'images/logo.png',
+ sizeInBytes: 1,
+ src: '/images/logo.png',
+ },
+ {
+ lastModified: 0,
+ name: 'images/vector.svg',
+ sizeInBytes: 1,
+ src: '/images/vector.svg',
+ },
+ {
+ lastModified: 0,
+ name: 'videos/intro.mp4',
+ sizeInBytes: 1,
+ src: '/videos/intro.mp4',
+ },
+ {
+ lastModified: 0,
+ name: 'videos/trailer.m4v',
+ sizeInBytes: 1,
+ src: '/videos/trailer.m4v',
+ },
+ {
+ lastModified: 0,
+ name: 'videos/stream.ts',
+ sizeInBytes: 1,
+ src: '/videos/stream.ts',
+ },
+ {
+ lastModified: 0,
+ name: 'videos/stream.m2ts',
+ sizeInBytes: 1,
+ src: '/videos/stream.m2ts',
+ },
+ {
+ lastModified: 0,
+ name: 'videos/playlist.m3u8',
+ sizeInBytes: 1,
+ src: '/videos/playlist.m3u8',
+ },
+];
+
+test('filters quick switcher assets by type', () => {
+ const result = filterAssetsByType({
+ assets,
+ query: 'intro type:video clip',
+ });
+
+ expect(result.query).toBe('intro clip');
+ expect(result.assets.map((asset) => asset.name)).toEqual([
+ 'videos/intro.mp4',
+ 'videos/trailer.m4v',
+ 'videos/stream.ts',
+ 'videos/stream.m2ts',
+ 'videos/playlist.m3u8',
+ ]);
+});
+
+test('supports multiple asset type filters', () => {
+ const result = filterAssetsByType({
+ assets,
+ query: 'type:audio type:IMAGE',
+ });
+
+ expect(result.query).toBe('');
+ expect(result.assets.map((asset) => asset.name)).toEqual([
+ 'audio/theme.mp3',
+ 'audio/podcast.m4a',
+ 'audio/lossless.flac',
+ 'images/logo.png',
+ 'images/vector.svg',
+ ]);
+});
+
+test('returns no assets for an unknown asset type', () => {
+ const result = filterAssetsByType({
+ assets,
+ query: 'type:spreadsheet',
+ });
+
+ expect(result.query).toBe('');
+ expect(result.assets).toEqual([]);
+});
+
+test('an audio asset field includes only audio files', () => {
+ const fieldSchema = {
+ type: 'asset',
+ assetType: 'audio',
+ default: undefined,
+ } satisfies AssetFieldSchema;
+ let selectedModal: ModalState | null = null;
+
+ render(
+ {
+ selectedModal =
+ typeof update === 'function' ? update(selectedModal) : update;
+ },
+ }}
+ >
+ Promise.resolve()}
+ onDragValueChange={() => undefined}
+ onDragEnd={() => undefined}
+ />
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', {name: 'Change source'}));
+ const modal = selectedModal as ModalState | null;
+ if (modal?.type !== 'quick-switcher' || modal.assetSelection === null) {
+ throw new Error('Expected asset Quick Switcher to open');
+ }
+
+ const result = filterAssetsByType({
+ assets,
+ query: modal.assetSelection.initialQuery,
+ });
+
+ expect(result.assets.map((asset) => asset.name)).toEqual([
+ 'audio/theme.mp3',
+ 'audio/podcast.m4a',
+ 'audio/lossless.flac',
+ ]);
+});
+
+test('an image asset filter includes SVG files', () => {
+ const result = filterAssetsByType({
+ assets,
+ query: 'type:image ',
+ });
+
+ expect(result.assets.map((asset) => asset.name)).toEqual([
+ 'images/logo.png',
+ 'images/vector.svg',
+ ]);
+});