Skip to content

Commit d08c4f5

Browse files
tiwariaayuJonnyBurgerclaude
authored
#6870 Studio: <Img> should also show up as a layer (#6892)
* #6870 Studio: <Img> should also show up as a layer * Add image thumbnail rendering in timeline + refactor height calculation - Add TimelineImageInfo component that renders image thumbnails using canvas repeating pattern - Give image sequences the same 50px height as video in the timeline - Refactor getTimelineLayerHeight to accept sequence type directly, removing repeated ternaries - Add simple-img test composition - Exclude image sequences from frame number overlay Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jonny Burger <jonathanburger11@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a0d2396 commit d08c4f5

File tree

13 files changed

+241
-18
lines changed

13 files changed

+241
-18
lines changed

packages/core/src/CompositionManager.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ type EnhancedTSequenceData =
8181
doesVolumeChange: boolean;
8282
startMediaFrom: number;
8383
playbackRate: number;
84+
}
85+
| {
86+
type: 'image';
87+
src: string;
8488
};
8589

8690
export type LoopDisplay = {

packages/core/src/Img.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import React, {
55
useImperativeHandle,
66
useLayoutEffect,
77
useRef,
8+
useState,
89
} from 'react';
910
import type {IsExact} from './audio/props.js';
11+
import {addSequenceStackTraces} from './enable-sequence-stack-traces.js';
1012
import {getCrossOriginValue} from './get-cross-origin-value.js';
1113
import {usePreload} from './prefetch.js';
1214
import {SequenceContext} from './SequenceContext.js';
1315
import {useBufferState} from './use-buffer-state.js';
1416
import {useDelayRender} from './use-delay-render.js';
17+
import {useImageInTimeline} from './use-media-in-timeline.js';
1518
import {useRemotionEnvironment} from './use-remotion-environment.js';
1619

1720
function exponentialBackoff(errorCount: number): number {
@@ -33,6 +36,12 @@ export type ImgProps = NativeImgProps & {
3336
readonly delayRenderTimeoutInMilliseconds?: number;
3437
readonly onImageFrame?: (imageElement: HTMLImageElement) => void;
3538
readonly src: string;
39+
readonly showInTimeline?: boolean;
40+
readonly name?: string;
41+
/**
42+
* @deprecated For internal use only
43+
*/
44+
readonly stack?: string;
3645
};
3746

3847
type Expected = Omit<NativeImgProps, 'onError' | 'src' | 'crossOrigin'>;
@@ -50,6 +59,9 @@ const ImgRefForwarding: React.ForwardRefRenderFunction<
5059
delayRenderTimeoutInMilliseconds,
5160
onImageFrame,
5261
crossOrigin,
62+
showInTimeline,
63+
name,
64+
stack,
5365
...props
5466
},
5567
ref,
@@ -58,6 +70,7 @@ const ImgRefForwarding: React.ForwardRefRenderFunction<
5870
const errors = useRef<Record<string, number>>({});
5971
const {delayPlayback} = useBufferState();
6072
const sequenceContext = useContext(SequenceContext);
73+
const [timelineId] = useState(() => String(Math.random()));
6174

6275
if (!src) {
6376
throw new Error('No "src" prop was passed to <Img>.');
@@ -73,6 +86,17 @@ const ImgRefForwarding: React.ForwardRefRenderFunction<
7386
return imageRef.current as HTMLImageElement;
7487
}, []);
7588

89+
useImageInTimeline({
90+
src,
91+
displayName: name ?? null,
92+
id: timelineId,
93+
stack: stack ?? null,
94+
showInTimeline: showInTimeline ?? true,
95+
premountDisplay: sequenceContext?.premountDisplay ?? null,
96+
postmountDisplay: sequenceContext?.postmountDisplay ?? null,
97+
loopDisplay: undefined,
98+
});
99+
76100
const actualSrc = usePreload(src as string);
77101

78102
const retryIn = useCallback((timeout: number) => {
@@ -273,3 +297,4 @@ const ImgRefForwarding: React.ForwardRefRenderFunction<
273297
* @see [Documentation](https://remotion.dev/docs/img)
274298
*/
275299
export const Img = forwardRef(ImgRefForwarding);
300+
addSequenceStackTraces(Img);

packages/core/src/test/wrap-sequence-context.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {CompositionManagerContext} from '../CompositionManagerContext.js';
55
import {CompositionManager} from '../CompositionManagerContext.js';
66
import type {LoggingContextValue} from '../log-level-context.js';
77
import {LogLevelContext} from '../log-level-context.js';
8+
import {SequenceManagerProvider} from '../SequenceManager.js';
89
import type {TimelineContextValue} from '../TimelineContext.js';
910
import {AbsoluteTimeContext, TimelineContext} from '../TimelineContext.js';
1011

@@ -86,9 +87,11 @@ export const WrapSequenceContext: React.FC<{
8687
<BufferingProvider>
8788
<CanUseRemotionHooksProvider>
8889
<MaybeTimelineProvider>
89-
<CompositionManager.Provider value={mockCompositionContext}>
90-
{children}
91-
</CompositionManager.Provider>
90+
<SequenceManagerProvider visualModeEnabled={false}>
91+
<CompositionManager.Provider value={mockCompositionContext}>
92+
{children}
93+
</CompositionManager.Provider>
94+
</SequenceManagerProvider>
9295
</MaybeTimelineProvider>
9396
</CanUseRemotionHooksProvider>
9497
</BufferingProvider>

packages/core/src/use-media-in-timeline.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const useBasicMediaInTimeline = ({
3535
}: {
3636
volume: VolumeProp | undefined;
3737
mediaVolume: number;
38-
mediaType: 'audio' | 'video';
38+
mediaType: 'audio' | 'video' | 'image';
3939
src: string | undefined;
4040
displayName: string | null;
4141
trimBefore: number | undefined;
@@ -105,6 +105,93 @@ export const useBasicMediaInTimeline = ({
105105
};
106106
};
107107

108+
export const useImageInTimeline = ({
109+
src,
110+
displayName,
111+
id,
112+
stack,
113+
showInTimeline,
114+
premountDisplay,
115+
postmountDisplay,
116+
loopDisplay,
117+
}: {
118+
src: string | undefined;
119+
displayName: string | null;
120+
id: string;
121+
stack: string | null;
122+
showInTimeline: boolean;
123+
premountDisplay: number | null;
124+
postmountDisplay: number | null;
125+
loopDisplay: LoopDisplay | undefined;
126+
}) => {
127+
const parentSequence = useContext(SequenceContext);
128+
const {registerSequence, unregisterSequence} = useContext(SequenceManager);
129+
130+
const {duration, nonce, rootId, isStudio, finalDisplayName} =
131+
useBasicMediaInTimeline({
132+
volume: undefined,
133+
mediaVolume: 0,
134+
mediaType: 'image',
135+
src,
136+
displayName,
137+
trimAfter: undefined,
138+
trimBefore: undefined,
139+
playbackRate: 1,
140+
});
141+
142+
useEffect(() => {
143+
if (!src) {
144+
throw new Error('No src passed');
145+
}
146+
147+
if (!isStudio && window.process?.env?.NODE_ENV !== 'test') {
148+
return;
149+
}
150+
151+
if (!showInTimeline) {
152+
return;
153+
}
154+
155+
registerSequence({
156+
type: 'image',
157+
src,
158+
id,
159+
duration,
160+
from: 0,
161+
parent: parentSequence?.id ?? null,
162+
displayName: finalDisplayName,
163+
rootId,
164+
showInTimeline: true,
165+
nonce: nonce.get(),
166+
loopDisplay,
167+
stack,
168+
premountDisplay,
169+
postmountDisplay,
170+
controls: null,
171+
});
172+
173+
return () => {
174+
unregisterSequence(id);
175+
};
176+
}, [
177+
duration,
178+
id,
179+
parentSequence,
180+
src,
181+
registerSequence,
182+
unregisterSequence,
183+
nonce,
184+
stack,
185+
showInTimeline,
186+
premountDisplay,
187+
postmountDisplay,
188+
isStudio,
189+
loopDisplay,
190+
rootId,
191+
finalDisplayName,
192+
]);
193+
};
194+
108195
export const useMediaInTimeline = ({
109196
volume,
110197
mediaVolume,

packages/example/src/Root.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import EllipseTest from './Shapes/EllipseTest';
6666
import RectTest from './Shapes/RectTest';
6767
import StarTest from './Shapes/StarTest';
6868
import TriangleTest from './Shapes/TriangleTest';
69+
import {SimpleImg} from './SimpleImg';
6970
import {SkipZeroFrame} from './SkipZeroFrame';
7071
import {SlicedVideo} from './SlicedVideo';
7172
import {BaseSpring, SpringWithDuration} from './Spring/base-spring';
@@ -440,6 +441,14 @@ export const Index: React.FC = () => {
440441
/>
441442
</Folder>
442443
<Folder name="regression-testing">
444+
<Composition
445+
id="simple-img"
446+
component={SimpleImg}
447+
width={1080}
448+
height={1080}
449+
fps={30}
450+
durationInFrames={10}
451+
/>
443452
<Composition
444453
id="missing-img"
445454
component={MissingImg}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import React from 'react';
2+
import {Img, staticFile} from 'remotion';
3+
4+
export const SimpleImg = (): React.ReactNode => {
5+
return <Img src={staticFile('1.jpg')} />;
6+
};

packages/studio/src/components/Timeline/Timeline.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,7 @@ const TimelineInner: React.FC = () => {
8888
visualModeEnabled && (expandedTracks[track.sequence.id] ?? false);
8989
return (
9090
acc +
91-
getTimelineLayerHeight(
92-
track.sequence.type === 'video' ? 'video' : 'other',
93-
) +
91+
getTimelineLayerHeight(track.sequence.type) +
9492
Number(TIMELINE_ITEM_BORDER_BOTTOM) +
9593
(isExpanded
9694
? getExpandedTrackHeight(track.sequence.controls) +
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React, {useEffect, useRef} from 'react';
2+
import {getTimelineLayerHeight} from '../../helpers/timeline-layout';
3+
4+
const HEIGHT = getTimelineLayerHeight('image') - 2;
5+
6+
const containerStyle: React.CSSProperties = {
7+
height: HEIGHT,
8+
width: '100%',
9+
backgroundColor: 'rgba(0, 0, 0, 0.3)',
10+
display: 'flex',
11+
borderTopLeftRadius: 2,
12+
borderBottomLeftRadius: 2,
13+
};
14+
15+
export const TimelineImageInfo: React.FC<{
16+
readonly src: string;
17+
readonly visualizationWidth: number;
18+
}> = ({src, visualizationWidth}) => {
19+
const ref = useRef<HTMLDivElement>(null);
20+
21+
useEffect(() => {
22+
const {current} = ref;
23+
if (!current) {
24+
return;
25+
}
26+
27+
const canvas = document.createElement('canvas');
28+
canvas.width = visualizationWidth * window.devicePixelRatio;
29+
canvas.height = HEIGHT * window.devicePixelRatio;
30+
canvas.style.width = visualizationWidth + 'px';
31+
canvas.style.height = HEIGHT + 'px';
32+
const ctx = canvas.getContext('2d');
33+
if (!ctx) {
34+
return;
35+
}
36+
37+
current.appendChild(canvas);
38+
39+
const img = new Image();
40+
img.crossOrigin = 'anonymous';
41+
42+
img.onload = () => {
43+
const scale = (HEIGHT * window.devicePixelRatio) / img.naturalHeight;
44+
const scaledWidth = img.naturalWidth * scale;
45+
const scaledHeight = HEIGHT * window.devicePixelRatio;
46+
47+
const offscreen = document.createElement('canvas');
48+
offscreen.width = scaledWidth;
49+
offscreen.height = scaledHeight;
50+
const offCtx = offscreen.getContext('2d');
51+
if (!offCtx) {
52+
return;
53+
}
54+
55+
offCtx.drawImage(img, 0, 0, scaledWidth, scaledHeight);
56+
57+
const pattern = ctx.createPattern(offscreen, 'repeat-x');
58+
if (!pattern) {
59+
return;
60+
}
61+
62+
ctx.fillStyle = pattern;
63+
ctx.fillRect(
64+
0,
65+
0,
66+
visualizationWidth * window.devicePixelRatio,
67+
HEIGHT * window.devicePixelRatio,
68+
);
69+
};
70+
71+
img.src = src;
72+
73+
return () => {
74+
current.removeChild(canvas);
75+
};
76+
}, [src, visualizationWidth]);
77+
78+
return <div ref={ref} style={containerStyle} />;
79+
};

packages/studio/src/components/Timeline/TimelineListItem.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,7 @@ export const TimelineListItem: React.FC<{
9090
const outer: React.CSSProperties = useMemo(() => {
9191
return {
9292
height:
93-
getTimelineLayerHeight(sequence.type === 'video' ? 'video' : 'other') +
94-
TIMELINE_ITEM_BORDER_BOTTOM,
93+
getTimelineLayerHeight(sequence.type) + TIMELINE_ITEM_BORDER_BOTTOM,
9594
color: 'white',
9695
fontFamily: 'Arial, Helvetica, sans-serif',
9796
display: 'flex',

packages/studio/src/components/Timeline/TimelineSequence.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import {getTimelineLayerHeight} from '../../helpers/timeline-layout';
1010
import {useMaxMediaDuration} from '../../helpers/use-max-media-duration';
1111
import {AudioWaveform} from '../AudioWaveform';
1212
import {LoopedTimelineIndicator} from './LoopedTimelineIndicators';
13+
import {TimelineImageInfo} from './TimelineImageInfo';
1314
import {TimelineSequenceFrame} from './TimelineSequenceFrame';
1415
import {TimelineVideoInfo} from './TimelineVideoInfo';
1516
import {TimelineWidthContext} from './TimelineWidthProvider';
1617

1718
const AUDIO_GRADIENT = 'linear-gradient(rgb(16 171 58), rgb(43 165 63) 60%)';
1819
const VIDEO_GRADIENT = 'linear-gradient(to top, #8e44ad, #9b59b6)';
20+
const IMAGE_GRADIENT = 'linear-gradient(to top, #2980b9, #3498db)';
1921

2022
export const TimelineSequence: React.FC<{
2123
readonly s: TSequence;
@@ -69,7 +71,8 @@ const Inner: React.FC<{
6971
? s.loopDisplay.durationInFrames * s.loopDisplay.numberOfTimes
7072
: s.duration,
7173
startFrom: s.loopDisplay ? s.from + s.loopDisplay.startOffset : s.from,
72-
startFromMedia: s.type === 'sequence' ? 0 : s.startMediaFrom,
74+
startFromMedia:
75+
s.type === 'sequence' || s.type === 'image' ? 0 : s.startMediaFrom,
7376
maxMediaDuration,
7477
video,
7578
windowWidth,
@@ -85,11 +88,13 @@ const Inner: React.FC<{
8588
? AUDIO_GRADIENT
8689
: s.type === 'video'
8790
? VIDEO_GRADIENT
88-
: BLUE,
91+
: s.type === 'image'
92+
? IMAGE_GRADIENT
93+
: BLUE,
8994
border: SEQUENCE_BORDER_WIDTH + 'px solid rgba(255, 255, 255, 0.2)',
9095
borderRadius: 2,
9196
position: 'absolute',
92-
height: getTimelineLayerHeight(s.type === 'video' ? 'video' : 'other'),
97+
height: getTimelineLayerHeight(s.type),
9398
marginLeft,
9499
width,
95100
color: 'white',
@@ -160,12 +165,16 @@ const Inner: React.FC<{
160165
playbackRate={s.playbackRate}
161166
/>
162167
) : null}
168+
{s.type === 'image' ? (
169+
<TimelineImageInfo src={s.src} visualizationWidth={width} />
170+
) : null}
163171
{s.loopDisplay === undefined ? null : (
164172
<LoopedTimelineIndicator loops={s.loopDisplay.numberOfTimes} />
165173
)}
166174

167175
{s.type !== 'audio' &&
168176
s.type !== 'video' &&
177+
s.type !== 'image' &&
169178
s.loopDisplay === undefined &&
170179
(isInRange || isPremounting || isPostmounting) ? (
171180
<div

0 commit comments

Comments
 (0)