@@ -9,6 +9,8 @@ import express from "express";
99import multer from "multer" ;
1010import type { ClipRotation , OutputSettings } from "../shared/types" ;
1111import { effectiveDuration } from "../shared/trimSegments" ;
12+ import { normalizeOutputSettings } from "../shared/mediaUtils" ;
13+ import { createCrossfadeConcatArgs , segmentOutputDuration , type CrossfadeSegment } from "../processing/ffmpegSegments" ;
1214import {
1315 createRemuxSegmentsArgs ,
1416 createSegmentArgs ,
@@ -33,7 +35,8 @@ const upload = multer({
3335const 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
3942const 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+
538593function 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
566621function parseRotations ( rawRotations : unknown , length : number ) : ClipRotation [ ] {
0 commit comments