Skip to content

Commit bbd30eb

Browse files
committed
fix(canvas): render generated video posters from one-second frame
1 parent f47e079 commit bbd30eb

4 files changed

Lines changed: 175 additions & 33 deletions

File tree

src/components/beatcanvas/nodes/generation-card-node-interaction.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,12 @@ test('generated videos expose a direct playback entry', () => {
6262
source,
6363
/cardMediaType === 'video'[\s\S]*?<button[\s\S]*?handlePreviewLatestOutput/
6464
);
65+
assert.match(source, /function StaticVideoPoster/);
66+
assert.match(source, /captureStaticVideoPreview\(video, maxEdge\)/);
67+
assert.match(source, /<img[\s\S]*?src=\{poster\}/);
6568
assert.match(
6669
source,
67-
/<video[\s\S]*?onDoubleClick=\{[\s\S]*?handlePreviewLatestOutput/
70+
/<StaticVideoPoster[\s\S]*?onDoubleClick=\{[\s\S]*?handlePreviewLatestOutput/
6871
);
6972
assert.match(source, /seekStaticVideoPreview\(event\.currentTarget\)/);
7073
});

src/components/beatcanvas/nodes/generation-card-node.tsx

Lines changed: 100 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,19 @@ import {
66
useInternalNode,
77
} from '@xyflow/react';
88
import { ScanSearch, Sparkles } from 'lucide-react';
9+
import {
10+
useCallback,
11+
useEffect,
12+
useState,
13+
type CSSProperties,
14+
type MouseEventHandler,
15+
} from 'react';
916

1017
import type { GenerationTake } from '@/core/beatcanvas/generation-history';
11-
import { seekStaticVideoPreview } from '@/core/media/video-preview';
18+
import {
19+
captureStaticVideoPreview,
20+
seekStaticVideoPreview,
21+
} from '@/core/media/video-preview';
1222

1323
import { getBeatCanvasNodeCopy } from './beatcanvas-node-copy';
1424
import type { BeatCanvasFlowNode } from '../react-flow/beatcanvas-react-flow-types';
@@ -22,6 +32,86 @@ const FRAME_BACKGROUND =
2232
'linear-gradient(180deg, var(--beat-surface-2) 0%, var(--beat-surface) 100%)';
2333
const PLACEHOLDER_COLOR = 'rgba(255, 255, 255, 0.22)';
2434

35+
function StaticVideoPoster({
36+
src,
37+
alt,
38+
maxEdge = 512,
39+
cursor = 'default',
40+
onDoubleClick,
41+
}: {
42+
src: string;
43+
alt: string;
44+
maxEdge?: number;
45+
cursor?: CSSProperties['cursor'];
46+
onDoubleClick?: MouseEventHandler<HTMLDivElement>;
47+
}) {
48+
const [poster, setPoster] = useState<string | null>(null);
49+
50+
useEffect(() => setPoster(null), [src]);
51+
52+
const captureFrame = useCallback(
53+
(video: HTMLVideoElement) => {
54+
const nextPoster = captureStaticVideoPreview(video, maxEdge);
55+
if (nextPoster) setPoster((current) => current ?? nextPoster);
56+
},
57+
[maxEdge]
58+
);
59+
60+
return (
61+
<div
62+
className="nowheel"
63+
onDoubleClick={onDoubleClick}
64+
style={{
65+
position: 'relative',
66+
width: '100%',
67+
height: '100%',
68+
overflow: 'hidden',
69+
cursor,
70+
background:
71+
'linear-gradient(145deg, var(--beat-surface-2), var(--beat-surface))',
72+
}}
73+
>
74+
<video
75+
src={src}
76+
muted
77+
playsInline
78+
preload="metadata"
79+
className="nowheel"
80+
aria-hidden="true"
81+
tabIndex={-1}
82+
onLoadedMetadata={(event) =>
83+
seekStaticVideoPreview(event.currentTarget)
84+
}
85+
onLoadedData={(event) => captureFrame(event.currentTarget)}
86+
onSeeked={(event) => captureFrame(event.currentTarget)}
87+
style={{
88+
position: 'absolute',
89+
inset: 0,
90+
width: '100%',
91+
height: '100%',
92+
objectFit: 'cover',
93+
opacity: 0,
94+
pointerEvents: 'none',
95+
}}
96+
/>
97+
{poster ? (
98+
<img
99+
src={poster}
100+
alt={alt}
101+
draggable={false}
102+
className="nowheel"
103+
style={{
104+
display: 'block',
105+
width: '100%',
106+
height: '100%',
107+
objectFit: 'cover',
108+
}}
109+
/>
110+
) : null}
111+
</div>
112+
);
113+
}
114+
25115
export function GenerationCardNode({
26116
id,
27117
data,
@@ -286,28 +376,16 @@ export function GenerationCardNode({
286376
</div>
287377
</div>
288378
) : cardMediaType === 'video' ? (
289-
<video
290-
src={latestOutputUrl ?? undefined}
291-
muted
292-
playsInline
293-
preload="metadata"
294-
onLoadedMetadata={(event) =>
295-
seekStaticVideoPreview(event.currentTarget)
296-
}
297-
draggable={false}
379+
<StaticVideoPoster
380+
key={latestOutputUrl}
381+
src={latestOutputUrl ?? ''}
382+
alt={displayLabel}
383+
cursor="zoom-in"
298384
onDoubleClick={(event) => {
299385
event.preventDefault();
300386
event.stopPropagation();
301387
handlePreviewLatestOutput();
302388
}}
303-
className="nowheel"
304-
style={{
305-
display: 'block',
306-
width: '100%',
307-
height: '100%',
308-
objectFit: 'cover',
309-
cursor: 'zoom-in',
310-
}}
311389
/>
312390
) : (
313391
<img
@@ -491,21 +569,11 @@ export function GenerationCardNode({
491569
}}
492570
>
493571
{take.url && take.type === 'video' ? (
494-
<video
572+
<StaticVideoPoster
573+
key={take.url}
495574
src={take.url}
496-
muted
497-
playsInline
498-
preload="metadata"
499-
className="nowheel"
500-
onLoadedMetadata={(event) =>
501-
seekStaticVideoPreview(event.currentTarget)
502-
}
503-
style={{
504-
display: 'block',
505-
width: '100%',
506-
height: '100%',
507-
objectFit: 'cover',
508-
}}
575+
alt=""
576+
maxEdge={96}
509577
/>
510578
) : take.url ? (
511579
<img

src/core/media/video-preview.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
22
import test from 'node:test';
33

44
import {
5+
getStaticVideoPreviewSize,
56
getStaticVideoPreviewTime,
67
seekStaticVideoPreview,
78
STATIC_VIDEO_PREVIEW_TIME_SECONDS,
@@ -27,3 +28,18 @@ test('static video preview seeking tolerates non-seekable media', () => {
2728
};
2829
assert.doesNotThrow(() => seekStaticVideoPreview(video));
2930
});
31+
32+
test('static video preview frames are downscaled without changing aspect ratio', () => {
33+
assert.deepEqual(getStaticVideoPreviewSize(720, 1280), {
34+
width: 288,
35+
height: 512,
36+
});
37+
assert.deepEqual(getStaticVideoPreviewSize(1920, 1080, 320), {
38+
width: 320,
39+
height: 180,
40+
});
41+
assert.deepEqual(getStaticVideoPreviewSize(240, 180), {
42+
width: 240,
43+
height: 180,
44+
});
45+
});

src/core/media/video-preview.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const STATIC_VIDEO_PREVIEW_TIME_SECONDS = 1;
2+
export const STATIC_VIDEO_PREVIEW_MAX_EDGE = 512;
23

34
export function getStaticVideoPreviewTime(
45
duration: number,
@@ -25,3 +26,57 @@ export function seekStaticVideoPreview(
2526
// Some remote or not-yet-seekable media rejects programmatic seeking.
2627
}
2728
}
29+
30+
export function getStaticVideoPreviewSize(
31+
sourceWidth: number,
32+
sourceHeight: number,
33+
maxEdge = STATIC_VIDEO_PREVIEW_MAX_EDGE
34+
) {
35+
if (
36+
!Number.isFinite(sourceWidth) ||
37+
!Number.isFinite(sourceHeight) ||
38+
sourceWidth <= 0 ||
39+
sourceHeight <= 0 ||
40+
!Number.isFinite(maxEdge) ||
41+
maxEdge <= 0
42+
) {
43+
return { width: 0, height: 0 };
44+
}
45+
46+
const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight));
47+
return {
48+
width: Math.max(1, Math.round(sourceWidth * scale)),
49+
height: Math.max(1, Math.round(sourceHeight * scale)),
50+
};
51+
}
52+
53+
export function captureStaticVideoPreview(
54+
video: HTMLVideoElement,
55+
maxEdge = STATIC_VIDEO_PREVIEW_MAX_EDGE
56+
) {
57+
if (typeof document === 'undefined' || video.readyState < 2) return null;
58+
59+
const targetTime = getStaticVideoPreviewTime(video.duration);
60+
if (Math.abs(video.currentTime - targetTime) > 0.12) return null;
61+
62+
const size = getStaticVideoPreviewSize(
63+
video.videoWidth,
64+
video.videoHeight,
65+
maxEdge
66+
);
67+
if (size.width === 0 || size.height === 0) return null;
68+
69+
try {
70+
const canvas = document.createElement('canvas');
71+
canvas.width = size.width;
72+
canvas.height = size.height;
73+
const context = canvas.getContext('2d');
74+
if (!context) return null;
75+
context.drawImage(video, 0, 0, size.width, size.height);
76+
return canvas.toDataURL('image/jpeg', 0.84);
77+
} catch {
78+
// Cross-origin media can reject canvas capture. The caller keeps its
79+
// non-black loading surface instead of exposing a broken video layer.
80+
return null;
81+
}
82+
}

0 commit comments

Comments
 (0)