cut.sh

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.

Requirements

ffmpeg ffprobe bc bash 3.2+

Usage

./cut.sh <source> <start> <end> [output] [--sequential]

Arguments

ArgumentDescription
sourceInput MP4 file
startStart timestamp (h:mm:ss or hh:mm:ss)
endEnd timestamp (h:mm:ss or hh:mm:ss)
outputOutput filename (optional)
--sequentialDisable parallel processing (optional)

Output path

Examples

# 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

How it works

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 ──[re-encode]──> kf_after_start │ kf_after_start ──[copy]──> kf_before_end │ kf_before_end ──[re-encode]──> END
  1. Head — re-encodes from $START to the next keyframe (kf_after_start) using the source codec
  2. Middle — stream-copies from kf_after_start to kf_before_end (fast, lossless)
  3. Tail — re-encodes from the last keyframe before $END (kf_before_end) to $END using the source codec
  4. Concat — joins all three segments into the final output

Only a few seconds at each boundary are re-encoded. The bulk of the clip is always a fast stream copy.

Keyframe scanning

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.

Parallel mode (default)

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.

Output

The script prints a summary after completion:

── Summary ─────────────────────────────────────────────── source : ./input.mp4 start : 0:13:38 end : 1:06:50 output : ./output/clip.mp4 mode : parallel ── timing ────────────────────────────────────────────── scan : 1s segments : 2s (head: 1s, mid: 2s, tail: 2s) concat : 2s elapsed : 00:00:05 ──────────────────────────────────────────────────────── expected : 3192s actual : 3192s diff : 0s done : ./output/clip.mp4 ──────────────────────────────────────────────────────────

Duration verification

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.

Notes

Collaboration Summary: Building cut.sh

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.

Estimated duration: 2-3 hours of back-and-forth conversation.

Journey

1
ffmpeg fundamentals

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 frames
2
bash foundations

Built reusable bash utilities:

  • A 4-argument script template with usage display
  • hhmmss_to_seconds — converts hh:mm:ss to total seconds using IFS splitting and 10# base-10 forcing to handle leading zeros (08, 09)
  • Discussed $1 vs read input, and how bash functions "return" values via echo + command substitution $()
3
keyframe scanning

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.

4
3-segment cut strategy

Replaced the naive single -c copy cut with a 3-segment approach:

START ──[re-encode]──> kf_after_start │ kf_after_start ──[copy]──> kf_before_end │ kf_before_end ──[re-encode]──> END
  • Head: re-encode START → kf_after_start
  • Middle: stream copy kf_after_start → kf_before_end
  • Tail: re-encode kf_before_end → END
  • Concat: join all three with -f concat
5
debugging

Several bugs were caught and fixed through systematic debugging:

BugSymptomFix
Missing $kf_after_start in Step 2Middle segment cut from wrong positionAdded missing argument after -ss
Timebase mismatchFinal output 4780s instead of 3192sAdded -video_track_timescale "$TIMESCALE" to all steps
Timestamp discontinuityConcat misaligned segmentsAdded -reset_timestamps 1 to all steps and concat
Float truncationkf_before_end returning wrong keyframeSwitched integer <= to awk float comparison
N/A keyframe valuesbc division by zero errorAdded [[ "$ts" == "N/A" ]] && continue filter
Combined ffprobe awk parsingEmpty VIDEO_CODEC, AUDIO_CODEC, TIMESCALEReverted to three separate ffprobe calls
6
performance optimization

1. Parallel processing — ran all three segments concurrently with & and wait:

  • Sequential: ~67s → Parallel: ~61s (modest improvement)

2. Pre-input -ss for head and tail — the real breakthrough:

  • Before: tail was decoding the entire file up to 66 minutes before encoding
  • After: fast seek jumps directly to the position
  • Sequential: 67s → 4s (~17x speedup)
  • Parallel: 61s → 3s (~20x speedup)

The middle segment kept post-input -ss since it uses -c copy and needs keyframe-accurate seeking.

7
polish and features
  • validate_timestamp — regex check for h:mm:ss or hh:mm:ss format
  • Source file existence check
  • End must be after start check
  • --sequential flag — strips flag from args before positional parsing
  • Source codec auto-detection — VIDEO_CODEC, AUDIO_CODEC, TIMESCALE extracted from source
  • Per-segment timing — ELAPSED_HEAD, ELAPSED_MID, ELAPSED_TAIL
  • Output duration verification — compares expected vs actual duration with diff
  • Full summary output
8
documentation and skill packaging

The 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 format
  • DEVLOG.md — full collaboration log covering all phases, bugs, optimizations, and key learnings
  • cut_sh_docs.html — self-contained HTML combining README and DEVLOG into a tabbed interface with syntax highlighting, dark mode, and a copy button

AI 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 notes
  • EXAMPLES.md — maps natural language user requests to exact cut.sh arguments, including edge cases like relative durations and multiple cuts
  • invoke_cut.sh — wrapper for AI agents that handles dependency checks, locates cut.sh automatically, validates arguments, and provides clean exit codes
To deploy the skill, place cut-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.

Final Summary

What was built

A production-ready bash script (cut.sh) and a complete AI agent skill package:

  1. Validates all inputs — source file, timestamp format, end after start
  2. Extracts codec info from the source file automatically
  3. Scans keyframes only around the cut points (not the full file)
  4. Cuts accurately using a 3-segment head/mid/tail strategy
  5. Re-encodes only the small boundaries, stream-copies the bulk
  6. Processes segments in parallel by default
  7. Verifies the output duration matches expected
  8. Prints a detailed summary with per-step timing
  9. Packaged as a reusable AI agent skill with SKILL.md, EXAMPLES.md, and invoke_cut.sh

Key metrics (on a ~2hr source file, ~53 min clip)

~1s
Naive -c copy
(wrong duration)
~67s
Post-input -ss
sequential
~61s
Post-input -ss
parallel
~4s
Pre-input -ss
sequential
~3s
Pre-input -ss
parallel

What we learned

-ss position matters a lot — pre-input is fast but changes -to semantics; post-input is accurate but slow for long seeks
Timebase consistency is critical for concat — mismatched time_base between segments causes duration corruption
Float precision in bash — always use awk for float comparisons, never truncate to integers
The real bottleneck wasn't encoding — it was ffmpeg decoding the entire file up to the seek point
Parallel processing helps less than expected when the dominant cost is a single fast operation (stream copy)

Tools used

ToolPurpose
ffmpegVideo cutting, encoding, concat
ffprobeKeyframe scanning, codec detection, duration verification
bcFloat arithmetic for durations
awkFloat comparisons, field parsing
bashScript orchestration, argument handling, timing

cut.sh

The full script as built through this collaboration session.

cut.sh
#!/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 "──────────────────────────────────────────────────────────"