@@ -95,6 +95,13 @@ on_exit() {
9595 restart_capture " $MATCH_ID " || true
9696 LIVE_CAPTURE_STOPPED=0
9797 fi
98+ # Backgrounded chip render — kill it if we're exiting before the
99+ # polish pass had a chance to wait on it (e.g. cs2 stall, SIGTERM).
100+ if [ -n " ${CHIP_RENDER_PID:- } " ] && kill -0 " $CHIP_RENDER_PID " 2> /dev/null; then
101+ kill -TERM " $CHIP_RENDER_PID " 2> /dev/null || true
102+ wait " $CHIP_RENDER_PID " 2> /dev/null || true
103+ fi
104+ [ -n " ${CHIP_RENDER_LOG:- } " ] && rm -f " $CHIP_RENDER_LOG "
98105 # ProRes intermediates are ~20MB/s — drop the chip mov even on
99106 # error so a flapping pod doesn't fill its scratch dir.
100107 if [ -n " ${CHIP_MOV:- } " ]; then rm -f " $CHIP_MOV " ; fi
@@ -345,8 +352,11 @@ MOTION_DIR="${MOTION_DIR:-/opt/game-streamer/motion}"
345352if [ -n " $CHIP_NAME " ] && [ ! -d " $MOTION_DIR " ]; then
346353 say " WARN motion project missing at $MOTION_DIR — skipping chip"
347354fi
355+ CHIP_RENDER_PID=" "
356+ CHIP_RENDER_LOG=" "
348357if [ -n " $CHIP_NAME " ] && [ -d " $MOTION_DIR " ]; then
349358 CHIP_MOV=" ${CLIP_OUT_DIR:-/ tmp/ game-streamer/ clips} /${CLIP_RENDER_JOB_ID} -chip.mov"
359+ CHIP_RENDER_LOG=" ${CHIP_MOV} .log"
350360 mkdir -p " $( dirname " $CHIP_MOV " ) "
351361 CHIP_PROPS=$( CHIP_NAME=" $CHIP_NAME " \
352362 CHIP_AVATAR=" $CHIP_AVATAR " \
@@ -367,26 +377,63 @@ if [ -n "$CHIP_NAME" ] && [ -d "$MOTION_DIR" ]; then
367377 height: Number(process.env.CHIP_OUT_H),
368378 fps: Number(process.env.CHIP_OUT_FPS),
369379 }))' )
370- say " CHIP: rendering for '${CHIP_NAME} '"
371- if ! (cd " $MOTION_DIR " && \
372- node node_modules/.bin/remotion render \
373- src/index.ts PlayerChip " $CHIP_MOV " \
374- --codec=prores --prores-profile=4444 \
375- --pixel-format=yuva444p10le --image-format=png \
376- --log=error \
377- --props=" $CHIP_PROPS " ); then
380+ say " CHIP: rendering for '${CHIP_NAME} ' (background)"
381+ # Remotion render runs in parallel with the segment seek + capture.
382+ # The chip mov is only consumed by the per-segment polish pass; we
383+ # wait_for_chip_render before that block. Backgrounding overlaps the
384+ # ~1-3s Chromium render with the ~3-10s capture wallclock.
385+ (
386+ cd " $MOTION_DIR " && \
387+ node node_modules/.bin/remotion render \
388+ src/index.ts PlayerChip " $CHIP_MOV " \
389+ --codec=prores --prores-profile=4444 \
390+ --pixel-format=yuva444p10le --image-format=png \
391+ --log=error \
392+ --props=" $CHIP_PROPS "
393+ ) > " $CHIP_RENDER_LOG " 2>&1 &
394+ CHIP_RENDER_PID=$!
395+ fi
396+
397+ wait_for_chip_render () {
398+ [ -z " $CHIP_RENDER_PID " ] && return 0
399+ if ! wait " $CHIP_RENDER_PID " ; then
378400 say " WARN chip render failed — continuing without chip overlay"
401+ [ -n " $CHIP_RENDER_LOG " ] && [ -s " $CHIP_RENDER_LOG " ] \
402+ && sed ' s/^/ chip: /' " $CHIP_RENDER_LOG " >&2
379403 rm -f " $CHIP_MOV "
380404 CHIP_MOV=" "
381405 fi
382- fi
406+ rm -f " $CHIP_RENDER_LOG "
407+ CHIP_RENDER_PID=" "
408+ }
383409
384410CLIP_OUT_DIR=" ${CLIP_OUT_DIR:-/ tmp/ game-streamer/ clips} "
385411mkdir -p " $CLIP_OUT_DIR "
386412CLIP_OUT_FILE=" ${CLIP_OUT_DIR} /${CLIP_RENDER_JOB_ID} .mp4"
387413CLIP_THUMB_FILE=" ${CLIP_OUT_DIR} /${CLIP_RENDER_JOB_ID} .jpg"
388414rm -f " $CLIP_OUT_FILE " " $CLIP_THUMB_FILE "
389415
416+ # Precompute: will an outro be appended at concat time? If yes AND we
417+ # would have run a per-segment polish pass (chip or speed change), we
418+ # can fuse both into a single ffmpeg encode at the end — eliminating
419+ # one full 1080p60 NVENC pass per clip. The polish-skip gate below
420+ # reads OUTRO_WILL_APPEND; the fused encode reads it at concat time.
421+ OUTRO_WILL_APPEND=0
422+ OUTRO_FUSED_FILE=" "
423+ if [ " $BRANDING_ENABLED " = " 1" ] && [ " ${CLIP_DISABLE_OUTRO:- 0} " != " 1" ]; then
424+ OUTRO_DIMS_PRE=" ${CLIP_OUTPUT_DIMS:- 1920x1080} "
425+ OUTRO_FPS_PRE=" ${CLIP_OUTPUT_FPS:- 60} "
426+ OUTRO_FUSED_FILE=" ${OUTRO_DIR:-/ opt/ game-streamer/ resources/ video} /outro_${OUTRO_DIMS_PRE} _${OUTRO_FPS_PRE} .mp4"
427+ if [ -f " $OUTRO_FUSED_FILE " ]; then
428+ OUTRO_WILL_APPEND=1
429+ fi
430+ fi
431+ WILL_FUSE_POLISH_OUTRO=0
432+ if [ " $OUTRO_WILL_APPEND " = " 1" ] \
433+ && { [ " $CLIP_RENDER_SPEED " != " 1" ] || [ -n " $CHIP_NAME " ]; }; then
434+ WILL_FUSE_POLISH_OUTRO=1
435+ fi
436+
390437# Per-segment output paths + concat list. We render each segment to
391438# its own file and let ffmpeg concat-demux glue them — this keeps each
392439# capture session independent (a stall in one doesn't ruin the rest)
@@ -538,8 +585,13 @@ for SEG_IDX in $(seq 0 $((SEG_COUNT - 1))); do
538585 # Per-segment polish pass — combines slowdown (when CLIP_RENDER_SPEED
539586 # != 1) and chip overlay (when CHIP_MOV is set) into a single ffmpeg
540587 # call so we don't re-encode twice. Skipped when neither applies so
541- # the speed=1 + no-chip path keeps gstreamer's capture intact.
542- if [ " $CLIP_RENDER_SPEED " != " 1" ] || [ -n " $CHIP_MOV " ]; then
588+ # the speed=1 + no-chip path keeps gstreamer's capture intact. Also
589+ # skipped when WILL_FUSE_POLISH_OUTRO=1 — the chip/slowdown gets
590+ # baked into the same filter_complex as the outro concat, saving
591+ # one full NVENC encode per clip.
592+ wait_for_chip_render
593+ if [ " $WILL_FUSE_POLISH_OUTRO " != " 1" ] \
594+ && { [ " $CLIP_RENDER_SPEED " != " 1" ] || [ -n " $CHIP_MOV " ]; }; then
543595 HAS_AUDIO=0
544596 if has_audio_stream " $SEG_FILE " ; then HAS_AUDIO=1; fi
545597 POLISH_FILE=" ${SEG_FILE} .polish.mp4"
@@ -669,18 +721,87 @@ elif [ "$OUTRO_APPENDED" = "1" ]; then
669721 # captured segments carry trailing PTS that pushes the outro ~30s
670722 # past with -c copy. Filter-graph concat is the reliable splice
671723 # across heterogeneous sources.
672- say " STEP 9: ffmpeg concat ${SEG_COUNT} segments (with outro, filter-graph)"
724+ #
725+ # WILL_FUSE_POLISH_OUTRO=1: the per-segment polish pass was skipped,
726+ # so the chip overlay + slowdown gets folded into this same encode
727+ # — one NVENC pass instead of two (polish-per-segment + concat).
728+ CAP_SEG_COUNT=$(( SEG_COUNT - 1 )) # last entry in concat.txt is outro
673729 CONCAT_INPUTS=()
674730 while IFS= read -r line; do
675731 f=$( printf ' %s' " $line " | awk -F" '" ' /^file/{print $2}' )
676732 [ -n " $f " ] && CONCAT_INPUTS+=(" -i" " $f " )
677733 done < " $SEG_DIR /concat.txt"
678- N= ${ # SEG_COUNT}
734+
679735 FC=" "
680- for i in $( seq 0 $(( SEG_COUNT - 1 )) ) ; do
681- FC+=" [${i} :v:0][${i} :a:0]"
682- done
683- FC+=" concat=n=${SEG_COUNT} :v=1:a=1[v][a]"
736+ if [ " $WILL_FUSE_POLISH_OUTRO " != " 1" ]; then
737+ # Segments were already polished per-segment; simple concat-only graph.
738+ say " STEP 9: ffmpeg concat ${SEG_COUNT} segments (with outro, filter-graph)"
739+ for i in $( seq 0 $(( SEG_COUNT - 1 )) ) ; do
740+ FC+=" [${i} :v:0][${i} :a:0]"
741+ done
742+ FC+=" concat=n=${SEG_COUNT} :v=1:a=1[v][a]"
743+ else
744+ # Fused path: bake chip overlay + slowdown into the same encode as
745+ # the outro concat — one NVENC pass instead of two.
746+ say " STEP 9: ffmpeg fused polish+concat ${CAP_SEG_COUNT} seg(s) + outro"
747+
748+ # Chip is appended as one extra input after segments+outro. Split it
749+ # once per captured segment when there's more than one segment (so
750+ # each gets its own ~3.5s chip head, matching the per-segment polish
751+ # behaviour). split=1 isn't valid, so single-segment skips the split.
752+ if [ -n " $CHIP_MOV " ]; then
753+ CHIP_IDX=$SEG_COUNT
754+ CONCAT_INPUTS+=(" -i" " $CHIP_MOV " )
755+ if [ " $CAP_SEG_COUNT " -gt 1 ]; then
756+ FC+=" [${CHIP_IDX} :v]split=${CAP_SEG_COUNT} "
757+ for i in $( seq 0 $(( CAP_SEG_COUNT - 1 )) ) ; do FC+=" [chip${i} ]" ; done
758+ FC+=" ;"
759+ fi
760+ fi
761+
762+ if [ " $CLIP_RENDER_SPEED " != " 1" ]; then
763+ case " $CLIP_RENDER_SPEED " in
764+ 2) ATEMPO_CHAIN=" atempo=0.5" ;;
765+ 3) ATEMPO_CHAIN=" atempo=0.5,atempo=0.667" ;;
766+ 4) ATEMPO_CHAIN=" atempo=0.5,atempo=0.5" ;;
767+ * ) ATEMPO_CHAIN=" atempo=0.5" ;;
768+ esac
769+ else
770+ ATEMPO_CHAIN=" "
771+ fi
772+
773+ for i in $( seq 0 $(( CAP_SEG_COUNT - 1 )) ) ; do
774+ if [ -n " $CHIP_MOV " ]; then
775+ if [ " $CAP_SEG_COUNT " -gt 1 ]; then
776+ FC+=" [${i} :v][chip${i} ]overlay=0:0:eof_action=pass:format=auto"
777+ else
778+ FC+=" [${i} :v][${CHIP_IDX} :v]overlay=0:0:eof_action=pass:format=auto"
779+ fi
780+ else
781+ FC+=" [${i} :v]null"
782+ fi
783+ if [ " $CLIP_RENDER_SPEED " != " 1" ]; then
784+ FC+=" ,setpts=${CLIP_RENDER_SPEED} *PTS"
785+ fi
786+ FC+=" [v${i} ];"
787+ if [ -n " $ATEMPO_CHAIN " ]; then
788+ FC+=" [${i} :a]${ATEMPO_CHAIN} [a${i} ];"
789+ fi
790+ done
791+
792+ # Final concat: per-segment polished streams + raw outro streams.
793+ for i in $( seq 0 $(( CAP_SEG_COUNT - 1 )) ) ; do
794+ FC+=" [v${i} ]"
795+ if [ -n " $ATEMPO_CHAIN " ]; then
796+ FC+=" [a${i} ]"
797+ else
798+ FC+=" [${i} :a]"
799+ fi
800+ done
801+ FC+=" [${CAP_SEG_COUNT} :v][${CAP_SEG_COUNT} :a]"
802+ FC+=" concat=n=${SEG_COUNT} :v=1:a=1[v][a]"
803+ fi
804+
684805 if ! ffmpeg -y -hide_banner -loglevel warning \
685806 " ${CONCAT_INPUTS[@]} " \
686807 -filter_complex " $FC " \
@@ -742,25 +863,32 @@ THUMB_DURATION_SECS=$(awk -v ms="$REAL_DURATION_MS" 'BEGIN{printf "%.3f", ms/100
742863if awk -v d=" $THUMB_DURATION_SECS " -v t=" $THUMB_SEEK_SECS " ' BEGIN{exit !(d <= t)}' ; then
743864 THUMB_SEEK_SECS=$( awk -v d=" $THUMB_DURATION_SECS " ' BEGIN{printf "%.3f", d/2}' )
744865fi
745- if ffmpeg -y -hide_banner -loglevel warning \
746- -ss " $THUMB_SEEK_SECS " -i " $CLIP_OUT_FILE " -frames:v 1 -q:v 3 \
747- " $CLIP_THUMB_FILE " 2> /dev/null \
748- && [ -s " $CLIP_THUMB_FILE " ]; then
749- THUMB_URL=" ${STATUS_API_BASE} /clip-renders/${CLIP_RENDER_JOB_ID} /thumbnail"
750- say " POST $THUMB_URL "
751- if ! curl --fail --silent --show-error \
752- --max-time 60 \
753- --header " x-origin-auth: ${CLIP_RENDER_JOB_ID} :${CLIP_RENDER_TOKEN} " \
754- --header " content-type: image/jpeg" \
755- --data-binary " @${CLIP_THUMB_FILE} " \
756- --output /dev/null \
757- " $THUMB_URL " ; then
758- say " WARN thumbnail upload failed — continuing without thumbnail"
866+
867+ # Thumbnail extract + POST runs in parallel with the clip upload —
868+ # both read $CLIP_OUT_FILE independently. The thumb POST is
869+ # best-effort (no die_failed), so failures only warn.
870+ THUMB_URL=" ${STATUS_API_BASE} /clip-renders/${CLIP_RENDER_JOB_ID} /thumbnail"
871+ say " thumbnail extract + POST $THUMB_URL (background)"
872+ (
873+ if ffmpeg -y -hide_banner -loglevel warning \
874+ -ss " $THUMB_SEEK_SECS " -i " $CLIP_OUT_FILE " -frames:v 1 -q:v 3 \
875+ " $CLIP_THUMB_FILE " 2> /dev/null \
876+ && [ -s " $CLIP_THUMB_FILE " ]; then
877+ if ! curl --fail --silent --show-error \
878+ --max-time 60 \
879+ --header " x-origin-auth: ${CLIP_RENDER_JOB_ID} :${CLIP_RENDER_TOKEN} " \
880+ --header " content-type: image/jpeg" \
881+ --data-binary " @${CLIP_THUMB_FILE} " \
882+ --output /dev/null \
883+ " $THUMB_URL " ; then
884+ say " WARN thumbnail upload failed — continuing without thumbnail"
885+ fi
886+ else
887+ say " WARN ffmpeg thumbnail extraction failed — continuing without thumbnail"
759888 fi
760- else
761- say " WARN ffmpeg thumbnail extraction failed — continuing without thumbnail"
762- fi
763- rm -f " $CLIP_THUMB_FILE "
889+ rm -f " $CLIP_THUMB_FILE "
890+ ) &
891+ THUMB_BG_PID=$!
764892
765893api_status " status=uploading" " progress=0.0"
766894UPLOAD_URL=" ${STATUS_API_BASE} /clip-renders/${CLIP_RENDER_JOB_ID} /upload"
@@ -776,6 +904,10 @@ if ! curl --fail --silent --show-error \
776904 die_failed " clip upload failed"
777905fi
778906
907+ # Thumbnail is best-effort but we still want it posted before the
908+ # pod exits (batch mode reaps the job right after status=done).
909+ wait " $THUMB_BG_PID " 2> /dev/null || true
910+
779911api_status " status=done" " progress=1.0"
780912CLIP_REACHED_TERMINAL=1
781913rm -f " $CLIP_OUT_FILE "
0 commit comments