A bash script for keyframe-accurate MP4 cutting using ffmpeg and ffprobe. Cuts a clip from a source file between two timestamps with frame-accurate boundaries and no quality loss on the middle segment.
ffmpeg ffprobe bc bash 3.2+
./cut.sh <source> <start> <end> [output] [--sequential]
| Argument | Description |
|---|---|
source | Input MP4 file |
start | Start timestamp (h:mm:ss or hh:mm:ss) |
end | End timestamp (h:mm:ss or hh:mm:ss) |
output | Output filename (optional) |
--sequential | Disable parallel processing (optional) |
clip.mp4) → saved to ./output/clip.mp4/ (e.g. ./clips/clip.mp4) → saved to that exact path./output/<source_filename># Basic cut, output to ./output/
./cut.sh input.mp4 0:13:38 1:06:50
# Custom output filename
./cut.sh input.mp4 0:13:38 1:06:50 clip.mp4
# Custom output path
./cut.sh input.mp4 0:13:38 1:06:50 ./clips/clip.mp4
# Sequential mode (for benchmarking or low-resource environments)
./cut.sh input.mp4 0:13:38 1:06:50 clip.mp4 --sequential
A naive -c copy cut in ffmpeg can only cut on keyframe boundaries, which causes corrupted frames at the start and end of the clip. This script solves that with a 3-segment strategy:
$START to the next keyframe (kf_after_start) using the source codeckf_after_start to kf_before_end (fast, lossless)$END (kf_before_end) to $END using the source codecOnly a few seconds at each boundary are re-encoded. The bulk of the clip is always a fast stream copy.
ffprobe is used to scan keyframes only around the $START and $END windows (±60 seconds by default) rather than the entire file, keeping the scan fast even on long videos.
By default, all three segments are processed in parallel, reducing total time. Use --sequential to process them one at a time, which is useful for benchmarking or on low-resource machines.
The script prints a summary after completion:
The script automatically verifies the output duration against the expected duration (end - start) and shows the difference. A diff of 0s confirms the cut is correct.
h264, hevc, etc.) and audio codec (aac, opus, etc.) and uses them for re-encoding, so no manual codec configuration is needed./tmp/ and cleaned up automatically on exit, even if the script fails.--sequential flag can be placed anywhere in the argument list.A collaborative session between a user and Claude (Anthropic) to build cut.sh — a keyframe-accurate MP4 cutting script using ffmpeg and ffprobe.
The session started from a simple ffmpeg question and evolved iteratively into a fully-featured, production-ready bash script through debugging, optimization, and refinement.
The session began with understanding a basic ffmpeg cut command:
ffmpeg -ss 0:13:38 -i ./input -to 0:13:39 -c copy ./output.mp4
-ss before -i is a fast seek but makes -to an absolute timestamp, causing the wrong duration-ss after -i is slower but accurate — fix was to move -ss after -i-c copy can only cut on keyframe boundaries, introducing corrupted framesBuilt reusable bash utilities:
hhmmss_to_seconds — converts hh:mm:ss to total seconds using IFS splitting and 10# base-10 forcing to handle leading zeros (08, 09)$1 vs read input, and how bash functions "return" values via echo + command substitution $()Used ffprobe to scan keyframes:
ffprobe -v error -of default=noprint_wrappers=1:nokey=1 \
-select_streams v -skip_frame nokey \
-show_frames -show_entries frame=pkt_dts_time $SOURCE
Built keyframe_before and keyframe_after functions. Fixed a float precision bug where 4010.072733 was being truncated to 4010 and incorrectly passing an integer <= check — fixed by using awk for float comparison instead of bash integer arithmetic.
Optimized ffprobe to scan only around $START and $END windows using -read_intervals, avoiding full-file scans on long videos.
Replaced the naive single -c copy cut with a 3-segment approach:
START → kf_after_startkf_after_start → kf_before_endkf_before_end → END-f concatSeveral bugs were caught and fixed through systematic debugging:
| Bug | Symptom | Fix |
|---|---|---|
Missing $kf_after_start in Step 2 | Middle segment cut from wrong position | Added missing argument after -ss |
| Timebase mismatch | Final output 4780s instead of 3192s | Added -video_track_timescale "$TIMESCALE" to all steps |
| Timestamp discontinuity | Concat misaligned segments | Added -reset_timestamps 1 to all steps and concat |
| Float truncation | kf_before_end returning wrong keyframe | Switched integer <= to awk float comparison |
N/A keyframe values | bc division by zero error | Added [[ "$ts" == "N/A" ]] && continue filter |
Combined ffprobe awk parsing | Empty VIDEO_CODEC, AUDIO_CODEC, TIMESCALE | Reverted to three separate ffprobe calls |
1. Parallel processing — ran all three segments concurrently with & and wait:
2. Pre-input -ss for head and tail — the real breakthrough:
The middle segment kept post-input -ss since it uses -c copy and needs keyframe-accurate seeking.
validate_timestamp — regex check for h:mm:ss or hh:mm:ss format--sequential flag — strips flag from args before positional parsingVIDEO_CODEC, AUDIO_CODEC, TIMESCALE extracted from sourceELAPSED_HEAD, ELAPSED_MID, ELAPSED_TAILThe session concluded with generating a full documentation and skill package.
Documentation files:
README.md — user-facing reference covering usage, arguments, output path rules, the 3-segment strategy, and the summary formatDEVLOG.md — full collaboration log covering all phases, bugs, optimizations, and key learningscut_sh_docs.html — self-contained HTML combining README and DEVLOG into a tabbed interface with syntax highlighting, dark mode, and a copy buttonAI agent skill folder (cut-sh-skill/):
SKILL.md — primary skill descriptor read by AI agents. Includes frontmatter trigger description, full argument reference, expected output format, error handling table, and performance notesEXAMPLES.md — maps natural language user requests to exact cut.sh arguments, including edge cases like relative durations and multiple cutsinvoke_cut.sh — wrapper for AI agents that handles dependency checks, locates cut.sh automatically, validates arguments, and provides clean exit codescut-sh-skill/ at /mnt/skills/user/cut-sh/ alongside cut.sh. The agent will automatically load SKILL.md when a user asks to cut or trim a video.A production-ready bash script (cut.sh) and a complete AI agent skill package:
SKILL.md, EXAMPLES.md, and invoke_cut.sh-ss position matters a lot — pre-input is fast but changes -to semantics; post-input is accurate but slow for long seekstime_base between segments causes duration corruptionawk for float comparisons, never truncate to integers| Tool | Purpose |
|---|---|
ffmpeg | Video cutting, encoding, concat |
ffprobe | Keyframe scanning, codec detection, duration verification |
bc | Float arithmetic for durations |
awk | Float comparisons, field parsing |
bash | Script orchestration, argument handling, timing |
The full script as built through this collaboration session.
#!/usr/bin/env bash
# ── Usage ─────────────────────────────────────────────────────
usage() {
echo "Usage: $(basename "$0") <source> <start> <end> [output] [--sequential]"
echo
echo " source Input MP4 file"
echo " start Start timestamp (h:mm:ss or hh:mm:ss)"
echo " end End timestamp (h:mm:ss or hh:mm:ss)"
echo " output Output filename (default: ./output/<source_filename>)"
echo " Provide a bare name to land in ./output/"
echo " Provide a path (with /) to override location"
echo
echo "Example:"
echo ' $(basename "$0") input.mp4 0:13:38 1:06:50'
echo ' $(basename "$0") input.mp4 0:13:38 1:06:50 clip.mp4'
echo ' $(basename "$0") input.mp4 0:13:38 1:06:50 ./clips/clip.mp4'
echo ' $(basename "$0") input.mp4 0:13:38 1:06:50 clip.mp4 --sequential'
exit 1
}
PARALLEL=true
ARGS=()
for arg in "$@"; do
[[ "$arg" == "--sequential" ]] && PARALLEL=false || ARGS+=("$arg")
done
[[ ${#ARGS[@]} -lt 3 || ${#ARGS[@]} -gt 4 ]] && { echo "Error: expected 3 or 4 arguments, got ${#ARGS[@]}." >&2; usage; }
SOURCE="${ARGS[0]}"
START="${ARGS[1]}"
END="${ARGS[2]}"
OUTPUT_ARG="${ARGS[3]}"
# ── Convert hh:mm:ss to seconds ───────────────────────────────
hhmmss_to_seconds() {
local hh mm ss
IFS=':' read -r hh mm ss <<< "$1"
echo $(( (10#$hh * 3600) + (10#$mm * 60) + 10#$ss ))
}
# ── Validate timestamp format ─────────────────────────────────
validate_timestamp() {
[[ "$1" =~ ^[0-9]{1,2}:[0-9]{2}:[0-9]{2}$ ]] || \
{ echo "Error: invalid timestamp format '$1', expected hh:mm:ss" >&2; exit 1; }
}
validate_timestamp "$START"
validate_timestamp "$END"
# ── Input validation ──────────────────────────────────────────
[[ ! -f "$SOURCE" ]] && { echo "Error: source file not found: $SOURCE" >&2; exit 1; }
start_sec=$(hhmmss_to_seconds "$START")
end_sec=$(hhmmss_to_seconds "$END")
[[ "$end_sec" -le "$start_sec" ]] && { echo "Error: end must be after start." >&2; exit 1; }
# ── Default output path ───────────────────────────────────────
mkdir -p ./output
if [[ -n "$OUTPUT_ARG" ]]; then
if [[ "$OUTPUT_ARG" == */* ]]; then
OUTPUT="$OUTPUT_ARG"
else
OUTPUT="./output/$OUTPUT_ARG"
fi
else
OUTPUT="./output/$(basename "$SOURCE")"
fi
# ── Temp files ────────────────────────────────────────────────
TMP_HEAD="/tmp/cut_head.mp4"
TMP_MID="/tmp/cut_mid.mp4"
TMP_TAIL="/tmp/cut_tail.mp4"
TMP_LIST="/tmp/cut_list.txt"
cleanup() {
rm -f "$TMP_HEAD" "$TMP_MID" "$TMP_TAIL" "$TMP_LIST" \
/tmp/cut_time_head /tmp/cut_time_mid /tmp/cut_time_tail
}
trap cleanup EXIT
# ── Find last keyframe at or before a given time ──────────────
keyframe_before() {
local target_sec="$1"
local best=""
for ts in "${KEYFRAMES[@]}"; do
local result
result=$(echo "$ts $target_sec" | awk '{print ($1 <= $2) ? "yes" : "no"}')
[[ "$result" == "yes" ]] && best="$ts" || break
done
echo "$best"
}
# ── Find first keyframe at or after a given time ──────────────
keyframe_after() {
local target_sec="$1"
for ts in "${KEYFRAMES[@]}"; do
local result
result=$(echo "$ts $target_sec" | awk '{print ($1 >= $2) ? "yes" : "no"}')
if [[ "$result" == "yes" ]]; then
echo "$ts"
return
fi
done
}
# ── Main ──────────────────────────────────────────────────────
# ── Extract encoding params from source ──────────────────────
VIDEO_CODEC=$(ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name \
-of default=noprint_wrappers=1:nokey=1 \
"$SOURCE")
AUDIO_CODEC=$(ffprobe -v error -select_streams a:0 \
-show_entries stream=codec_name \
-of default=noprint_wrappers=1:nokey=1 \
"$SOURCE")
TIMESCALE=$(ffprobe -v error -select_streams v:0 \
-show_entries stream=time_base \
-of default=noprint_wrappers=1:nokey=1 \
"$SOURCE" | awk -F'/' '{print $2}')
if [[ -z "$VIDEO_CODEC" || -z "$AUDIO_CODEC" || -z "$TIMESCALE" ]]; then
echo "Error: could not extract codec info from $SOURCE." >&2
exit 1
fi
echo "Source info:"
echo " video codec : $VIDEO_CODEC"
echo " audio codec : $AUDIO_CODEC"
echo " timescale : $TIMESCALE"
# ── Timer start ───────────────────────────────────────────────
TIME_START=$(date +%s)
TIME_SCAN_START=$TIME_START
echo "Scanning keyframes in $SOURCE..."
INTERVAL=60
start_window=$(( start_sec - INTERVAL ))
[[ "$start_window" -lt 0 ]] && start_window=0
KEYFRAMES=()
while IFS= read -r ts; do
[[ "$ts" == "N/A" ]] && continue
KEYFRAMES+=("$ts")
frame_sec=$(echo "$ts" | awk '{printf "%d", $1}')
[[ "$frame_sec" -gt "$(( end_sec + INTERVAL ))" ]] && break
done < <(ffprobe -v error \
-read_intervals "${start_window}%$(( start_sec + INTERVAL )),$(( end_sec - INTERVAL ))%$(( end_sec + INTERVAL ))" \
-of default=noprint_wrappers=1:nokey=1 \
-select_streams v \
-skip_frame nokey \
-show_frames \
-show_entries frame=pkt_dts_time \
"$SOURCE")
if [[ ${#KEYFRAMES[@]} -eq 0 ]]; then
echo "Error: no keyframes found in $SOURCE." >&2
exit 1
fi
kf_after_start=$(keyframe_after "$start_sec" | tr -d '[:space:]')
kf_before_end=$(keyframe_before "$end_sec" | tr -d '[:space:]')
if [[ -z "$kf_after_start" || -z "$kf_before_end" ]]; then
echo "Error: could not find keyframes around the given range." >&2
exit 1
fi
TIME_SCAN_END=$(date +%s)
# ── Steps 1-3: Process all segments ───────────────────────────
head_duration=$(echo "$kf_after_start - $start_sec" | bc)
mid_duration=$(echo "$kf_before_end - $kf_after_start" | bc)
tail_duration=$(echo "$end_sec - $kf_before_end" | bc)
TIME_SEGMENTS_START=$(date +%s)
echo "Processing segments in $([ "$PARALLEL" == true ] && echo "parallel" || echo "sequential") mode..."
if [[ "$PARALLEL" == true ]]; then
TIME_HEAD_START=$(date +%s)
ffmpeg -v error -ss "$START" -i "$SOURCE" -t "$head_duration" \
-reset_timestamps 1 -video_track_timescale "$TIMESCALE" \
-c:v "$VIDEO_CODEC" -c:a "$AUDIO_CODEC" -y "$TMP_HEAD" && \
echo "$(( $(date +%s) - TIME_HEAD_START ))" > /tmp/cut_time_head &
PID_HEAD=$!
TIME_MID_START=$(date +%s)
ffmpeg -v error -i "$SOURCE" -ss "$kf_after_start" -t "$mid_duration" \
-reset_timestamps 1 -video_track_timescale "$TIMESCALE" \
-c copy -y "$TMP_MID" && \
echo "$(( $(date +%s) - TIME_MID_START ))" > /tmp/cut_time_mid &
PID_MID=$!
TIME_TAIL_START=$(date +%s)
ffmpeg -v error -ss "$kf_before_end" -i "$SOURCE" -t "$tail_duration" \
-reset_timestamps 1 -video_track_timescale "$TIMESCALE" \
-c:v "$VIDEO_CODEC" -c:a "$AUDIO_CODEC" -y "$TMP_TAIL" && \
echo "$(( $(date +%s) - TIME_TAIL_START ))" > /tmp/cut_time_tail &
PID_TAIL=$!
wait $PID_HEAD || { echo "Error: head segment failed." >&2; exit 1; }
wait $PID_MID || { echo "Error: mid segment failed." >&2; exit 1; }
wait $PID_TAIL || { echo "Error: tail segment failed." >&2; exit 1; }
ELAPSED_HEAD=$(cat /tmp/cut_time_head 2>/dev/null || echo "N/A")
ELAPSED_MID=$(cat /tmp/cut_time_mid 2>/dev/null || echo "N/A")
ELAPSED_TAIL=$(cat /tmp/cut_time_tail 2>/dev/null || echo "N/A")
else
TIME_HEAD_START=$(date +%s)
ffmpeg -v error -ss "$START" -i "$SOURCE" -t "$head_duration" \
-reset_timestamps 1 -video_track_timescale "$TIMESCALE" \
-c:v "$VIDEO_CODEC" -c:a "$AUDIO_CODEC" -y "$TMP_HEAD" \
|| { echo "Error: head segment failed." >&2; exit 1; }
ELAPSED_HEAD=$(( $(date +%s) - TIME_HEAD_START ))
TIME_MID_START=$(date +%s)
ffmpeg -v error -i "$SOURCE" -ss "$kf_after_start" -t "$mid_duration" \
-reset_timestamps 1 -video_track_timescale "$TIMESCALE" \
-c copy -y "$TMP_MID" \
|| { echo "Error: mid segment failed." >&2; exit 1; }
ELAPSED_MID=$(( $(date +%s) - TIME_MID_START ))
TIME_TAIL_START=$(date +%s)
ffmpeg -v error -ss "$kf_before_end" -i "$SOURCE" -t "$tail_duration" \
-reset_timestamps 1 -video_track_timescale "$TIMESCALE" \
-c:v "$VIDEO_CODEC" -c:a "$AUDIO_CODEC" -y "$TMP_TAIL" \
|| { echo "Error: tail segment failed." >&2; exit 1; }
ELAPSED_TAIL=$(( $(date +%s) - TIME_TAIL_START ))
fi
echo "All segments done."
TIME_SEGMENTS_END=$(date +%s)
# ── Step 4: Concat all three ───────────────────────────────────
TIME_CONCAT_START=$(date +%s)
echo "Concatenating segments..."
printf "file '%s'\nfile '%s'\nfile '%s'\n" \
"$TMP_HEAD" "$TMP_MID" "$TMP_TAIL" > "$TMP_LIST"
ffmpeg -v error -f concat -safe 0 -i "$TMP_LIST" \
-c copy \
-reset_timestamps 1 \
-y "$OUTPUT"
TIME_CONCAT_END=$(date +%s)
# ── Verify output duration ────────────────────────────────────
EXPECTED_DURATION=$(( end_sec - start_sec ))
ACTUAL_DURATION=$(ffprobe -v error \
-show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 \
"$OUTPUT" | awk '{printf "%d", $1}')
DURATION_DIFF=$(( ACTUAL_DURATION - EXPECTED_DURATION ))
[[ $DURATION_DIFF -lt 0 ]] && DURATION_DIFF=$(( -DURATION_DIFF ))
TIME_END=$(date +%s)
ELAPSED=$(( TIME_END - TIME_START ))
ELAPSED_FMT=$(printf "%02d:%02d:%02d" $(( ELAPSED / 3600 )) $(( (ELAPSED % 3600) / 60 )) $(( ELAPSED % 60 )))
ELAPSED_SCAN=$(( TIME_SCAN_END - TIME_SCAN_START ))
ELAPSED_SEGMENTS=$(( TIME_SEGMENTS_END - TIME_SEGMENTS_START ))
ELAPSED_CONCAT=$(( TIME_CONCAT_END - TIME_CONCAT_START ))
echo
echo "── Summary ───────────────────────────────────────────────"
echo " source : $SOURCE"
echo " start : $START"
echo " end : $END"
echo " output : $OUTPUT"
echo ' mode : $([ "$PARALLEL" == true ] && echo "parallel" || echo "sequential")'
echo " ── timing ──────────────────────────────────────────────"
echo " scan : ${ELAPSED_SCAN}s"
echo " segments : ${ELAPSED_SEGMENTS}s (head: ${ELAPSED_HEAD}s, mid: ${ELAPSED_MID}s, tail: ${ELAPSED_TAIL}s)"
echo " concat : ${ELAPSED_CONCAT}s"
echo " elapsed : $ELAPSED_FMT"
echo " ────────────────────────────────────────────────────────"
echo " expected : ${EXPECTED_DURATION}s"
echo " actual : ${ACTUAL_DURATION}s"
echo " diff : ${DURATION_DIFF}s"
echo " done : $OUTPUT"
echo "──────────────────────────────────────────────────────────"