Skip to content

Commit cc440e0

Browse files
authored
Merge pull request #3 from Nackapatz/claude/video-merger-migration-bw4wib
Add composition preview, bug fixes and styles
2 parents 1989cf1 + cc447b8 commit cc440e0

18 files changed

Lines changed: 3742 additions & 223 deletions

src/backend/ffmpegCommands.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,13 @@ import {
77
getVideoBitrate
88
} from "../processing/ffmpegSegments";
99

10-
export interface BackendClip {
10+
export interface BackendClip extends ClipEffects {
1111
inputPath: string;
1212
metadata: VideoMetadata;
1313
rotation: ClipRotation;
1414
trimSegments?: TrimSegment[];
15-
volume: number;
16-
muted: boolean;
17-
speed: number;
18-
fadeIn: number;
19-
fadeOut: number;
20-
colorAdjust: ColorAdjust;
15+
/** Absolute path to a pre-rendered text-overlay PNG uploaded by the client. */
16+
overlayPath?: string;
2117
}
2218

2319
export interface ClipEffects {
@@ -27,6 +23,9 @@ export interface ClipEffects {
2723
fadeIn: number;
2824
fadeOut: number;
2925
colorAdjust: ColorAdjust;
26+
reversed: boolean;
27+
freezeFrame: number;
28+
crossfadeAfter: number;
3029
}
3130

3231
export function parseClipEffects(rawEffects: unknown, length: number): ClipEffects[] {
@@ -37,7 +36,10 @@ export function parseClipEffects(rawEffects: unknown, length: number): ClipEffec
3736
speed: 1,
3837
fadeIn: 0,
3938
fadeOut: 0,
40-
colorAdjust: { ...defaultColorAdjust }
39+
colorAdjust: { ...defaultColorAdjust },
40+
reversed: false,
41+
freezeFrame: 0,
42+
crossfadeAfter: 0
4143
}));
4244

4345
if (typeof rawEffects !== "string" || length === 0) {
@@ -65,6 +67,9 @@ function normalizeClipEffects(value: unknown): ClipEffects {
6567
speed: clampNumber(entry.speed, 0.5, 2, 1),
6668
fadeIn: clampNumber(entry.fadeIn, 0, 30, 0),
6769
fadeOut: clampNumber(entry.fadeOut, 0, 30, 0),
70+
reversed: entry.reversed === true,
71+
freezeFrame: clampNumber(entry.freezeFrame, 0, 30, 0),
72+
crossfadeAfter: clampNumber(entry.crossfadeAfter, 0, 5, 0),
6873
colorAdjust: {
6974
brightness: clampNumber(colorAdjustRaw.brightness, -1, 1, 0),
7075
contrast: clampNumber(colorAdjustRaw.contrast, 0, 3, 1),

src/backend/server.ts

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import express from "express";
99
import multer from "multer";
1010
import type { ClipRotation, OutputSettings } from "../shared/types";
1111
import { effectiveDuration } from "../shared/trimSegments";
12+
import { normalizeOutputSettings } from "../shared/mediaUtils";
13+
import { createCrossfadeConcatArgs, segmentOutputDuration, type CrossfadeSegment } from "../processing/ffmpegSegments";
1214
import {
1315
createRemuxSegmentsArgs,
1416
createSegmentArgs,
@@ -33,7 +35,8 @@ const upload = multer({
3335
const uploadFields = upload.fields([
3436
{ name: "videos" },
3537
{ name: "watermark", maxCount: 1 },
36-
{ name: "music", maxCount: 1 }
38+
{ name: "music", maxCount: 1 },
39+
{ name: "overlays" }
3740
]);
3841

3942
const app = express();
@@ -105,7 +108,13 @@ app.post("/api/merge", uploadFields, async (request, response) => {
105108
const files = filesByField.videos ?? [];
106109
const watermarkFile = filesByField.watermark?.[0];
107110
const musicFile = filesByField.music?.[0];
108-
const allUploadedFiles = [...files, ...(watermarkFile ? [watermarkFile] : []), ...(musicFile ? [musicFile] : [])];
111+
const overlayFiles = filesByField.overlays ?? [];
112+
const allUploadedFiles = [
113+
...files,
114+
...(watermarkFile ? [watermarkFile] : []),
115+
...(musicFile ? [musicFile] : []),
116+
...overlayFiles
117+
];
109118

110119
if (files.length === 0) {
111120
await cleanupFiles(allUploadedFiles);
@@ -133,6 +142,8 @@ app.post("/api/merge", uploadFields, async (request, response) => {
133142
const remuxPath = path.join(workDir, "remuxed-video.mp4");
134143
const outputPath = path.join(workDir, "merged-video.mp4");
135144

145+
const overlayByClipIndex = mapOverlayFiles(request.body.overlayIndexes, overlayFiles);
146+
136147
const clips: BackendClip[] = [];
137148
for (const [index, file] of files.entries()) {
138149
try {
@@ -142,6 +153,7 @@ app.post("/api/merge", uploadFields, async (request, response) => {
142153
metadata,
143154
rotation: rotations[index],
144155
trimSegments: trimSegmentsList[index],
156+
overlayPath: overlayByClipIndex.get(index),
145157
...clipEffects[index]
146158
});
147159
} catch (probeError) {
@@ -151,10 +163,13 @@ app.post("/api/merge", uploadFields, async (request, response) => {
151163
}
152164
}
153165

154-
const totalDuration = clips.reduce(
155-
(sum, clip) => sum + Math.max(0.1, effectiveDuration(clip)) / clip.speed,
156-
0
157-
);
166+
const totalDuration = clips.reduce((sum, clip, index) => {
167+
let clipDuration = Math.max(0.1, effectiveDuration(clip)) / clip.speed + clip.freezeFrame;
168+
if (index < clips.length - 1) {
169+
clipDuration -= Math.max(0, Math.min(clip.crossfadeAfter, 5));
170+
}
171+
return sum + clipDuration;
172+
}, 0);
158173
const segmentPaths = clips.map((_, index) => path.join(workDir, `segment-${index}.ts`));
159174

160175
const job: MergeJob = {
@@ -389,7 +404,9 @@ async function runMergeJob(job: MergeJob): Promise<void> {
389404
emitJobEvent(job, { type: "progress", progress: buildProgressSnapshot(job) });
390405

391406
const item = makeVideoItemForFilter(clip);
392-
const args = createSegmentArgs(clip.inputPath, segmentPath, item, job.settings);
407+
const args = createSegmentArgs(clip.inputPath, segmentPath, item, job.settings, {
408+
textOverlayPath: clip.overlayPath
409+
});
393410

394411
await runFfmpegWithProgress(args, {
395412
onChild: (child) => {
@@ -410,7 +427,19 @@ async function runMergeJob(job: MergeJob): Promise<void> {
410427
emitJobEvent(job, { type: "progress", progress: buildProgressSnapshot(job) });
411428

412429
const needsPost = Boolean(job.watermarkPath || job.musicPath);
413-
await runFfmpegWithProgress(createRemuxSegmentsArgs(job.segmentPaths, needsPost ? job.remuxPath : job.outputPath), {
430+
const hasCrossfades = job.clips.length > 1 && job.clips.slice(0, -1).some((clip) => clip.crossfadeAfter > 0.01);
431+
const muxArgs = hasCrossfades
432+
? createCrossfadeConcatArgs(
433+
job.clips.map((clip, index): CrossfadeSegment => ({
434+
path: job.segmentPaths[index],
435+
duration: segmentOutputDuration(makeVideoItemForFilter(clip)),
436+
crossfadeAfter: clip.crossfadeAfter
437+
})),
438+
needsPost ? job.remuxPath : job.outputPath,
439+
job.settings
440+
)
441+
: createRemuxSegmentsArgs(job.segmentPaths, needsPost ? job.remuxPath : job.outputPath);
442+
await runFfmpegWithProgress(muxArgs, {
414443
onChild: (child) => {
415444
job.ffmpegChild = child;
416445
},
@@ -531,10 +560,36 @@ function makeVideoItemForFilter(clip: BackendClip) {
531560
speed: clip.speed,
532561
fadeIn: clip.fadeIn,
533562
fadeOut: clip.fadeOut,
534-
colorAdjust: clip.colorAdjust
563+
colorAdjust: clip.colorAdjust,
564+
reversed: clip.reversed,
565+
freezeFrame: clip.freezeFrame,
566+
crossfadeAfter: clip.crossfadeAfter
535567
};
536568
}
537569

570+
function mapOverlayFiles(rawIndexes: unknown, overlayFiles: Express.Multer.File[]): Map<number, string> {
571+
const result = new Map<number, string>();
572+
if (typeof rawIndexes !== "string" || overlayFiles.length === 0) {
573+
return result;
574+
}
575+
try {
576+
const parsed = JSON.parse(rawIndexes) as unknown;
577+
if (!Array.isArray(parsed)) {
578+
return result;
579+
}
580+
parsed.forEach((value, position) => {
581+
const clipIndex = Number(value);
582+
const file = overlayFiles[position];
583+
if (Number.isInteger(clipIndex) && clipIndex >= 0 && file) {
584+
result.set(clipIndex, file.path);
585+
}
586+
});
587+
} catch {
588+
// Ignore malformed overlay metadata; clips simply render without overlays.
589+
}
590+
return result;
591+
}
592+
538593
function clampBody(value: unknown, min: number, max: number, fallback: number): number {
539594
const numeric = typeof value === "string" ? Number(value) : NaN;
540595
if (!Number.isFinite(numeric)) {
@@ -560,7 +615,7 @@ function parseSettings(rawSettings: unknown): OutputSettings {
560615
throw new Error("Invalid output settings.");
561616
}
562617

563-
return settings;
618+
return normalizeOutputSettings(settings);
564619
}
565620

566621
function parseRotations(rawRotations: unknown, length: number): ClipRotation[] {

0 commit comments

Comments
 (0)