221 lines
9.7 KiB
Markdown
221 lines
9.7 KiB
Markdown
# Project: Lossless A/V Sync Marker Tool
|
|
|
|
## Goal
|
|
|
|
Build a Linux desktop GUI app — think "LosslessCut, but for fixing audio/video
|
|
sync" — that lets a user:
|
|
|
|
1. Load a video file (it has both a video track and an audio track, currently
|
|
out of sync with each other).
|
|
2. Scrub the video track to a specific frame where a known event happens
|
|
(e.g. a clap, a flash, a beep) and click "Mark Video".
|
|
3. Scrub the audio track to the same event and click "Mark Audio".
|
|
4. The app computes the offset between the two markers (in ms).
|
|
5. The app exports a new file with the audio shifted to match the video,
|
|
**losslessly** (stream copy, no re-encoding) via ffmpeg.
|
|
|
|
This replaces a manual workflow of: scrub in a player, write down two
|
|
timestamps, subtract them by hand, then run an ffmpeg command with
|
|
`-itsoffset`.
|
|
|
|
## Why frame-accurate scrubbing matters
|
|
|
|
Sync events are often only 1-2 frames wide. HTML5 `<video>` seeking is not
|
|
reliable enough at that precision. Use **PyQt6 (or PySide6) + libmpv**
|
|
(via `python-mpv`) for playback — mpv supports frame-stepping and precise
|
|
seeking (`--hr-seek`), which is what makes this usable.
|
|
|
|
If python-mpv/libmpv setup proves too painful on the target system, python-vlc
|
|
is an acceptable fallback, but frame-step precision should be verified early —
|
|
this is the main technical risk in the project, so validate it first before
|
|
building the rest of the UI around it.
|
|
|
|
## Suggested architecture
|
|
|
|
- `main.py` — app entry point, PyQt6 window
|
|
- `player_widget.py` — embeds mpv (or vlc) render surface in a Qt widget,
|
|
exposes: `load(path)`, `seek(seconds)`, `step_frame(n)`, `get_position()`,
|
|
`play()`, `pause()`
|
|
- `sync_logic.py` — pure functions: given two marker timestamps (video_mark,
|
|
audio_mark), compute delay_ms = (video_mark - audio_mark) * 1000. Sign
|
|
convention: positive delay_ms means audio needs to be delayed (shifted
|
|
later) to match video; negative means video needs to be delayed instead
|
|
(see ffmpeg_export.py for how each case is handled).
|
|
- `ffmpeg_export.py` — wraps the ffmpeg calls below; takes (input_path,
|
|
delay_ms, output_path) and runs the correct lossless command depending on
|
|
sign of delay_ms.
|
|
- `ui/` — Qt Designer .ui files or hand-built widgets: two player panes (or
|
|
one pane with a toggle for "viewing as video-sync-target" vs
|
|
"audio-sync-target" — TBD based on whether we want separate video/audio
|
|
scrubbers or one shared transport with two mark buttons), marker readouts,
|
|
offset display, "Export" button with output path picker.
|
|
|
|
## UI concept (rough, adjust as needed during build)
|
|
|
|
- Single video preview (shows both video+audio playing together, since it's
|
|
one file) OR two loaded instances of the same file if the user wants to
|
|
scrub each track independently — needs a decision during implementation
|
|
based on what feels usable. Likely simplest: one player, but let the user
|
|
mute video/audio independently while scrubbing so they can isolate the
|
|
track they're marking.
|
|
- Timeline scrubber with frame-step buttons (prev frame / next frame) and
|
|
keyboard shortcuts (e.g. `,` / `.` like most NLEs).
|
|
- Two "Mark" buttons: "Mark Video Event" and "Mark Audio Event" — each
|
|
captures the current playhead position in ms.
|
|
- Live offset readout: "Offset: +503 ms (audio will be delayed)".
|
|
- Optional: manual override field to nudge the computed offset by hand
|
|
before export.
|
|
- Export button → runs ffmpeg lossless shift, shows progress/completion.
|
|
|
|
## Existing working ffmpeg logic (reuse this, don't reinvent)
|
|
|
|
This bash script (`ffdelay`) already implements the correct lossless
|
|
ffmpeg commands for both positive and negative delays, including an
|
|
optional clip mode (start timestamp + duration) for fast testing on a
|
|
short segment instead of the whole file. Port this logic into
|
|
`ffmpeg_export.py` (as subprocess calls or via a Python ffmpeg wrapper —
|
|
subprocess is simplest and matches what's already proven to work):
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
|
|
# ffdelay - Easy audio delay tool for ffmpeg/ffplay
|
|
# Usage:
|
|
# ./ffdelay input.mp4 fix 500
|
|
# ./ffdelay input.mp4 fix 500 00:45:00 30 # Quick 30s test from 45min mark
|
|
# ./ffdelay input.mp4 play 500
|
|
# ./ffdelay input.mp4 play 500 00:45:00
|
|
|
|
set -e
|
|
|
|
INPUT="$1"
|
|
MODE="$2"
|
|
DELAY_MS="$3"
|
|
TIMESTAMP="$4"
|
|
DURATION="$5"
|
|
|
|
if [ -z "$INPUT" ] || [ -z "$MODE" ] || [ -z "$DELAY_MS" ]; then
|
|
echo "Usage:"
|
|
echo " $0 <input.mp4> fix <delay_ms> [start_time] [duration_sec]"
|
|
echo " $0 <input.mp4> play <delay_ms> [start_time]"
|
|
exit 1
|
|
fi
|
|
|
|
if [ ! -f "$INPUT" ]; then
|
|
echo "Error: Input file '$INPUT' not found"
|
|
exit 1
|
|
fi
|
|
|
|
ABS_DELAY_MS=${DELAY_MS#-}
|
|
DELAY_SEC=$(awk "BEGIN {printf \"%.3f\", $DELAY_MS/1000}")
|
|
ABS_DELAY_SEC=$(awk "BEGIN {printf \"%.3f\", $ABS_DELAY_MS/1000}")
|
|
|
|
case "$MODE" in
|
|
fix)
|
|
BASENAME="${INPUT%.*}"
|
|
EXTENSION="${INPUT##*.}"
|
|
|
|
if [ -n "$TIMESTAMP" ] && [ -n "$DURATION" ]; then
|
|
OUTPUT="${BASENAME}_delayed${DELAY_MS}ms_clip.${EXTENSION}"
|
|
else
|
|
OUTPUT="${BASENAME}_delayed${DELAY_MS}ms.${EXTENSION}"
|
|
fi
|
|
|
|
if [ "$DELAY_MS" -lt 0 ]; then
|
|
# Negative delay = offset the VIDEO instead (delaying video
|
|
# relative to audio has the same effect as advancing audio)
|
|
if [ -n "$TIMESTAMP" ] && [ -n "$DURATION" ]; then
|
|
ffmpeg -itsoffset "$ABS_DELAY_SEC" -ss "$TIMESTAMP" -t "$DURATION" -i "$INPUT" \
|
|
-ss "$TIMESTAMP" -t "$DURATION" -i "$INPUT" \
|
|
-map 0:v -map 1:a -c:v copy -c:a copy "$OUTPUT"
|
|
else
|
|
ffmpeg -itsoffset "$ABS_DELAY_SEC" -i "$INPUT" -i "$INPUT" \
|
|
-map 0:v -map 1:a -c:v copy -c:a copy "$OUTPUT"
|
|
fi
|
|
else
|
|
# Positive delay = offset the AUDIO
|
|
if [ -n "$TIMESTAMP" ] && [ -n "$DURATION" ]; then
|
|
ffmpeg -ss "$TIMESTAMP" -t "$DURATION" -i "$INPUT" \
|
|
-itsoffset "$DELAY_SEC" -ss "$TIMESTAMP" -t "$DURATION" -i "$INPUT" \
|
|
-map 0:v -map 1:a -c:v copy -c:a copy "$OUTPUT"
|
|
else
|
|
ffmpeg -i "$INPUT" -itsoffset "$DELAY_SEC" -i "$INPUT" \
|
|
-map 0:v -map 1:a -c:v copy -c:a copy "$OUTPUT"
|
|
fi
|
|
fi
|
|
echo "Done! Created: $OUTPUT"
|
|
;;
|
|
|
|
play)
|
|
if [ "$DELAY_MS" -lt 0 ]; then
|
|
if [ -n "$TIMESTAMP" ]; then
|
|
ffplay -ss "$TIMESTAMP" -t 30 -i "$INPUT" -vf "setpts=PTS+${ABS_DELAY_SEC}/TB"
|
|
else
|
|
ffplay -t 30 -i "$INPUT" -vf "setpts=PTS+${ABS_DELAY_SEC}/TB"
|
|
fi
|
|
else
|
|
if [ -n "$TIMESTAMP" ]; then
|
|
ffplay -ss "$TIMESTAMP" -t 30 -i "$INPUT" -af "adelay=${DELAY_MS}|${DELAY_MS}"
|
|
else
|
|
ffplay -t 30 -i "$INPUT" -af "adelay=${DELAY_MS}|${DELAY_MS}"
|
|
fi
|
|
fi
|
|
;;
|
|
|
|
*)
|
|
echo "Error: Mode must be 'fix' or 'play'"
|
|
exit 1
|
|
;;
|
|
esac
|
|
```
|
|
|
|
### Known quirks already discovered during CLI development (avoid re-learning these):
|
|
|
|
- `bc` produces `.500` instead of `0.500` for sub-1 values, which ffmpeg
|
|
rejects as an invalid duration for `-itsoffset`. Use `awk` (or Python's
|
|
own float formatting) instead — always emit a leading zero.
|
|
- Negative delay must NOT be applied by naively negating inside `-itsoffset`
|
|
(ffmpeg's `-itsoffset` doesn't take negative values the way you'd hope for
|
|
this use case) — instead, restructure which of the two inputs gets the
|
|
offset and which stream is mapped from which input. See the two branches
|
|
above (`-lt 0` vs positive).
|
|
- When combining `-itsoffset` with `-ss`/`-t` (for fast clip-based testing),
|
|
the offset flag must be placed correctly relative to the specific input
|
|
it applies to, and `-ss`/`-t` should be applied to BOTH inputs so the
|
|
extracted clip covers the same real-world time window in each.
|
|
- Warnings like `sps_id out of range`, `Invalid NAL unit size`, or
|
|
`missing picture in access unit` during stream-copy operations are
|
|
generally harmless source-file quirks (common with screen recordings/live
|
|
captures) and don't indicate a problem with the sync fix itself — don't
|
|
treat these as fatal errors in the export UI, just log them.
|
|
- `ffplay`'s `adelay` audio filter is cheap (just inserts silence) but the
|
|
video-delay equivalent (`setpts=PTS+offset/TB`) is comparatively expensive
|
|
because it re-times every frame — this made live preview of negative
|
|
offsets slow at full resolution in the CLI version. Worth keeping in mind
|
|
if the GUI adds a "preview shifted result before export" feature; may
|
|
want to preview on a downscaled/short clip rather than the full file.
|
|
|
|
## Non-goals for v1
|
|
|
|
- No automatic cross-correlation/audio-fingerprint sync detection (that's a
|
|
different, harder feature — separate tools like SyncSink exist for that).
|
|
This tool is specifically for the case where the user can see/hear the
|
|
sync event and just needs to mark it on each track.
|
|
- No re-encoding/transcoding support — lossless stream copy only. If the
|
|
input codec/container doesn't support clean stream copy for some reason,
|
|
surface the ffmpeg error rather than silently falling back to re-encode.
|
|
- No batch processing in v1 — single file in, single file out.
|
|
|
|
## Suggested build order
|
|
|
|
1. Validate frame-accurate seeking works acceptably with python-mpv (or
|
|
python-vlc) in a minimal Qt window — this is the highest-risk piece,
|
|
confirm it early before investing in the rest of the UI.
|
|
2. Build the marker + offset calculation logic (pure Python, easily
|
|
testable without any UI).
|
|
3. Port the ffmpeg export logic from the bash script above.
|
|
4. Wire up the full UI: player, scrub controls, mark buttons, offset
|
|
display, export button.
|
|
5. Test end-to-end on a real out-of-sync file with a clear sync event
|
|
(e.g. record a clap on both a camera and a separate audio recorder).
|