Prevent visual looping in Classic render
Browse files
src/lib/server/serverClipRenderer.ts
CHANGED
|
@@ -25,7 +25,6 @@ const WORK_DIR = path.join(process.cwd(), '.local-review-data', 'server-renders'
|
|
| 25 |
const VIDEO_WIDTH = 1080;
|
| 26 |
const VIDEO_HEIGHT = 1920;
|
| 27 |
const VIDEO_FPS = 24;
|
| 28 |
-
const MIN_VIDEO_CLIP_SECONDS = 5;
|
| 29 |
const MAX_VIDEO_CLIP_SECONDS = 5;
|
| 30 |
const MAX_AUDIO_CLIP_SECONDS = 5;
|
| 31 |
const FINAL_VIDEO_MIN_SECONDS = 12;
|
|
@@ -213,6 +212,12 @@ function maxSecondsForStep(step: number) {
|
|
| 213 |
return STEP_VIDEO_MAX_SECONDS[step] ?? MAX_VIDEO_CLIP_SECONDS;
|
| 214 |
}
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
function addSegment(segments: VideoSegment[], source: PreparedClip | null, duration: number) {
|
| 217 |
if (!source || duration < 0.25) return;
|
| 218 |
segments.push({
|
|
@@ -221,16 +226,38 @@ function addSegment(segments: VideoSegment[], source: PreparedClip | null, durat
|
|
| 221 |
});
|
| 222 |
}
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
function buildLinearSegments(videoClips: PreparedClip[]) {
|
| 225 |
const segments: VideoSegment[] = [];
|
| 226 |
let remaining = FINAL_VIDEO_MAX_SECONDS;
|
| 227 |
|
| 228 |
for (const source of videoClips) {
|
| 229 |
if (remaining <= 0.25) break;
|
| 230 |
-
const
|
| 231 |
-
const fallbackDuration = Math.min(MIN_VIDEO_CLIP_SECONDS, stepMax);
|
| 232 |
-
const sourceDuration =
|
| 233 |
-
source.duration > 0 ? Math.min(source.duration, stepMax) : fallbackDuration;
|
| 234 |
const duration = Math.min(sourceDuration, remaining);
|
| 235 |
addSegment(segments, source, duration);
|
| 236 |
remaining -= duration;
|
|
@@ -255,11 +282,13 @@ function buildVoiceAwareSegments({
|
|
| 255 |
const actionShot = clipByStep(videoClips, 3) ?? videoClips[2] ?? wideShot;
|
| 256 |
const reactionShot =
|
| 257 |
clipByStep(videoClips, 4) ??
|
|
|
|
| 258 |
(videoClips.length > 3 ? videoClips[videoClips.length - 1]! : null);
|
| 259 |
|
| 260 |
-
const orderAudio = clipByStep(audioClips, 5) ?? audioClips[0] ?? null;
|
| 261 |
const likedAudio =
|
| 262 |
clipByStep(audioClips, 6) ??
|
|
|
|
| 263 |
audioClips.find((clip) => clip !== orderAudio) ??
|
| 264 |
null;
|
| 265 |
const recommendationAudio =
|
|
@@ -292,20 +321,34 @@ function buildVoiceAwareSegments({
|
|
| 292 |
const likedSeconds = Math.max(0, mainVoiceSeconds - orderSeconds);
|
| 293 |
|
| 294 |
const segments: VideoSegment[] = [];
|
|
|
|
| 295 |
|
| 296 |
if (closeShot === wideShot || orderSeconds < 2.5) {
|
| 297 |
-
|
| 298 |
} else {
|
| 299 |
const closeSeconds = clamp(orderSeconds * 0.58, 1.25, orderSeconds - 0.75);
|
| 300 |
-
|
| 301 |
-
|
| 302 |
}
|
| 303 |
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
return segments.length > 0 ? segments : buildLinearSegments(videoClips);
|
| 311 |
}
|
|
@@ -379,14 +422,15 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
|
|
| 379 |
renderTargetSeconds: voiceoverTargetSeconds,
|
| 380 |
})
|
| 381 |
: buildLinearSegments(preparedVideoClips);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
const renderTargetSeconds = hasVoiceover
|
| 383 |
-
? voiceoverTargetSeconds
|
| 384 |
: Math.max(
|
| 385 |
1,
|
| 386 |
-
Math.min(
|
| 387 |
-
FINAL_VIDEO_MAX_SECONDS,
|
| 388 |
-
videoSegments.reduce((total, segment) => total + segment.duration, 0),
|
| 389 |
-
),
|
| 390 |
);
|
| 391 |
|
| 392 |
if (videoSegments.length === 0) {
|
|
@@ -395,8 +439,6 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
|
|
| 395 |
|
| 396 |
const inputArgs = [
|
| 397 |
...videoSegments.flatMap((segment) => [
|
| 398 |
-
'-stream_loop',
|
| 399 |
-
'-1',
|
| 400 |
'-t',
|
| 401 |
formatDuration(segment.duration),
|
| 402 |
'-i',
|
|
|
|
| 25 |
const VIDEO_WIDTH = 1080;
|
| 26 |
const VIDEO_HEIGHT = 1920;
|
| 27 |
const VIDEO_FPS = 24;
|
|
|
|
| 28 |
const MAX_VIDEO_CLIP_SECONDS = 5;
|
| 29 |
const MAX_AUDIO_CLIP_SECONDS = 5;
|
| 30 |
const FINAL_VIDEO_MIN_SECONDS = 12;
|
|
|
|
| 212 |
return STEP_VIDEO_MAX_SECONDS[step] ?? MAX_VIDEO_CLIP_SECONDS;
|
| 213 |
}
|
| 214 |
|
| 215 |
+
function usableClipDuration(source: PreparedClip) {
|
| 216 |
+
const stepMax = maxSecondsForStep(source.step);
|
| 217 |
+
if (source.duration > 0) return Math.min(source.duration, stepMax);
|
| 218 |
+
return stepMax;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
function addSegment(segments: VideoSegment[], source: PreparedClip | null, duration: number) {
|
| 222 |
if (!source || duration < 0.25) return;
|
| 223 |
segments.push({
|
|
|
|
| 226 |
});
|
| 227 |
}
|
| 228 |
|
| 229 |
+
function createClipBudgets(videoClips: PreparedClip[]) {
|
| 230 |
+
const budgets = new Map<string, number>();
|
| 231 |
+
for (const clip of videoClips) {
|
| 232 |
+
budgets.set(clip.path, Math.max(budgets.get(clip.path) ?? 0, usableClipDuration(clip)));
|
| 233 |
+
}
|
| 234 |
+
return budgets;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
function addSegmentWithBudget(
|
| 238 |
+
segments: VideoSegment[],
|
| 239 |
+
budgets: Map<string, number>,
|
| 240 |
+
source: PreparedClip | null,
|
| 241 |
+
duration: number,
|
| 242 |
+
) {
|
| 243 |
+
if (!source || duration < 0.25) return 0;
|
| 244 |
+
|
| 245 |
+
const remaining = budgets.get(source.path) ?? usableClipDuration(source);
|
| 246 |
+
const actualDuration = Math.min(duration, remaining);
|
| 247 |
+
if (actualDuration < 0.25) return 0;
|
| 248 |
+
|
| 249 |
+
budgets.set(source.path, Math.max(0, remaining - actualDuration));
|
| 250 |
+
addSegment(segments, source, actualDuration);
|
| 251 |
+
return actualDuration;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
function buildLinearSegments(videoClips: PreparedClip[]) {
|
| 255 |
const segments: VideoSegment[] = [];
|
| 256 |
let remaining = FINAL_VIDEO_MAX_SECONDS;
|
| 257 |
|
| 258 |
for (const source of videoClips) {
|
| 259 |
if (remaining <= 0.25) break;
|
| 260 |
+
const sourceDuration = usableClipDuration(source);
|
|
|
|
|
|
|
|
|
|
| 261 |
const duration = Math.min(sourceDuration, remaining);
|
| 262 |
addSegment(segments, source, duration);
|
| 263 |
remaining -= duration;
|
|
|
|
| 282 |
const actionShot = clipByStep(videoClips, 3) ?? videoClips[2] ?? wideShot;
|
| 283 |
const reactionShot =
|
| 284 |
clipByStep(videoClips, 4) ??
|
| 285 |
+
videoClips.find((clip) => clip.step >= 6) ??
|
| 286 |
(videoClips.length > 3 ? videoClips[videoClips.length - 1]! : null);
|
| 287 |
|
| 288 |
+
const orderAudio = clipByStep(audioClips, 5) ?? clipByStep(audioClips, 4) ?? audioClips[0] ?? null;
|
| 289 |
const likedAudio =
|
| 290 |
clipByStep(audioClips, 6) ??
|
| 291 |
+
clipByStep(audioClips, 5) ??
|
| 292 |
audioClips.find((clip) => clip !== orderAudio) ??
|
| 293 |
null;
|
| 294 |
const recommendationAudio =
|
|
|
|
| 321 |
const likedSeconds = Math.max(0, mainVoiceSeconds - orderSeconds);
|
| 322 |
|
| 323 |
const segments: VideoSegment[] = [];
|
| 324 |
+
const budgets = createClipBudgets(videoClips);
|
| 325 |
|
| 326 |
if (closeShot === wideShot || orderSeconds < 2.5) {
|
| 327 |
+
addSegmentWithBudget(segments, budgets, closeShot, orderSeconds);
|
| 328 |
} else {
|
| 329 |
const closeSeconds = clamp(orderSeconds * 0.58, 1.25, orderSeconds - 0.75);
|
| 330 |
+
addSegmentWithBudget(segments, budgets, closeShot, closeSeconds);
|
| 331 |
+
addSegmentWithBudget(segments, budgets, wideShot, orderSeconds - closeSeconds);
|
| 332 |
}
|
| 333 |
|
| 334 |
+
addSegmentWithBudget(segments, budgets, actionShot, likedSeconds);
|
| 335 |
+
|
| 336 |
+
let remainingRecommendationSeconds = recommendationSeconds;
|
| 337 |
+
remainingRecommendationSeconds -= addSegmentWithBudget(
|
| 338 |
+
segments,
|
| 339 |
+
budgets,
|
| 340 |
+
wideShot,
|
| 341 |
+
remainingRecommendationSeconds,
|
| 342 |
+
);
|
| 343 |
+
remainingRecommendationSeconds -= addSegmentWithBudget(
|
| 344 |
+
segments,
|
| 345 |
+
budgets,
|
| 346 |
+
actionShot,
|
| 347 |
+
remainingRecommendationSeconds,
|
| 348 |
+
);
|
| 349 |
+
addSegmentWithBudget(segments, budgets, closeShot, remainingRecommendationSeconds);
|
| 350 |
+
|
| 351 |
+
addSegmentWithBudget(segments, budgets, reactionShot, reactionSeconds);
|
| 352 |
|
| 353 |
return segments.length > 0 ? segments : buildLinearSegments(videoClips);
|
| 354 |
}
|
|
|
|
| 422 |
renderTargetSeconds: voiceoverTargetSeconds,
|
| 423 |
})
|
| 424 |
: buildLinearSegments(preparedVideoClips);
|
| 425 |
+
const visualDurationSeconds = videoSegments.reduce(
|
| 426 |
+
(total, segment) => total + segment.duration,
|
| 427 |
+
0,
|
| 428 |
+
);
|
| 429 |
const renderTargetSeconds = hasVoiceover
|
| 430 |
+
? Math.max(1, Math.min(voiceoverTargetSeconds, visualDurationSeconds))
|
| 431 |
: Math.max(
|
| 432 |
1,
|
| 433 |
+
Math.min(FINAL_VIDEO_MAX_SECONDS, visualDurationSeconds),
|
|
|
|
|
|
|
|
|
|
| 434 |
);
|
| 435 |
|
| 436 |
if (videoSegments.length === 0) {
|
|
|
|
| 439 |
|
| 440 |
const inputArgs = [
|
| 441 |
...videoSegments.flatMap((segment) => [
|
|
|
|
|
|
|
| 442 |
'-t',
|
| 443 |
formatDuration(segment.duration),
|
| 444 |
'-i',
|