diff --git a/packages/core/src/Img.tsx b/packages/core/src/Img.tsx index 626a229b3ac..b9128c4906a 100644 --- a/packages/core/src/Img.tsx +++ b/packages/core/src/Img.tsx @@ -444,6 +444,7 @@ const CanvasImageWithPrivateProps = CanvasImage as React.ComponentType< export const imgSchema = { src: { type: 'asset', + assetType: 'image', default: undefined, description: 'Source', keyframable: false, diff --git a/packages/core/src/animated-image/AnimatedImage.tsx b/packages/core/src/animated-image/AnimatedImage.tsx index e8793f4f3d1..25a50868890 100644 --- a/packages/core/src/animated-image/AnimatedImage.tsx +++ b/packages/core/src/animated-image/AnimatedImage.tsx @@ -49,6 +49,7 @@ import {resolveAnimatedImageSource} from './resolve-image-source'; export const animatedImageSchema = { src: { type: 'asset', + assetType: 'image', default: undefined, description: 'Source', keyframable: false, diff --git a/packages/core/src/canvas-image/CanvasImage.tsx b/packages/core/src/canvas-image/CanvasImage.tsx index 581a471136a..8fb113ef48a 100644 --- a/packages/core/src/canvas-image/CanvasImage.tsx +++ b/packages/core/src/canvas-image/CanvasImage.tsx @@ -44,6 +44,7 @@ import type {CanvasImageCanvasProps, CanvasImageProps} from './props.js'; export const canvasImageSchema = { src: { type: 'asset', + assetType: 'image', default: undefined, description: 'Source', keyframable: false, diff --git a/packages/core/src/interactivity-schema.ts b/packages/core/src/interactivity-schema.ts index 247e2427e30..c3348c9030a 100644 --- a/packages/core/src/interactivity-schema.ts +++ b/packages/core/src/interactivity-schema.ts @@ -121,6 +121,7 @@ export type FontWeightFieldSchema = { export type AssetFieldSchema = { type: 'asset'; + assetType?: 'audio' | 'video' | 'image'; default: string | undefined; description?: string; keyframable?: false; diff --git a/packages/core/src/test/Img.test.tsx b/packages/core/src/test/Img.test.tsx index 19d2955d5cc..397ab0b4dcd 100644 --- a/packages/core/src/test/Img.test.tsx +++ b/packages/core/src/test/Img.test.tsx @@ -408,6 +408,7 @@ test('Img with effects renders through the canvas image path', async () => { test(' schema exposes src and crop but not fit', () => { expect(imgSchema.src).toEqual({ type: 'asset', + assetType: 'image', default: undefined, description: 'Source', keyframable: false, diff --git a/packages/core/src/test/canvas-image.test.tsx b/packages/core/src/test/canvas-image.test.tsx index 5bdb1c6ac7c..9d3079bdb19 100644 --- a/packages/core/src/test/canvas-image.test.tsx +++ b/packages/core/src/test/canvas-image.test.tsx @@ -267,6 +267,7 @@ test(' registers its canvas as the outline ref', async () => { test(' schema exposes src and non-keyframable premounting fields', () => { expect(canvasImageSchema.src).toEqual({ type: 'asset', + assetType: 'image', default: undefined, description: 'Source', keyframable: false, diff --git a/packages/docs/docs/interactivity-schema.mdx b/packages/docs/docs/interactivity-schema.mdx index 5c02ccded94..9b115ea1076 100644 --- a/packages/docs/docs/interactivity-schema.mdx +++ b/packages/docs/docs/interactivity-schema.mdx @@ -101,6 +101,29 @@ Shows an on/off control. Shows a color control. The value is a CSS color string. +### `asset` + +Shows a control for selecting an asset source. + +#### `assetType?` + +Set to `'audio'`, `'video'` or `'image'` to only show matching files when replacing this field's asset in Remotion Studio. + +`assetType` describes the intended content. It does not inspect or validate the current value or remote URLs. + +```ts twoslash title="schema.ts" +import type {InteractivitySchema} from 'remotion'; + +export const schema = { + audioSrc: { + type: 'asset', + assetType: 'audio', + default: 'https://example.com/audio.mp3', + description: 'Audio source', + }, +} as const satisfies InteractivitySchema; +``` + ### `font-family` Shows a font family control. The value is a CSS font family string. diff --git a/packages/docs/elements/audio/mirrored-spectrum/mirrored-spectrum.tsx b/packages/docs/elements/audio/mirrored-spectrum/mirrored-spectrum.tsx index e9632dd0d4b..dad57419138 100644 --- a/packages/docs/elements/audio/mirrored-spectrum/mirrored-spectrum.tsx +++ b/packages/docs/elements/audio/mirrored-spectrum/mirrored-spectrum.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/docs/elements/audio/oscilloscope/audio-oscilloscope.tsx b/packages/docs/elements/audio/oscilloscope/audio-oscilloscope.tsx index bda47ea1664..80322396adc 100644 --- a/packages/docs/elements/audio/oscilloscope/audio-oscilloscope.tsx +++ b/packages/docs/elements/audio/oscilloscope/audio-oscilloscope.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/docs/elements/audio/waveform-progress/audio-waveform-progress.tsx b/packages/docs/elements/audio/waveform-progress/audio-waveform-progress.tsx index f3b62221760..df1a2adbe04 100644 --- a/packages/docs/elements/audio/waveform-progress/audio-waveform-progress.tsx +++ b/packages/docs/elements/audio/waveform-progress/audio-waveform-progress.tsx @@ -32,6 +32,7 @@ const audioWaveformProgressSchema = { ...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/docs/elements/captions/index.mdx b/packages/docs/elements/captions/index.mdx index 1280cdd7c3f..94bda60e244 100644 --- a/packages/docs/elements/captions/index.mdx +++ b/packages/docs/elements/captions/index.mdx @@ -7,4 +7,14 @@ import {ElementLibrary} from '@site/src/components/Elements/ElementLibrary'; # Captions +## Importing captions into Studio + +1 Install an Element from the [Captions category](/elements/captions), or add [`Interactive.captionsSchema`](/docs/interactivity-schema#interactivecaptionsschema) to an interactive component that accepts [`Caption[]`](/docs/captions/caption). +
+2 Select its caption layer in Studio. +
+3 In the Captions inspector, click **Import** and choose a JSON file containing a Remotion [`Caption[]`](/docs/captions/caption). + +Files are processed locally. Importing SRT and transcription service JSON is tracked in [#11062](https://github.com/remotion-dev/remotion/issues/11062). + diff --git a/packages/docs/elements/commerce/product-collection/product-collection.tsx b/packages/docs/elements/commerce/product-collection/product-collection.tsx index e76aaa75dc0..61e7d5d7c41 100644 --- a/packages/docs/elements/commerce/product-collection/product-collection.tsx +++ b/packages/docs/elements/commerce/product-collection/product-collection.tsx @@ -25,7 +25,6 @@ type ProductCardProps = InteractiveBaseProps & readonly count: number; readonly discount: string; readonly image: string; - readonly imageFit: 'contain' | 'cover'; readonly index: number; readonly originalPrice: string; readonly price: string; @@ -42,19 +41,11 @@ const productCardSchema = { }, image: { type: 'asset', + assetType: 'image', default: 'https://remotion.media/elements/product-collection-cloudline-runner.png', description: 'Product image', }, - imageFit: { - type: 'enum', - default: 'cover', - description: 'Image fit', - variants: { - contain: {}, - cover: {}, - }, - }, price: { type: 'text-content', default: '$100', @@ -85,7 +76,6 @@ const ProductCardInner = forwardRef< count, discount, image, - imageFit, index, name, originalPrice, @@ -194,6 +184,8 @@ const ProductCardInner = forwardRef< style={{ ...style, backgroundColor: '#ffffff', + borderRadius: 6, + boxShadow: '0 2px 6px rgba(29, 29, 25, 0.12)', boxSizing: 'border-box', color: '#1d1d19', display: 'flex', @@ -218,7 +210,7 @@ const ProductCardInner = forwardRef< src={image} style={{ height: '100%', - objectFit: imageFit, + objectFit: 'cover', objectPosition: '50% 50%', scale: interpolate( frame, @@ -426,7 +418,6 @@ export const ProductCollection = () => { count={5} discount="20% off" image="https://remotion.media/elements/product-collection-cloudline-runner.png" - imageFit="cover" index={0} name="Cloudline Runner card" originalPrice="$148" @@ -438,7 +429,6 @@ export const ProductCollection = () => { count={5} discount="" image="https://remotion.media/elements/product-collection-minimal-steel-watch.png" - imageFit="cover" index={1} name="Minimal Steel Watch card" originalPrice="" @@ -450,7 +440,6 @@ export const ProductCollection = () => { count={5} discount="Save $31" image="https://images.unsplash.com/photo-1511499767150-a48a237f0083?fm=jpg&fit=crop&w=900&q=90" - imageFit="cover" index={2} name="Studio Sunglasses card" originalPrice="$125" @@ -462,7 +451,6 @@ export const ProductCollection = () => { count={5} discount="New" image="https://images.unsplash.com/photo-1505740420928-5e560c06d30e?fm=jpg&fit=crop&w=900&q=90" - imageFit="cover" index={3} name="Studio Headset card" originalPrice="" @@ -474,7 +462,6 @@ export const ProductCollection = () => { count={5} discount="" image="https://images.unsplash.com/photo-1507473885765-e6ed057f782c?fm=jpg&fit=crop&w=900&q=90" - imageFit="cover" index={4} name="Sculptural Table Lamp card" originalPrice="" diff --git a/packages/docs/src/components/Elements/ElementPage.tsx b/packages/docs/src/components/Elements/ElementPage.tsx index ab14c8de74a..ff1336bc648 100644 --- a/packages/docs/src/components/Elements/ElementPage.tsx +++ b/packages/docs/src/components/Elements/ElementPage.tsx @@ -1,10 +1,10 @@ import Head from '@docusaurus/Head'; import { installInStudio, - type InstallInStudioErrorCode, isInsideStudio, setStudioDragData, StudioProtocolInternals, + type InstallInStudioErrorCode, } from '@remotion/studio-protocol'; import React, { useCallback, @@ -345,6 +345,14 @@ export const ElementPage: React.FC = ({

{description}

+ {definition.category === 'captions' ? ( +

+ Have captions?{' '} + + Import Remotion Caption[] JSON from Studio. + +

+ ) : null}
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', + ]); +});