Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ The ButterCut folder is one project with two parts:

## Core Workflow

You help with video tasks by processing raw video footage by analyzing transcripts and indexing visuals through libraries.
Claude/Codex/The Agent/You are working as an assistant AI video editor working for a (non-technical, non-engineer) video editor.

You help with video tasks by processing raw footage — video, plus any audio and still images — analyzing transcripts and indexing visuals through libraries.

Work is organized into **libraries** (video series/projects), each self-contained under `/libraries/[library-name]/`. When a user refers to a library, you you'll want to load the library file in memory. If they talk about building a roughcut, extracting dialogue, etc, you'll need to first find and read the correct library file. If it's not clear what library they're talking about, find recently modified libraries and list them for the user using the AskUserQuestionTool or similiar to see what library they want to work with. If it's clear what library they're referring to, just start working with that library.

Expand Down Expand Up @@ -42,6 +44,7 @@ Known migration triggers (match each to a `scripts/NNN_migrate_*.rb` script via
- video entries with `summary` missing (added in 0.5.0; missing means "todo", default to empty string)
- video entries with `transcript_path` / `visual_transcript_path` (renamed to `transcript` / `visual_transcript` in 0.3.0)
- video entries with `file_size_mb` (removed in 0.3.0)
- video entries with `media_type` missing (added in 0.8.0; missing means "predates audio/image support, default to `video`")
- library has a `roughcuts/` directory (renamed to `cuts/` when the `roughcut` skill became `cut`). This trigger is layout, not YAML — check the directory listing, not the schema.

A missing field is not the same as a field set to the template default — the template default only applies to freshly created libraries. If you see a schema issue not on this list, still check CHANGELOG.md; the list may be behind. After running migrations, re-read the library.yaml and continue with whatever the user asked for.
Expand Down Expand Up @@ -89,7 +92,7 @@ Writes (`add_videos`, `complete`, `update_metadata`), destructive resets, legacy
## Key Reminders

- After exporting an XML file, offer to open it directly in the user's editor with `open -a "Final Cut Pro"`, `open -a "Adobe Premiere Pro"`, or `open -a "DaVinci Resolve"` (matching the library's `editor` setting). Check `libraries/settings.yaml` for `open_in_editor_after_export` — if the key is missing, ask and save the preference. If `open -a` fails with "Unable to find application named ...", don't assume the editor is missing — the app may be installed under a slightly different name (e.g. a version suffix). Grep the installed apps for it (`ls /Applications | grep -i premiere`, or `mdfind "kMDItemKind == 'Application'" | grep -i resolve`) and retry `open -a` with the actual name before telling the user it isn't installed.
- Never modify source video files - always preserve originals
- Never modify source media files (video, audio, or images) - always preserve originals
- Flag areas needing human judgment rather than making assumptions
- When possible, use the existing Ruby files to get work done. Make scripts when the skill or step doesn't provide what you need.
- Parallelism caps live in each skill's `SKILL.md` (parent brief). Read it before dispatching sub agents.
Expand All @@ -106,9 +109,9 @@ Writes (`add_videos`, `complete`, `update_metadata`), destructive resets, legacy

ButterCut is designed to be geared toward working with non technical people using ButterCut via a client, Claude Cowork or Claude Code.

- **Input**: Array of full file paths to video files
- **Input**: Array of full file paths to footage files (video, audio, or still images)
- **Output**: Working XML file ready to import into the non-technical user's video editor (Final Cut, Premiere, Resolve)
- **Metadata Extraction**: Uses FFmpeg internally to extract video properties (duration, resolution, frame rate, audio rate, etc.)
- **Metadata Extraction**: Uses FFmpeg internally to extract media properties (duration, resolution, frame rate, audio rate, etc.)

### Vocabulary — talk like an editor, not a developer

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Audio and image footage (single-track).** Libraries can now import audio files (`.mp3/.wav/.m4a/.aac/.flac/.ogg`) and still images (`.jpg/.png/.heic/...`) alongside video. A new per-clip `media_type` field (`video | audio | image`, inferred from the file extension at add time) routes each clip through the right analysis: audio gets a transcript + summary (no contact sheet), images get a summary written from the image itself (no transcript, no contact sheet), video is unchanged. All three export onto the single timeline track in Final Cut, Premiere, and Resolve — audio clips emit an audio-only element, stills emit a video-only element with a default 5s duration (overridable per-clip in the cut). Staying single-track, a music/voiceover clip plays only during its own slot; there is no second track for music *under* video.
- **Daily update-check gate in the `Library` CLI.** Library commands now check a gitignored `last_buttercut_update_check` stamp first; once a day they raise `UpdateCheckNeeded`, surfaced as a `library:` error that tells the agent to check for a newer ButterCut and offer to update.

### Migration
- **`media_type` added to every clip (0.8.0).** Existing video entries are stamped `media_type: video`. Run:
```bash
ruby lib/buttercut/library.rb migrate
```

## [0.7.2] - 2026-06-02

Processing your footage now uses less of your account, your Mac stays awake while it works, and we've fixed a bug in Premiere files so vertical clips export to Premiere right side up.
Expand Down
38 changes: 0 additions & 38 deletions docs/basic-xml-generation.md

This file was deleted.

4 changes: 0 additions & 4 deletions docs/creating-a-cut.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,3 @@ Every cut exports a timeline into `libraries/[library-name]/cuts/`:
- **DaVinci Resolve** → `.xml`

If you've enabled it, Claude can also drop a copy on your Desktop and open the file directly in your editor after export.

## Direct XML generation

To generate a timeline in Ruby without going through Claude, see [basic-xml-generation.md](basic-xml-generation.md).
14 changes: 8 additions & 6 deletions docs/creating-a-library.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Creating a Video Library
# Creating a Library

A **library** organizes your video footage along with the transcripts, contact sheets, and summaries Claude needs to edit it. Creating one is the first thing you do with ButterCut — you can't build a cut until a library is processed.
A **library** organizes your footage — video, plus any audio (music/voiceover) and still images — along with the transcripts, contact sheets, and summaries Claude needs to edit it. Creating one is the first thing you do with ButterCut — you can't build a cut until a library is processed.

Just tell Claude you want a new library and it walks you through setup:

Expand All @@ -11,18 +11,20 @@ Claude: [Guides you through library setup and asks for details]

You:
- Library name: "wedding"
- Video location: "/path/to/videos"
- Footage location: "/path/to/footage"
- Language: "English"

Claude: [Automatically processes all videos]
Claude: [Automatically processes all footage]
✓ Creates the library structure
✓ Transcribes audio with word-level timing (WhisperX)
✓ Builds a contact sheet for every clip
✓ Transcribes audio with word-level timing (WhisperX) — video and audio clips
✓ Builds a contact sheet for every video clip
✓ Writes a short summary of each clip

Result: Full footage analysis, ready to build a cut
```

Video, audio, and still images can all live in one library on a single track. ButterCut infers each clip's type from its file extension and routes it: video gets all three steps, audio (music/voiceover) gets a transcript and summary, and an image gets just a summary (no contact sheet).

Claude handles parallel processing, metadata extraction (via FFmpeg), and analysis. Each library is self-contained under `libraries/[library-name]/`, with `library.yaml` as its source of truth.

## Adding footage or resuming
Expand Down
6 changes: 4 additions & 2 deletions docs/example-library-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

This walkthrough shows a complete example of using ButterCut with Claude Code to analyze wedding footage. The entire process—from initial request to fully analyzed footage—happens in one conversation with automatic parallel processing.

> This example library is all video. ButterCut also accepts audio (music/voiceover) and still images — they're added and analyzed the same way, each routed by its file type.

---

## The Conversation
Expand All @@ -14,7 +16,7 @@ I'll help you create a new library. Let me gather some information about your pr

**Questions:**
1. What would you like to name this library?
2. Where are your video files located?
2. Where is your footage located?
3. What language is spoken in these videos?

### **User**
Expand All @@ -23,7 +25,7 @@ I'll help you create a new library. Let me gather some information about your pr
### **Claude**
Perfect! The library will be named "wedding".

Now, what is the directory path containing your video files?
Now, what is the directory path containing your footage?

### **User**
> /Users/andrew/Final Cuts/Wedding Camcorder Footage
Expand Down
1 change: 0 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,3 @@ First tell Claude to create a **library** — it organizes your footage along wi
- [Installation](installation.md) — manual setup and dependency versions.
- [Creating a library](creating-a-library.md) — process footage into a library. ([full walkthrough](example-library-setup.md))
- [Creating a cut](creating-a-cut.md) — build a scene, selects reel, roughcut, or custom cut and export it.
- [Basic XML generation](basic-xml-generation.md) — generate timelines in Ruby without Claude Code.
11 changes: 8 additions & 3 deletions lib/buttercut.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@
# FCP7 itself is the shared xmeml-v5 format base that Resolve and Premiere subclass;
# it is not a public editor symbol.
#
# Example usage:
# Clips are built by lib/buttercut/export.rb — the only caller — from a cut's
# YAML against its library. Each clip hash carries a media_type
# (video | audio | image) so the generators route it onto the single timeline
# track; every clip must declare one (the constructor requires it). video/audio
# trim with start_at + duration; an image just takes an on-timeline duration:
# clips = [
# { path: '/absolute/path/to/video1.mov', start_at: 2.0, duration: 5.0 },
# { path: '/absolute/path/to/video2.mov' }
# { path: '/abs/interview.mov', start_at: 2.0, duration: 5.0, media_type: 'video' },
# { path: '/abs/music.mp3', start_at: 0.0, duration: 8.0, media_type: 'audio' },
# { path: '/abs/still.jpg', start_at: 0.0, duration: 5.0, media_type: 'image' }
# ]
# generator = ButterCut.new(clips, editor: :fcpx)
# generator.save('output.fcpxml')
Expand Down
82 changes: 70 additions & 12 deletions lib/buttercut/editor_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@ class EditorBase
DEFAULT_INITIAL_OFFSET = "0s"
DEFAULT_VOLUME_ADJUSTMENT = "-13.100000000000001db"

# Fallbacks for clips that lack a stream the timeline math expects: audio
# files have no video stream (no dimensions/frame rate), images have no
# audio stream. The values are only ever used to keep the fraction math and
# the sequence-format header well-formed — they never override a real clip's
# own metadata, and an audio/image clip emits no element that reads them.
DEFAULT_WIDTH = 1920
DEFAULT_HEIGHT = 1080
DEFAULT_FRAME_RATE = "30/1"
DEFAULT_SAMPLE_RATE = "48000"

# Stills have no source duration of their own; FCP7/FCPXML treat them as
# effectively unbounded media. Give an image asset a generous source
# duration so any chosen on-timeline length fits inside it.
IMAGE_ASSET_DURATION_SECONDS = 3600

# The media kinds a clip may declare. The exporter (the only caller) stamps
# one on every clip from library.yaml, and the constructor requires it — so
# the generators read the kind directly instead of guessing a default.
MEDIA_TYPES = %w[video audio image].freeze

attr_reader :clips, :initial_offset, :volume_adjustment

def initialize(clips)
Expand All @@ -34,6 +54,12 @@ def initialize(clips)
raise ArgumentError, "All video file paths must be absolute paths. Relative paths found: #{paths}"
end

clips.each_with_index do |clip, index|
next if MEDIA_TYPES.include?(clip[:media_type])

raise ArgumentError, "Clip at index #{index} must have a media_type of #{MEDIA_TYPES.join(', ')}, got #{clip[:media_type].inspect}"
end

@clips = clips
@initial_offset = DEFAULT_INITIAL_OFFSET
@volume_adjustment = DEFAULT_VOLUME_ADJUSTMENT
Expand Down Expand Up @@ -65,12 +91,16 @@ def audio_stream(video_path)
extract_metadata(video_path)['streams'].find { |s| s['codec_type'] == 'audio' }
end

# A clip's media kind (video|audio|image). The constructor requires every
# clip to declare one, so this is a plain read with no default to guess.
def clip_kind(clip) = clip[:media_type]

def video_width(video_path)
video_stream(video_path)['width']
video_stream(video_path)&.dig('width') || DEFAULT_WIDTH
end

def video_height(video_path)
video_stream(video_path)['height']
video_stream(video_path)&.dig('height') || DEFAULT_HEIGHT
end

# Degrees clockwise (0/90/180/270) the source must be rotated to display upright,
Expand All @@ -85,7 +115,12 @@ def video_duration(video_path)
end

def frame_rate(video_path)
video_stream(video_path)['r_frame_rate']
rate = video_stream(video_path)&.dig('r_frame_rate')
# No stream, or a degenerate rate (stills can report "0/0" or "N/D"): use a
# sane fallback so the fraction math downstream never divides by zero.
return DEFAULT_FRAME_RATE if rate.nil? || rate.start_with?('0/') || rate.end_with?('/0')

rate
end

def frame_duration(video_path)
Expand All @@ -95,7 +130,7 @@ def frame_duration(video_path)
end

def audio_sample_rate(video_path)
audio_stream(video_path)['sample_rate']
audio_stream(video_path)&.dig('sample_rate') || DEFAULT_SAMPLE_RATE
end

def nominal_frame_rate(video_path)
Expand Down Expand Up @@ -206,32 +241,43 @@ def duration_to_fraction(video_path)
"#{duration_num / divisor}/#{duration_denom / divisor}s"
end

# The clip whose metadata defines the sequence format. Prefer a real video
# clip; fall back to an image (a stills-only timeline still needs real
# dimensions); finally the first clip (an all-audio timeline, where the
# dimensions are a harmless placeholder no one sees).
def format_source_path
@format_source_path ||=
(@clips.find { |c| clip_kind(c) == 'video' } ||
@clips.find { |c| clip_kind(c) == 'image' } ||
@clips.first)[:path]
end

def format_width
video_width(@clips.first[:path])
video_width(format_source_path)
end

def format_height
video_height(@clips.first[:path])
video_height(format_source_path)
end

def format_frame_duration
frame_duration(@clips.first[:path])
frame_duration(format_source_path)
end

def format_frame_rate
frame_rate(@clips.first[:path])
frame_rate(format_source_path)
end

def format_nominal_frame_rate
nominal_frame_rate(@clips.first[:path])
nominal_frame_rate(format_source_path)
end

def format_color_space
color_space(@clips.first[:path])
color_space(format_source_path)
end

def format_audio_rate
audio_sample_rate(@clips.first[:path])
audio_sample_rate(format_source_path)
end

# Greatest Common Divisor (GCD): the largest whole number that divides two
Expand Down Expand Up @@ -358,6 +404,7 @@ def build_asset_map
abs_path = get_absolute_path(video_file_path)
next if file_to_asset.key?(abs_path)

kind = clip_kind(clip_def)
asset_id = deterministic_asset_id(abs_path)
asset_uid = deterministic_asset_uid(abs_path)
filename = get_filename(video_file_path)
Expand All @@ -370,7 +417,9 @@ def build_asset_map
filename: filename,
basename: get_basename(filename),
file_url: file_url,
asset_duration: duration_to_fraction(video_file_path),
has_video: kind != 'audio',
has_audio: kind != 'image',
asset_duration: asset_duration_fraction(video_file_path, kind),
audio_rate: audio_sample_rate(video_file_path),
timecode: clip_timecode_fraction(video_file_path),
frame_duration: frame_duration(video_file_path),
Expand All @@ -384,6 +433,15 @@ def build_asset_map
file_to_asset
end

# A still has no source duration of its own (`video_duration` reads 0), so
# give it a bounded placeholder that comfortably exceeds any on-timeline
# length. Video and audio carry a real duration.
def asset_duration_fraction(path, kind)
return seconds_to_fraction(IMAGE_ASSET_DURATION_SECONDS) if kind == 'image'

duration_to_fraction(path)
end

def build_timeline_clips(asset_map, timeline_frame_duration)
current_offset = initial_offset
clips = @clips.map do |clip_def|
Expand Down
Loading