fix: skip in-progress .part segments and enable MP4 faststart
- /api/recordings no longer lists rec_*.part.mp4 files: FFmpeg has not written the moov atom yet, so they can never play. The in-progress segment was always the newest entry at the top of the list. - Record new segments with -movflags +faststart so the MP4 index is at the front. iOS/Safari otherwise tail-seeks the moov and stalls. - Add playsinline to the playback video for inline iOS playback. - Add scripts/remux-faststart.sh to convert the existing archive (dry-run by default, atomic, preserves mtime for retention).
This commit is contained in:
parent
f794c57198
commit
6346d5fbe1
4 changed files with 142 additions and 2 deletions
8
api.go
8
api.go
|
|
@ -479,11 +479,17 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
|
|
||||||
// Only process rec_*.mp4 files (not snap_*.jpg).
|
// Only process completed rec_*.mp4 files (not snap_*.jpg).
|
||||||
if !strings.HasPrefix(name, "rec_") || !strings.HasSuffix(strings.ToLower(name), ".mp4") {
|
if !strings.HasPrefix(name, "rec_") || !strings.HasSuffix(strings.ToLower(name), ".mp4") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip in-progress segments. FFmpeg only writes the MP4 index (moov)
|
||||||
|
// when it finalizes the file, so a .part.mp4 cannot be played yet.
|
||||||
|
if strings.HasSuffix(strings.ToLower(name), ".part.mp4") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
info, statErr := entry.Info()
|
info, statErr := entry.Info()
|
||||||
if statErr != nil {
|
if statErr != nil {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="pb-clips" class="clip-grid"></div>
|
<div id="pb-clips" class="clip-grid"></div>
|
||||||
<div id="pb-player" class="clip-player hidden">
|
<div id="pb-player" class="clip-player hidden">
|
||||||
<video id="pb-video" controls></video>
|
<video id="pb-video" controls playsinline></video>
|
||||||
<div style="display:flex;gap:8px;margin-top:12px">
|
<div style="display:flex;gap:8px;margin-top:12px">
|
||||||
<button id="pb-download" class="btn-primary">⬇ Download</button>
|
<button id="pb-download" class="btn-primary">⬇ Download</button>
|
||||||
<button id="pb-close">✕ Close</button>
|
<button id="pb-close">✕ Close</button>
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,7 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) {
|
||||||
"-an", // drop audio (pcm_mulaw not supported in MP4)
|
"-an", // drop audio (pcm_mulaw not supported in MP4)
|
||||||
"-c:v", "copy", // stream-copy video: zero transcoding, near-zero CPU
|
"-c:v", "copy", // stream-copy video: zero transcoding, near-zero CPU
|
||||||
"-t", "300", // stop after 300 seconds (5 minutes) of input
|
"-t", "300", // stop after 300 seconds (5 minutes) of input
|
||||||
|
"-movflags", "+faststart", // put the MP4 index first — Safari/iOS needs this
|
||||||
"-y", // overwrite output without asking
|
"-y", // overwrite output without asking
|
||||||
partFile,
|
partFile,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
133
scripts/remux-faststart.sh
Executable file
133
scripts/remux-faststart.sh
Executable file
|
|
@ -0,0 +1,133 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# NextNVR — remux-faststart.sh
|
||||||
|
#
|
||||||
|
# Moves the MP4 index atom (moov) to the front of existing recordings so they
|
||||||
|
# start immediately in browsers, especially Safari/iOS. New recordings already
|
||||||
|
# use -movflags +faststart; this script fixes the archive recorded before that
|
||||||
|
# change.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/remux-faststart.sh [--apply] [recordings-path]
|
||||||
|
#
|
||||||
|
# Defaults to a dry run against /mnt/recordings. Writes are atomic (temp file
|
||||||
|
# in the same directory + rename) and the original mtime is preserved so the
|
||||||
|
# retention cleaner still sees the true recording age.
|
||||||
|
#
|
||||||
|
# Requirements: ffmpeg. python3 is optional — when present, files that already
|
||||||
|
# have moov before mdat are skipped.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APPLY=0
|
||||||
|
ROOT="/mnt/recordings"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: remux-faststart.sh [--apply] [recordings-path]
|
||||||
|
|
||||||
|
--apply Actually rewrite files (default: dry run, report only)
|
||||||
|
-h,--help Show this help
|
||||||
|
|
||||||
|
Rewrites completed rec_*.mp4 files so their MP4 index (moov) is at the front.
|
||||||
|
In-progress *.part.mp4 files are never touched.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--apply) APPLY=1 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
--*) echo "error: unknown option: $arg" >&2; usage >&2; exit 2 ;;
|
||||||
|
*) ROOT="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
command -v ffmpeg >/dev/null 2>&1 || { echo "error: ffmpeg not found" >&2; exit 1; }
|
||||||
|
[ -d "$ROOT" ] || { echo "error: not a directory: $ROOT" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Returns 0 when moov already precedes mdat (faststart), 1 when it does not,
|
||||||
|
# 2 when the file cannot be parsed.
|
||||||
|
moov_is_first() {
|
||||||
|
python3 - "$1" <<'PY'
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = sys.argv[1]
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
off = 0
|
||||||
|
order = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
while off < size:
|
||||||
|
f.seek(off)
|
||||||
|
hdr = f.read(8)
|
||||||
|
if len(hdr) < 8:
|
||||||
|
break
|
||||||
|
atom_size = struct.unpack(">I", hdr[:4])[0]
|
||||||
|
atom_type = hdr[4:8].decode("latin1")
|
||||||
|
if atom_size == 1:
|
||||||
|
ext = f.read(8)
|
||||||
|
if len(ext) < 8:
|
||||||
|
break
|
||||||
|
atom_size = struct.unpack(">Q", ext)[0]
|
||||||
|
order.append(atom_type)
|
||||||
|
if atom_size == 0:
|
||||||
|
break
|
||||||
|
off += atom_size
|
||||||
|
except Exception:
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
if "moov" not in order or "mdat" not in order:
|
||||||
|
sys.exit(2)
|
||||||
|
sys.exit(0 if order.index("moov") < order.index("mdat") else 1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
HAVE_PYTHON=0
|
||||||
|
command -v python3 >/dev/null 2>&1 && HAVE_PYTHON=1
|
||||||
|
|
||||||
|
total=0
|
||||||
|
converted=0
|
||||||
|
skipped=0
|
||||||
|
failed=0
|
||||||
|
|
||||||
|
echo "remux-faststart: root=$ROOT apply=$APPLY"
|
||||||
|
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
total=$((total + 1))
|
||||||
|
|
||||||
|
if [ "$HAVE_PYTHON" -eq 1 ] && moov_is_first "$file"; then
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$APPLY" -eq 0 ]; then
|
||||||
|
echo "would remux: $file"
|
||||||
|
converted=$((converted + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp="${file%.mp4}.faststart.tmp.mp4"
|
||||||
|
if ffmpeg -nostdin -v error -y -i "$file" -c copy -movflags +faststart "$tmp" \
|
||||||
|
&& touch -r "$file" "$tmp" \
|
||||||
|
&& mv -f "$tmp" "$file"; then
|
||||||
|
converted=$((converted + 1))
|
||||||
|
echo "remuxed: $file"
|
||||||
|
else
|
||||||
|
failed=$((failed + 1))
|
||||||
|
rm -f "$tmp"
|
||||||
|
echo "FAILED: $file" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $((converted % 100)) -eq 0 ] && [ "$converted" -gt 0 ]; then
|
||||||
|
echo "--- progress: $converted processed ---"
|
||||||
|
fi
|
||||||
|
done < <(find "$ROOT" -type f -name 'rec_*.mp4' ! -name '*.part.mp4' -print0)
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "remux-faststart: scanned=$total remuxed=$converted skipped=$skipped failed=$failed"
|
||||||
|
if [ "$APPLY" -eq 0 ] && [ "$converted" -gt 0 ]; then
|
||||||
|
echo "dry run — re-run with --apply to rewrite the files above"
|
||||||
|
fi
|
||||||
Loading…
Reference in a new issue