moonlantern1 commited on
Commit
ff790f0
·
verified ·
1 Parent(s): ab3c93e

Cap review videos at seventeen seconds

Browse files
src/app/c/[slug]/record/GuidedRecordingClient.tsx CHANGED
@@ -487,7 +487,11 @@ function BrowserMediaRecorder({
487
  <div className="flex-1" />
488
 
489
  <div className="flex flex-col items-center gap-4 pb-[max(env(safe-area-inset-bottom),24px)]">
490
- <RecordingBadge elapsedMs={elapsedMs} visible={state === 'recording'} />
 
 
 
 
491
 
492
  {error ? (
493
  <div className="mx-4 rounded-2xl bg-red-500/90 px-4 py-2 text-sm">
 
487
  <div className="flex-1" />
488
 
489
  <div className="flex flex-col items-center gap-4 pb-[max(env(safe-area-inset-bottom),24px)]">
490
+ <RecordingBadge
491
+ elapsedMs={elapsedMs}
492
+ maxMs={prompt.maxSeconds * 1000}
493
+ visible={state === 'recording'}
494
+ />
495
 
496
  {error ? (
497
  <div className="mx-4 rounded-2xl bg-red-500/90 px-4 py-2 text-sm">
src/components/RecordingBadge.tsx CHANGED
@@ -1,19 +1,24 @@
1
- type Props = {
2
- elapsedMs: number;
3
- visible: boolean;
4
- };
5
-
6
- export function RecordingBadge({ elapsedMs, visible }: Props) {
7
- if (!visible) return null;
8
-
9
- const totalSeconds = Math.floor(elapsedMs / 1000);
10
- const min = Math.floor(totalSeconds / 60);
11
- const sec = totalSeconds % 60;
12
- const time = `${min}:${String(sec).padStart(2, '0')}`;
13
-
14
- return (
15
- <div className="flex items-center gap-2 rounded-full bg-[rgba(238,64,64,0.95)] px-3.5 py-2 font-mono text-xs text-white">
16
- <span className="block h-2 w-2 animate-blink rounded-full bg-white" />
 
 
 
 
 
17
  <span>REC {time}</span>
18
  </div>
19
  );
 
1
+ type Props = {
2
+ elapsedMs: number;
3
+ maxMs?: number;
4
+ visible: boolean;
5
+ };
6
+
7
+ function formatTime(ms: number) {
8
+ const totalSeconds = Math.floor(ms / 1000);
9
+ const min = Math.floor(totalSeconds / 60);
10
+ const sec = totalSeconds % 60;
11
+ return `${min}:${String(sec).padStart(2, '0')}`;
12
+ }
13
+
14
+ export function RecordingBadge({ elapsedMs, maxMs, visible }: Props) {
15
+ if (!visible) return null;
16
+
17
+ const time = maxMs ? `${formatTime(elapsedMs)} / ${formatTime(maxMs)}` : formatTime(elapsedMs);
18
+
19
+ return (
20
+ <div className="flex items-center gap-2 rounded-full bg-[rgba(238,64,64,0.95)] px-3.5 py-2 font-mono text-xs text-white">
21
+ <span className="block h-2 w-2 animate-blink rounded-full bg-white" />
22
  <span>REC {time}</span>
23
  </div>
24
  );
src/lib/server/reviewStore.ts CHANGED
@@ -125,7 +125,7 @@ const SAGE_AND_STONE: PublicReviewCampaign = {
125
  tip: 'Voice only. Say the dish name and describe what is on the plate.',
126
  mediaType: 'audio',
127
  camera: 'front',
128
- maxSeconds: 8,
129
  optional: false,
130
  },
131
  {
@@ -134,7 +134,7 @@ const SAGE_AND_STONE: PublicReviewCampaign = {
134
  tip: 'Voice only. Mention the flavor, texture, portion, or what stood out.',
135
  mediaType: 'audio',
136
  camera: 'front',
137
- maxSeconds: 12,
138
  optional: false,
139
  },
140
  {
 
125
  tip: 'Voice only. Say the dish name and describe what is on the plate.',
126
  mediaType: 'audio',
127
  camera: 'front',
128
+ maxSeconds: 7,
129
  optional: false,
130
  },
131
  {
 
134
  tip: 'Voice only. Mention the flavor, texture, portion, or what stood out.',
135
  mediaType: 'audio',
136
  camera: 'front',
137
+ maxSeconds: 10,
138
  optional: false,
139
  },
140
  {
src/lib/server/serverClipRenderer.ts CHANGED
@@ -26,7 +26,7 @@ const VIDEO_FPS = 24;
26
  const MIN_VIDEO_CLIP_SECONDS = 5;
27
  const MAX_VIDEO_CLIP_SECONDS = 7;
28
  const MAX_AUDIO_CLIP_SECONDS = 12;
29
- const AUDIO_VIDEO_SAFETY_SECONDS = 0.2;
30
 
31
  function safeExt(ext: string) {
32
  const normalized = ext.toLowerCase().replace(/[^a-z0-9]/g, '');
@@ -159,21 +159,25 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
159
  Promise.all(audioPaths.map(probeDuration)),
160
  ]);
161
 
 
162
  const audioDurationSeconds = audioDurations.reduce((total, duration) => total + duration, 0);
163
  const videoClipSeconds =
164
- audioDurationSeconds > 0
165
- ? clamp(Math.ceil(audioDurationSeconds / videoPaths.length), MIN_VIDEO_CLIP_SECONDS, MAX_VIDEO_CLIP_SECONDS)
166
- : MIN_VIDEO_CLIP_SECONDS;
 
 
 
 
 
 
167
  const cappedVideoDurations = videoDurations.map((duration) =>
168
  duration > 0 ? Math.min(duration, videoClipSeconds) : videoClipSeconds,
169
  );
170
  const baseVideoDurationSeconds = cappedVideoDurations.reduce((total, duration) => total + duration, 0);
171
  const renderTargetSeconds =
172
- audioDurationSeconds > 0
173
- ? Math.max(1, audioDurationSeconds + AUDIO_VIDEO_SAFETY_SECONDS)
174
- : Math.max(1, baseVideoDurationSeconds);
175
  const loopFrameCount = Math.max(1, Math.ceil(baseVideoDurationSeconds * VIDEO_FPS));
176
- const needsVideoLoop = audioDurationSeconds > 0 && baseVideoDurationSeconds + 0.1 < renderTargetSeconds;
177
 
178
  const inputArgs = [
179
  ...videoPaths.flatMap((filePath) => [
@@ -202,7 +206,7 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
202
  .join(';');
203
  const videoInputs = videoPaths.map((_, i) => `[v${i}]`).join('');
204
  const videoConcat = `${videoInputs}concat=n=${videoPaths.length}:v=1:a=0[vcat]`;
205
- const videoFinalize = needsVideoLoop
206
  ? `[vcat]loop=loop=-1:size=${loopFrameCount}:start=0,trim=duration=${formatDuration(
207
  renderTargetSeconds,
208
  )},setpts=PTS-STARTPTS[v]`
@@ -219,12 +223,15 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
219
  .join(';');
220
  const audioInputs = audioPaths.map((_, i) => `[a${i}]`).join('');
221
  const audioConcat = audioPaths.length
222
- ? `${audioInputs}concat=n=${audioPaths.length}:v=0:a=1[a]`
 
 
 
223
  : '';
224
 
225
- // Native phone clips are often shorter than the voiceover. Loop the b-roll
226
- // sequence to avoid the browser freezing on the last frame while audio plays.
227
- const filterComplex = [videoFilters, videoConcat, videoFinalize, audioFilters, audioConcat]
228
  .filter(Boolean)
229
  .join(';');
230
 
@@ -250,7 +257,7 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
250
  '30',
251
  '-r',
252
  String(VIDEO_FPS),
253
- ...(audioPaths.length ? ['-c:a', 'aac', '-b:a', '96k'] : ['-an']),
254
  '-movflags',
255
  '+faststart',
256
  '-avoid_negative_ts',
 
26
  const MIN_VIDEO_CLIP_SECONDS = 5;
27
  const MAX_VIDEO_CLIP_SECONDS = 7;
28
  const MAX_AUDIO_CLIP_SECONDS = 12;
29
+ const FINAL_VIDEO_MAX_SECONDS = 17;
30
 
31
  function safeExt(ext: string) {
32
  const normalized = ext.toLowerCase().replace(/[^a-z0-9]/g, '');
 
159
  Promise.all(audioPaths.map(probeDuration)),
160
  ]);
161
 
162
+ const hasVoiceover = audioPaths.length > 0;
163
  const audioDurationSeconds = audioDurations.reduce((total, duration) => total + duration, 0);
164
  const videoClipSeconds =
165
+ hasVoiceover
166
+ ? MAX_VIDEO_CLIP_SECONDS
167
+ : audioDurationSeconds > 0
168
+ ? clamp(
169
+ Math.ceil(audioDurationSeconds / videoPaths.length),
170
+ MIN_VIDEO_CLIP_SECONDS,
171
+ MAX_VIDEO_CLIP_SECONDS,
172
+ )
173
+ : MIN_VIDEO_CLIP_SECONDS;
174
  const cappedVideoDurations = videoDurations.map((duration) =>
175
  duration > 0 ? Math.min(duration, videoClipSeconds) : videoClipSeconds,
176
  );
177
  const baseVideoDurationSeconds = cappedVideoDurations.reduce((total, duration) => total + duration, 0);
178
  const renderTargetSeconds =
179
+ hasVoiceover ? FINAL_VIDEO_MAX_SECONDS : Math.max(1, baseVideoDurationSeconds);
 
 
180
  const loopFrameCount = Math.max(1, Math.ceil(baseVideoDurationSeconds * VIDEO_FPS));
 
181
 
182
  const inputArgs = [
183
  ...videoPaths.flatMap((filePath) => [
 
206
  .join(';');
207
  const videoInputs = videoPaths.map((_, i) => `[v${i}]`).join('');
208
  const videoConcat = `${videoInputs}concat=n=${videoPaths.length}:v=1:a=0[vcat]`;
209
+ const videoFinalize = hasVoiceover
210
  ? `[vcat]loop=loop=-1:size=${loopFrameCount}:start=0,trim=duration=${formatDuration(
211
  renderTargetSeconds,
212
  )},setpts=PTS-STARTPTS[v]`
 
223
  .join(';');
224
  const audioInputs = audioPaths.map((_, i) => `[a${i}]`).join('');
225
  const audioConcat = audioPaths.length
226
+ ? `${audioInputs}concat=n=${audioPaths.length}:v=0:a=1[acat]`
227
+ : '';
228
+ const audioFinalize = audioPaths.length
229
+ ? `[acat]atrim=duration=${FINAL_VIDEO_MAX_SECONDS},asetpts=PTS-STARTPTS[a]`
230
  : '';
231
 
232
+ // Cap every voiceover render to a short-form 17 seconds. The b-roll loops
233
+ // only to that cap, and -shortest ends the file with the actual audio.
234
+ const filterComplex = [videoFilters, videoConcat, videoFinalize, audioFilters, audioConcat, audioFinalize]
235
  .filter(Boolean)
236
  .join(';');
237
 
 
257
  '30',
258
  '-r',
259
  String(VIDEO_FPS),
260
+ ...(audioPaths.length ? ['-shortest', '-c:a', 'aac', '-b:a', '96k'] : ['-an']),
261
  '-movflags',
262
  '+faststart',
263
  '-avoid_negative_ts',
src/lib/voiceover.ts CHANGED
@@ -12,6 +12,8 @@ export type VoiceoverResult = {
12
  filename: string;
13
  };
14
 
 
 
15
  async function createFFmpeg(): Promise<FFmpeg> {
16
  const ffmpeg = new FFmpeg();
17
  const coreBaseUrl = 'https://unpkg.com/@ffmpeg/core@0.12.9/dist/umd';
@@ -84,7 +86,10 @@ export async function addVoiceoverToVideo(
84
  .map((_, i) => `[${i}:a]aresample=48000,asetpts=PTS-STARTPTS[a${i}]`)
85
  .join(';');
86
  const concatInputs = audioFiles.map((_, i) => `[a${i}]`).join('');
87
- const filterComplex = `${normalizedAudio};${concatInputs}concat=n=${audioFiles.length}:v=0:a=1[a]`;
 
 
 
88
 
89
  await runOrThrow([
90
  '-y',
@@ -110,6 +115,9 @@ export async function addVoiceoverToVideo(
110
  '0:v:0',
111
  '-map',
112
  '1:a:0',
 
 
 
113
  '-c:v',
114
  'copy',
115
  '-c:a',
 
12
  filename: string;
13
  };
14
 
15
+ const FINAL_VIDEO_MAX_SECONDS = 17;
16
+
17
  async function createFFmpeg(): Promise<FFmpeg> {
18
  const ffmpeg = new FFmpeg();
19
  const coreBaseUrl = 'https://unpkg.com/@ffmpeg/core@0.12.9/dist/umd';
 
86
  .map((_, i) => `[${i}:a]aresample=48000,asetpts=PTS-STARTPTS[a${i}]`)
87
  .join(';');
88
  const concatInputs = audioFiles.map((_, i) => `[a${i}]`).join('');
89
+ const filterComplex =
90
+ `${normalizedAudio};` +
91
+ `${concatInputs}concat=n=${audioFiles.length}:v=0:a=1,` +
92
+ `atrim=duration=${FINAL_VIDEO_MAX_SECONDS},asetpts=PTS-STARTPTS[a]`;
93
 
94
  await runOrThrow([
95
  '-y',
 
115
  '0:v:0',
116
  '-map',
117
  '1:a:0',
118
+ '-t',
119
+ String(FINAL_VIDEO_MAX_SECONDS),
120
+ '-shortest',
121
  '-c:v',
122
  'copy',
123
  '-c:a',