3
0
Fork 0
releas-tooling/render-chunks.sh

277 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
set -o errexit -o nounset -o pipefail
IFS=$'\n'
# License MIT
# Authors 2024
# thunfisch
# iiidefix
# l3d
# This script is designed to help you automatically select the correct intro
# and outro files (prerendered with built-in fades) and the correct chunks
# of a chunked recording (e.g. OBS with automatic split every 5 minutes).
# It will open the first and last chunk in mpv if you want, so that you
# can find the start and end points (in seconds), you manually need to transfer
# them into the script for now (no idea how to convince mpv to output it).
# After that the script will render the introfile with the first chunk and the
# last chunk with the outrofile, and then in the last step assemble everything
# into the final recording with audio normalization.
# It will also assume, that the intros and outros are pictures...
# This script requires you to have ffmpeg and fzf installed.
which ffmpeg >/dev/null || (echo "Please install ffmpeg" ; exit 1)
which fzf >/dev/null || (echo "Please install fzf" ; exit 1)
which mpv >/dev/null || (echo "Please install mpv" ; exit 1)
INTROS_PATH="${HOME}/Dokumente/cloud.ctbk.de/FSCK/2025/VOC/Talk-Intros/"
OUTROS_PATH="${HOME}/Dokumente/cloud.ctbk.de/FSCK/2025/VOC/Talk-Outro/"
CHUNKS_PATH="${HOME}/Dokumente/syncthing/fsck2025/streamingrechner/Recordings/"
OUTPUT_PATH="${HOME}/Dokumente/syncthing/cwtv/cwtv-synthing/rendered/"
WORKDIR=$(mktemp -d)
function finish {
rm -r "$WORKDIR"
}
trap finish EXIT
# Function to convert image to video with audio
convert_image_to_video() {
local IMAGE_FILE="${1}"
local VIDEO_FILE="${2}"
local DURATION="${3}"
local FADE_TYPE
local FADE_START
local FADE_DURATION
if [[ "$4" == 0 ]]
then
# Fade-out
FADE_TYPE="out"
FADE_DURATION="${5}"
FADE_START="$(echo "${DURATION} - ${FADE_DURATION} - 0.02" | bc | awk '{printf "%.5f\n", $0}')"
# Note: The 0.02 = 1/50 is a hack to ensure we don't miss the last (fully black) frame
else
# Fade-in
FADE_TYPE="in"
FADE_DURATION="${4}"
FADE_START="0"
fi
echo "Rendering fade:"
echo "Type: ${FADE_TYPE}"
echo "Start: ${FADE_START}"
echo "Duration: ${FADE_DURATION}"
ffmpeg \
-loop 1 \
-framerate 50 \
-i "${IMAGE_FILE}" \
-f lavfi -i anullsrc=r=48000:cl=stereo \
-vf "fade=type=${FADE_TYPE}:start_time=${FADE_START}:duration=${FADE_DURATION},format=pix_fmts=yuv420p,fps=50" \
-c:a aac -b:a 192k \
-c:v libx264 -threads 0 -pix_fmt yuv420p -crf 18 \
-profile:v high -level 4.1 -disposition default -color_range tv \
-t "${DURATION}" "${VIDEO_FILE}"
}
# STEP 1
# Select the appropriate files
SELECTED_INTRO="$(find "$INTROS_PATH" -type f | sort --reverse --human-numeric-sort | fzf --delimiter / --with-nth -1 --prompt "Intro File:")"
SELECTED_OUTRO="$(find "$OUTROS_PATH" -type f | sort --reverse --human-numeric-sort | fzf --delimiter / --with-nth -1 --prompt "Outro File:")"
SELECTED_CHUNKS="$(find "$CHUNKS_PATH" -type f | sort --reverse | fzf --delimiter / --with-nth -1 -m --prompt "Video Chunks (use tab to select multiple):" | sort )"
readarray -t CHUNKS_ARRAY < <(echo "$SELECTED_CHUNKS")
BASEPATH="$(basename "${SELECTED_INTRO}")"
DEFAULT_OUTPUT_NAME="${BASEPATH%.*}"
read -r -p "Please enter a name for the outputfile (path and extension will be added automatically): " -i "${DEFAULT_OUTPUT_NAME}" -e OUTPUT_NAME
CONFIG_FILE="${OUTPUT_PATH}/${OUTPUT_NAME}.config"
# find the start-offset for the first chunk
read -r -p "Do you want to play the first chunk ${CHUNKS_ARRAY[0]} to find the start-offset? (y/n) [n]: " PLAY_FIRST_CHUNK
PLAY_FIRST_CHUNK="${PLAY_FIRST_CHUNK:-n}"
[[ "$PLAY_FIRST_CHUNK" == "y" ]] && mpv "${CHUNKS_ARRAY[0]}" --osd-level=3 --osd-status-msg='${=time-pos}' --really-quiet
read -r -p "Enter the start-offset in seconds for the first chunk ${CHUNKS_ARRAY[0]} [0]: " START_OFFSET
START_OFFSET="${START_OFFSET:-0}"
# find the end-offset for the last chunk
read -r -p "Do you want to play the last chunk ${CHUNKS_ARRAY[-1]} to find the end-offset? (y/n) [n]: " PLAY_LAST_CHUNK
PLAY_LAST_CHUNK="${PLAY_LAST_CHUNK:-n}"
[[ "$PLAY_LAST_CHUNK" == "y" ]] && mpv "${CHUNKS_ARRAY[-1]}" --osd-level=3 --osd-status-msg='${=time-pos}' --really-quiet
read -r -p "Enter the end-offset in seconds for the last chunk ${CHUNKS_ARRAY[0]} [1]: " END_OFFSET
END_OFFSET="${END_OFFSET:-1}"
# Check if intro is an image and convert if necessary
EXT_INTRO="${SELECTED_INTRO##*.}"
if [[ "$EXT_INTRO" == "png" || "$EXT_INTRO" == "jpg" || "$EXT_INTRO" == "jpeg" ]]; then
INTRO_VIDEO="$WORKDIR/intro_converted.mkv"
convert_image_to_video "$SELECTED_INTRO" "$INTRO_VIDEO" 4 0.5 0
SELECTED_INTRO="$INTRO_VIDEO"
fi
# Check if outro is an image and convert if necessary
EXT_OUTRO="${SELECTED_OUTRO##*.}"
if [[ "$EXT_OUTRO" == "png" || "$EXT_OUTRO" == "jpg" || "$EXT_OUTRO" == "jpeg" ]]; then
OUTRO_VIDEO="$WORKDIR/outro_converted.mkv"
convert_image_to_video "$SELECTED_OUTRO" "$OUTRO_VIDEO" 6 0 1
SELECTED_OUTRO="$OUTRO_VIDEO"
fi
# print and log choices
echo ; echo ; echo
cat <<EOT | tee "${CONFIG_FILE}"
I will be rendering with the following configuration:
+ Selected Intro: ${SELECTED_INTRO}
+ Selected Outro: ${SELECTED_OUTRO}
+ Video Chunks:
+ First chunk: ${CHUNKS_ARRAY[0]}
Starting at second ${START_OFFSET}
+ Last chunk: ${CHUNKS_ARRAY[$((${#CHUNKS_ARRAY[@]} - 1))]}
Ending at second ${END_OFFSET}
+ All chunks:
EOT
for index in "${!CHUNKS_ARRAY[@]}"
do
echo " + $index: ${CHUNKS_ARRAY[index]}" | tee -a "${CONFIG_FILE}"
done
echo "Export: ${OUTPUT_PATH}/${OUTPUT_NAME}.mkv" | tee -a "${CONFIG_FILE}"
echo "Config: ${CONFIG_FILE}" | tee -a "${CONFIG_FILE}"
echo ; echo ; echo
read -r -p "Do you want to proceed with this configuration? (y/n) [y]" PROCEED
PROCEED="${PROCEED:-y}"
[[ "$PROCEED" == "y" ]] || (echo "aborting"; exit 1)
echo "doing ffmpeg things here"
# combine the videos
ARRAY_LENGTH="${#CHUNKS_ARRAY[@]}"
if [[ ${ARRAY_LENGTH} -lt 2 ]]
then
echo "Too few chunks, this script can't handle this yet. Please do that on your own."
exit 1
fi
# STEP 2
echo "==== STEP 2 ===="
# Prüfen, ob CHUNKS_ARRAY existiert und nicht leer ist
if [[ -z "${CHUNKS_ARRAY[0]}" ]]; then
echo "Fehler: CHUNKS_ARRAY ist leer oder nicht definiert."
exit 1
fi
# Dauer des Intros ermitteln
INTRO_DURATION="$(ffprobe -i "$SELECTED_INTRO" -show_entries format=duration -v quiet -of csv="p=0")"
# Sicherstellen, dass die Dauer gültig ist
if [[ -z "$INTRO_DURATION" || "$INTRO_DURATION" == "N/A" ]]; then
echo "Fehler: Die Dauer des Intros konnte nicht ermittelt werden."
exit 1
fi
OUTRO_DURATION="$(ffprobe -i "$SELECTED_OUTRO" -show_entries format=duration -v quiet -of csv="p=0")"
# Sicherstellen, dass die Dauer gültig ist
if [[ -z "$OUTRO_DURATION" || "$OUTRO_DURATION" == "N/A" ]]; then
echo "Fehler: Die Dauer des Outros konnte nicht ermittelt werden."
exit 1
fi
CROSSFADE_DURATION="0.5"
# Offset berechnen und negative Werte verhindern
INTRO_OFFSET="$(echo "${INTRO_DURATION} - ${CROSSFADE_DURATION}" | bc | awk '{printf "%.5f\n", $0}')"
if (( $(echo "${INTRO_OFFSET} < 0" | bc) )); then INTRO_OFFSET=0; fi
OUTRO_OFFSET="$(echo "${END_OFFSET} - ${CROSSFADE_DURATION}" | bc | awk '{printf "%.5f\n", $0}')"
if (( $(echo "${OUTRO_OFFSET} < 0" | bc) )); then OUTRO_OFFSET=0; fi
# Intro mit Crossfade zum ersten Chunk
echo "Rendering intro transition:"
echo "Intro duration: ${INTRO_DURATION}"
echo "Intro offset: ${INTRO_OFFSET}"
echo "Crossfade duration: ${CROSSFADE_DURATION}"
ffmpeg \
-i "${SELECTED_INTRO}" \
-ss "${START_OFFSET}" -accurate_seek -i "${CHUNKS_ARRAY[0]}" \
-filter_complex \
"[0:v:0]format=pix_fmts=yuv420p,fps=50[vintro]; \
[1:v:0]format=pix_fmts=yuv420p,fps=50[vchunk1]; \
[vintro][vchunk1]xfade=transition=fade:duration=${CROSSFADE_DURATION}:offset=${INTRO_OFFSET}[vout]; \
[0:a:0]atrim=start=0:end=${INTRO_OFFSET}[atrim]; \
[atrim]asetpts=PTS-STARTPTS[aintro]; \
[1:a:0]afade=type=in:start_time=0:duration=${CROSSFADE_DURATION}[achunk1]; \
[aintro][achunk1]concat=n=2:v=0:a=1[aout]" \
-map '[vout]' -map '[aout]' \
-c:a aac -b:a 192k \
-c:v libx264 -threads 0 -pix_fmt yuv420p -crf 18 -profile:v high -level 4.1 -disposition default \
-metadata:s:a:0 language=native \
"${WORKDIR}/introcombined.mkv"
# Outro mit Crossfade vom letzten Chunk
echo "Rendering outro transition:"
echo "Outro duration: ${OUTRO_DURATION}"
echo "Outro offset: ${OUTRO_OFFSET}"
echo "Crossfade duration: ${CROSSFADE_DURATION}"
ffmpeg \
-i "${SELECTED_OUTRO}" \
-t "${END_OFFSET}" -accurate_seek -i "${CHUNKS_ARRAY[$((${#CHUNKS_ARRAY[@]} - 1))]}" \
-filter_complex \
"[0:v:0]format=pix_fmts=yuv420p,fps=50[voutro]; \
[1:v:0]format=pix_fmts=yuv420p,fps=50[vchunkn]; \
[vchunkn][voutro]xfade=transition=fade:duration=${CROSSFADE_DURATION}:offset=${OUTRO_OFFSET}[vout]; \
[1:a:0]afade=type=out:start_time=${OUTRO_OFFSET}:duration=${CROSSFADE_DURATION}[achunkn]; \
[0:a:0]atrim=start=${CROSSFADE_DURATION}:end=${OUTRO_DURATION}[atrim]; \
[atrim]asetpts=PTS-STARTPTS[aoutro]; \
[achunkn][aoutro]concat=n=2:v=0:a=1[aout]" \
-map '[vout]' -map '[aout]' \
-c:a aac -b:a 192k \
-c:v libx264 -threads 0 -pix_fmt yuv420p -crf 18 -profile:v high -level 4.1 -disposition default \
-metadata:s:a:0 language=native \
"${WORKDIR}/outrocombined.mkv"
# STEP 3
# Encoded intro+outro und alle Chunks in between mit c:v copy und audio dynnorm + encode
echo "==== STEP 3 ===="
CHUNKLIST="${WORKDIR}/chunklist.txt"
echo "file '${WORKDIR}/introcombined.mkv'" > "$CHUNKLIST"
FFMPEG_CONCAT_CHUNKS=""
if [[ ${ARRAY_LENGTH} -gt 2 ]]
then
for index in $(seq 1 $(( ARRAY_LENGTH - 2 )) )
do
FFMPEG_CONCAT_CHUNKS="${FFMPEG_CONCAT_CHUNKS}|${CHUNKS_ARRAY[index]}"
echo "file '${CHUNKS_ARRAY[index]}'" >> "$CHUNKLIST"
done
fi
echo "file '${WORKDIR}/outrocombined.mkv'" >> "$CHUNKLIST"
ffmpeg \
-f concat -safe 0 -i "$CHUNKLIST" \
-af dynaudnorm \
-c:v copy \
-c:a aac -b:a 192k \
-metadata:s:a:0 language=native \
"${OUTPUT_PATH}/${OUTPUT_NAME}.mkv"
echo "Video Exported to ${OUTPUT_PATH}/${OUTPUT_NAME}.mkv"