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()
|
||||
|
||||
// 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") {
|
||||
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()
|
||||
if statErr != nil {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
</div>
|
||||
<div id="pb-clips" class="clip-grid"></div>
|
||||
<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">
|
||||
<button id="pb-download" class="btn-primary">⬇ Download</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)
|
||||
"-c:v", "copy", // stream-copy video: zero transcoding, near-zero CPU
|
||||
"-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
|
||||
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