feat(shotcut): adopt two-step import/add model, remove batch_mode

- Split add_clip into import_media() + add_clip(clip_id) for chain sharing
- Port upstream features: --at absolute placement, volume-envelope, duck
- Fix ducking window parser: use '..' separator instead of ':' to avoid
  timecode ambiguity (split(":",1) on "HH:MM:SS.mmm:HH:MM:SS.mmm")
- Remove batch_mode; chain sharing is now the default behavior
- Add 8 new tests (208 total), 91% coverage
This commit is contained in:
Feng Yu
2026-04-20 09:37:33 +08:00
parent e81c3a9409
commit 66be2a2e54
20 changed files with 3341 additions and 3136 deletions
+12
View File
@@ -1 +1,13 @@
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
.pytest_cache/
.coverage
coverage.json
htmlcov/
*.mlt
*.egg
.worktrees/
+1 -1
View File
@@ -205,7 +205,7 @@ agent-harness/
│ │ └── session.py # Stateful session, undo/redo
│ ├── utils/ # Shared utilities
│ │ ├── __init__.py
│ │ ├── mlt_xml.py # MLT XML parsing/generation (lxml)
│ │ ├── mlt_xml.py # MLT XML parsing/generation
│ │ └── time.py # Timecode ↔ frame conversion
│ └── tests/ # Test suites
│ ├── test_core.py # Unit tests (65 tests, synthetic)
@@ -7,7 +7,6 @@ without a GUI.
## Prerequisites
- Python 3.10+
- `lxml` (XML manipulation)
- `click` (CLI framework)
Optional (for interactive REPL):
@@ -20,7 +19,7 @@ Optional (for rendering/media probing):
## Install Dependencies
```bash
pip install lxml click prompt_toolkit
pip install click prompt_toolkit
```
## How to Run
@@ -74,7 +73,7 @@ timeline show # Visual timeline overview
timeline tracks # List all tracks
timeline add-track --type <video|audio> [--name N] # Add track
timeline remove-track <index> # Remove track
timeline add-clip <file> --track <n> [--in tc] [--out tc] # Add clip
timeline add-clip <clip_id> --track <n> [--in tc] [--out tc] [--at tc] # Add clip
timeline remove-clip <track> <clip> [--no-ripple] # Remove clip
timeline move-clip <track> <clip> --to-track <n> # Move clip
timeline trim <track> <clip> [--in tc] [--out tc] # Trim clip
@@ -95,6 +94,8 @@ filter add <name> [--track n] [--clip n] [--param k=v ...] # Apply filter
filter remove <index> [--track n] [--clip n] # Remove filter
filter set <index> <param> <value> [--track n] [--clip n] # Set param
filter list [--track n] [--clip n] # List active filters
filter volume-envelope [--track n] [--clip n] --point TIME=LEVEL ... # Volume envelope
filter duck [--track n] [--clip n] --window START..END ... # Ducking envelope
```
### Transitions
@@ -128,8 +129,9 @@ Available blend modes: `normal`, `add`, `multiply`, `screen`, `overlay`, `darken
### Media
```bash
media probe <file> # Analyze media file
media import <file> [--caption name] # Import media into project bin
media list # List media in project
media probe <file> # Analyze media file
media check # Check all files exist
media thumbnail <file> -o <output> [--time tc] # Extract thumbnail
```
@@ -190,13 +192,21 @@ python3 -m cli.shotcut_cli project new --profile hd1080p30 -o edit.mlt
python3 -m cli.shotcut_cli --project edit.mlt timeline add-track --type video --name "Main"
python3 -m cli.shotcut_cli --project edit.mlt timeline add-track --type audio --name "Music"
# Add clips (assuming media files exist)
python3 -m cli.shotcut_cli --project edit.mlt timeline add-clip intro.mp4 --track 1 --in 00:00:00.000 --out 00:00:05.000
python3 -m cli.shotcut_cli --project edit.mlt timeline add-clip main.mp4 --track 1 --in 00:00:00.000 --out 00:00:30.000
# Import media files into the project bin
python3 -m cli.shotcut_cli --project edit.mlt media import intro.mp4
python3 -m cli.shotcut_cli --project edit.mlt media import main.mp4
# Add clips to the timeline by clip_id
python3 -m cli.shotcut_cli --project edit.mlt timeline add-clip clip0 --track 1 --in 00:00:00.000 --out 00:00:05.000
python3 -m cli.shotcut_cli --project edit.mlt timeline add-clip clip1 --track 1 --in 00:00:00.000 --out 00:00:30.000 --at 00:00:08.000
# Apply a brightness filter to the first clip
python3 -m cli.shotcut_cli --project edit.mlt filter add brightness --track 1 --clip 0 --param level=1.3
# Duck the music during narration
python3 -m cli.shotcut_cli --project edit.mlt filter duck --track 2 \
--window 00:00:00.000:00:00:05.000 --duck 0.2
# View the timeline
python3 -m cli.shotcut_cli --project edit.mlt timeline show
@@ -1,10 +1,11 @@
"""Compositing: blend modes, picture-in-picture, and layer compositing."""
from typing import Optional
from lxml import etree
import xml.etree.ElementTree as ET
from ..utils import mlt_xml
from .session import Session
from .timeline import real_clip_entries
# Available blend modes for the cairo blend transition
@@ -60,9 +61,14 @@ def set_track_blend_mode(session: Session, track_index: int,
# Find the compositing transition for this track
comp_trans = _find_compositing_transition(tractor, track_index)
if comp_trans is None:
if comp_trans is not None:
cur_service = mlt_xml.get_property(comp_trans, "mlt_service", "")
if cur_service == "qtblend":
mlt_xml.set_property(comp_trans, "mlt_service", "frei0r.cairoblend")
mlt_xml.set_property(comp_trans, "disable", "0")
else:
# Create one if it doesn't exist
comp_trans = etree.SubElement(tractor, "transition")
comp_trans = ET.SubElement(tractor, "transition")
comp_trans.set("id", mlt_xml.new_id("transition"))
mlt_xml.set_property(comp_trans, "a_track", "0")
mlt_xml.set_property(comp_trans, "b_track", str(track_index))
@@ -126,9 +132,9 @@ def set_track_opacity(session: Session, track_index: int,
# Create new opacity filter
filt = mlt_xml.add_filter_to_element(playlist, "brightness",
{"alpha": str(opacity),
"level": "1",
"shotcut:filter": "opacity"})
shotcut_filter="opacity",
properties={"alpha": str(opacity),
"level": "1"})
return {"action": "set_track_opacity", "track_index": track_index,
"opacity": opacity}
@@ -166,8 +172,7 @@ def pip_position(session: Session, track_index: int, clip_index: int,
if playlist is None:
raise RuntimeError("Track playlist not found")
entries = mlt_xml.get_playlist_entries(playlist)
clip_entries = [e for e in entries if e["type"] == "entry"]
clip_entries = real_clip_entries(mlt_xml.get_playlist_entries(playlist), session.root)
if clip_index < 0 or clip_index >= len(clip_entries):
raise IndexError(f"Clip index {clip_index} out of range")
@@ -190,19 +195,20 @@ def pip_position(session: Session, track_index: int, clip_index: int,
# Create new affine filter
mlt_xml.add_filter_to_element(producer, "affine",
{"transition.geometry": geometry,
"background": "color:#00000000"})
shotcut_filter="affine",
properties={"transition.geometry": geometry,
"background": "color:#00000000"})
return {"action": "pip_position", "track_index": track_index,
"clip_index": clip_index, "geometry": geometry}
def _find_compositing_transition(tractor: etree._Element,
track_index: int) -> Optional[etree._Element]:
def _find_compositing_transition(tractor: ET.Element,
track_index: int) -> Optional[ET.Element]:
"""Find the compositing transition for a specific track."""
for trans in tractor.findall("transition"):
service = mlt_xml.get_property(trans, "mlt_service", "")
b_track = mlt_xml.get_property(trans, "b_track", "")
if service == "frei0r.cairoblend" and b_track == str(track_index):
if service in ("frei0r.cairoblend", "qtblend") and b_track == str(track_index):
return trans
return None
@@ -6,11 +6,9 @@ import shutil
from typing import Optional
from ..utils import mlt_xml
from ..utils.time import timecode_to_frames, frames_to_timecode
from .session import Session
# Export presets matching Shotcut's common presets
EXPORT_PRESETS = {
"default": {
"description": "H.264 High Profile, AAC (default quality)",
@@ -112,235 +110,6 @@ EXPORT_PRESETS = {
},
}
# Maps MLT filter service names to ffmpeg filter builders.
# Each builder takes the MLT filter's properties dict and returns
# an ffmpeg video or audio filter string (or None to skip).
_MLT_TO_FFMPEG_VIDEO = {
"brightness": lambda p: _build_brightness(p),
"frei0r.saturat0r": lambda p: f"eq=saturation={p.get('saturation', '1')}",
"frei0r.hueshift0r": lambda p: f"hue=h={float(p.get('shift', '0')) * 360:.1f}",
"sepia": lambda p: (
f"colorchannelmixer="
f"rr=0.393:rg=0.769:rb=0.189:"
f"gr=0.349:gg=0.686:gb=0.168:"
f"br=0.272:bg=0.534:bb=0.131"
),
"charcoal": lambda p: "edgedetect=mode=colormix:high=0",
"mirror": lambda p: "hflip" if p.get("mirror", "horizontal") == "horizontal" else "vflip",
"crop": lambda p: (
f"crop=iw-{int(p.get('left', 0))}-{int(p.get('right', 0))}:"
f"ih-{int(p.get('top', 0))}-{int(p.get('bottom', 0))}:"
f"{p.get('left', 0)}:{p.get('top', 0)}"
),
"frei0r.glow": lambda p: f"gblur=sigma={float(p.get('blur', '0.5')) * 10:.1f}",
"frei0r.IIRblur": lambda p: f"gblur=sigma={float(p.get('amount', '0.2')) * 20:.1f}",
"dynamictext": lambda p: _build_drawtext(p),
"greyscale": lambda p: "format=gray",
"affine": lambda p: None, # Complex — skip for now
"timewarp": lambda p: f"setpts={1/float(p.get('speed', '1')):.4f}*PTS",
}
_MLT_TO_FFMPEG_AUDIO = {
"volume": lambda p: _build_volume(p),
}
def _build_brightness(props: dict) -> str:
"""Convert MLT brightness filter to ffmpeg eq filter."""
level = props.get("level", "1.0")
# Check if it's a keyframed value (contains = and ;)
if "=" in level and ";" in level:
return _build_brightness_fade(level)
val = float(level)
# MLT brightness level: 1.0 = normal. ffmpeg eq brightness: 0 = normal.
# MLT level 1.2 → ffmpeg brightness +0.08 (approximate)
brightness = (val - 1.0) * 0.4
return f"eq=brightness={brightness:.3f}"
def _build_brightness_fade(keyframes: str) -> Optional[str]:
"""Convert keyframed brightness to ffmpeg fade filter."""
# Parse "00:00:00.000=0;00:00:01.000=1" format
parts = keyframes.split(";")
if len(parts) < 2:
return None
try:
first_val = float(parts[0].split("=")[1])
last_val = float(parts[-1].split("=")[1])
last_tc = parts[-1].split("=")[0]
# Parse duration
duration = _tc_to_seconds(last_tc)
if first_val < last_val:
# Fade in
return f"fade=t=in:st=0:d={duration:.3f}"
else:
# Fade out
return f"fade=t=out:st=0:d={duration:.3f}"
except (ValueError, IndexError):
return None
def _build_volume(props: dict) -> Optional[str]:
"""Convert MLT volume filter to ffmpeg volume/afade."""
level = props.get("level", props.get("gain", "1.0"))
if "=" in level and ";" in level:
return _build_volume_expression(level)
# Check if gain in dB
gain = props.get("gain")
if gain:
return f"volume={gain}dB"
return f"volume={level}"
def _build_volume_expression(keyframes: str) -> Optional[str]:
"""Convert keyframed volume to an ffmpeg volume expression."""
parts = [part for part in keyframes.split(";") if part]
if not parts:
return None
try:
points = []
for part in parts:
timecode, value = part.split("=", 1)
points.append((_tc_to_seconds(timecode), float(value)))
if len(points) == 1:
return f"volume={points[0][1]}"
def _segment(index: int) -> str:
current_t, current_val = points[index]
next_t, next_val = points[index + 1]
if next_t <= current_t:
return _segment(index + 1)
linear = (
f"({current_val:.6f}+({next_val:.6f}-{current_val:.6f})"
f"*(t-{current_t:.6f})/{(next_t - current_t):.6f})"
)
if index + 1 == len(points) - 1:
return linear
return f"if(lt(t,{next_t:.6f}),{linear},{_segment(index + 1)})"
return f"volume='if(lt(t,{points[0][0]:.6f}),{points[0][1]:.6f},{_segment(0)})'"
except (ValueError, IndexError):
return None
def _build_drawtext(props: dict) -> str:
"""Convert MLT dynamictext to ffmpeg drawtext."""
text = props.get("argument", "").replace("'", "\\'").replace(":", "\\:")
size = props.get("size", "48")
color = props.get("fgcolour", "#ffffffff")
# Convert #AARRGGBB to ffmpeg format
if len(color) == 9 and color.startswith("#"):
color = f"#{color[3:9]}" # Strip alpha, keep RRGGBB
halign = props.get("halign", "center")
valign = props.get("valign", "middle")
x = {"left": "10", "center": "(w-text_w)/2", "right": "w-text_w-10"}.get(halign, "(w-text_w)/2")
y = {"top": "10", "middle": "(h-text_h)/2", "bottom": "h-text_h-10"}.get(valign, "(h-text_h)/2")
return f"drawtext=text='{text}':fontsize={size}:fontcolor={color}:x={x}:y={y}"
def _tc_to_seconds(tc: str) -> float:
"""Quick timecode to seconds parser for filter keyframes."""
parts = tc.strip().split(":")
if len(parts) == 3:
h, m, rest = parts
if "." in rest:
s, ms = rest.split(".")
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms.ljust(3, "0")[:3]) / 1000
return int(h) * 3600 + int(m) * 60 + int(rest)
return float(tc)
def _merge_eq_filters(filters: list[str]) -> list[str]:
"""Merge multiple ffmpeg eq= filters into a single one.
ffmpeg only allows one eq filter per chain. So:
eq=brightness=0.06, eq=saturation=1.3
becomes:
eq=brightness=0.06:saturation=1.3
"""
eq_params = {}
result = []
for f in filters:
if f.startswith("eq="):
# Parse eq params
for part in f[3:].split(":"):
if "=" in part:
k, v = part.split("=", 1)
eq_params[k] = v
else:
result.append(f)
if eq_params:
eq_str = "eq=" + ":".join(f"{k}={v}" for k, v in eq_params.items())
# Insert eq early in the chain (after scale/trim but before fades/text)
result.insert(0, eq_str)
return result
def _get_clip_filters(session: Session, producer_id: str) -> tuple[list[str], list[str]]:
"""Extract ffmpeg video and audio filter strings from a producer's MLT filters.
Returns (video_filters, audio_filters).
"""
producer = mlt_xml.find_element_by_id(session.root, producer_id)
if producer is None:
return [], []
vfilters = []
afilters = []
for filt in producer.findall("filter"):
service = mlt_xml.get_property(filt, "mlt_service", "")
# Collect all properties
props = {}
for prop in filt.findall("property"):
name = prop.get("name", "")
if name and name != "mlt_service":
props[name] = prop.text or ""
# Try video filter mapping
if service in _MLT_TO_FFMPEG_VIDEO:
result = _MLT_TO_FFMPEG_VIDEO[service](props)
if result:
vfilters.append(result)
# Try audio filter mapping
if service in _MLT_TO_FFMPEG_AUDIO:
result = _MLT_TO_FFMPEG_AUDIO[service](props)
if result:
afilters.append(result)
return vfilters, afilters
def _get_track_filters(session: Session, playlist_id: str) -> tuple[list[str], list[str]]:
"""Extract ffmpeg filter strings from a track-level playlist's filters."""
playlist = mlt_xml.find_element_by_id(session.root, playlist_id)
if playlist is None:
return [], []
vfilters = []
afilters = []
for filt in playlist.findall("filter"):
service = mlt_xml.get_property(filt, "mlt_service", "")
props = {}
for prop in filt.findall("property"):
name = prop.get("name", "")
if name and name != "mlt_service":
props[name] = prop.text or ""
if service in _MLT_TO_FFMPEG_VIDEO:
result = _MLT_TO_FFMPEG_VIDEO[service](props)
if result:
vfilters.append(result)
if service in _MLT_TO_FFMPEG_AUDIO:
result = _MLT_TO_FFMPEG_AUDIO[service](props)
if result:
afilters.append(result)
return vfilters, afilters
def list_presets() -> list[dict]:
"""List all available export presets."""
@@ -372,22 +141,6 @@ def render(session: Session, output_path: str,
height: Optional[int] = None,
overwrite: bool = False,
extra_args: Optional[list[str]] = None) -> dict:
"""Render the project to an output file.
This works by:
1. Saving the current project to a temporary .mlt file
2. Using melt (if available) or ffmpeg to render it
3. For melt-less environments, generating an ffmpeg concat/filter script
Args:
session: Active session with an open project
output_path: Path for the output file
preset: Export preset name
width: Override output width
height: Override output height
overwrite: Overwrite existing output file
extra_args: Additional command-line arguments for the encoder
"""
if not session.is_open:
raise RuntimeError("No project is open")
@@ -401,101 +154,54 @@ def render(session: Session, output_path: str,
available = ", ".join(sorted(EXPORT_PRESETS.keys()))
raise ValueError(f"Unknown preset: {preset!r}. Available: {available}")
melt = shutil.which("melt")
if not melt:
raise RuntimeError(
"melt is required for rendering but not found. "
"Install it with: apt install melt (or equivalent for your OS)"
)
# No ffmpeg fallback — melt is the only render path because it natively
# reads MLT XML and handles all project features (transitions, compositing,
# multi-track). Direct ffmpeg encoding cannot interpret MLT projects.
preset_config = EXPORT_PRESETS[preset]
# Determine output format from preset or filename
output_ext = os.path.splitext(output_path)[1].lower()
if not output_ext:
fmt = preset_config.get("format", "mp4")
output_path += f".{fmt}"
# Try melt first, then ffmpeg
melt = shutil.which("melt")
if melt:
return _render_with_melt(session, output_path, preset_config, melt,
width, height, extra_args)
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
return _render_with_ffmpeg(session, output_path, preset_config, ffmpeg,
width, height, extra_args)
# Generate the render command for the user to run
return _generate_render_script(session, output_path, preset_config,
width, height)
def _set_tractor_out(session: Session) -> None:
"""Set tractor out= to actual timeline duration before passing to melt.
The tractor is created with out="00:00:00.000" and never updated as clips
are added. Without this fix, melt falls back to the longest track in the
multitrack — the 4-hour black background — and renders a 4-hour file.
"""
profile = session.get_profile()
fps_num = int(profile.get("frame_rate_num", 30000))
fps_den = int(profile.get("frame_rate_den", 1001))
tractor = mlt_xml.get_main_tractor(session.root)
if tractor is None:
return
tracks = mlt_xml.get_tractor_tracks(tractor)
max_frames = 0
for te in tracks:
prod_id = te.get("producer", "")
if prod_id == "background":
continue
playlist = mlt_xml.find_element_by_id(session.root, prod_id)
if playlist is None:
continue
total = 0
for child in playlist:
if child.tag == "entry":
in_f = timecode_to_frames(child.get("in", "0"), fps_num, fps_den)
out_f = timecode_to_frames(child.get("out", "0"), fps_num, fps_den)
total += max(0, out_f - in_f + 1)
elif child.tag == "blank":
try:
total += timecode_to_frames(child.get("length", "0"), fps_num, fps_den)
except Exception:
pass
max_frames = max(max_frames, total)
if max_frames > 0:
out_tc = frames_to_timecode(max_frames - 1, fps_num, fps_den)
tractor.set("out", out_tc)
# Cap the background track to the same duration so melt doesn't
# extend the render to the 4-hour background default.
bg_playlist = mlt_xml.find_element_by_id(session.root, "background")
if bg_playlist is not None:
for entry in bg_playlist.findall("entry"):
entry.set("out", out_tc)
black_producer = session.root.find(".//producer[@id='black']")
if black_producer is not None:
black_producer.set("out", out_tc)
return _render_with_melt(session, output_path, preset_config, melt,
width, height, extra_args)
def _render_with_melt(session: Session, output_path: str,
preset: dict, melt_path: str,
width: Optional[int], height: Optional[int],
extra_args: Optional[list[str]]) -> dict:
"""Render using melt command."""
import tempfile
# Fix tractor out before rendering — without this melt renders the full
# 4-hour background track instead of the actual content duration.
_set_tractor_out(session)
root = session.root
assert root is not None
# Save project to temp file
with tempfile.NamedTemporaryFile(suffix=".mlt", delete=False, mode="w") as f:
temp_mlt = f.name
mlt_xml.write_mlt(session.root, temp_mlt)
from .timeline import _update_tractor_out
_update_tractor_out(session)
old_producer = root.get("producer", "main_bin")
tractor = mlt_xml.get_main_tractor(root)
tractor_id = tractor.get("id", "tractor0") if tractor is not None else "tractor0"
root.set("producer", tractor_id)
try:
with tempfile.NamedTemporaryFile(suffix=".mlt", delete=False, mode="w") as f:
temp_mlt = f.name
mlt_xml.write_mlt(root, temp_mlt)
finally:
root.set("producer", old_producer)
try:
cmd = [melt_path, temp_mlt, "-consumer"]
# Build consumer string
consumer = f"avformat:{output_path}"
cmd.append(consumer)
@@ -538,206 +244,3 @@ def _render_with_melt(session: Session, output_path: str,
}
finally:
os.unlink(temp_mlt)
def _render_with_ffmpeg(session: Session, output_path: str,
preset: dict, ffmpeg_path: str,
width: Optional[int], height: Optional[int],
extra_args: Optional[list[str]]) -> dict:
"""Render using ffmpeg with filter_complex to apply MLT filters.
Reads all clips and their attached MLT filters, translates them
to an ffmpeg filter_complex graph, and renders.
"""
profile = session.get_profile()
proj_width = width or int(profile.get("width", 1920))
proj_height = height or int(profile.get("height", 1080))
# Gather clips from all non-background tracks (video tracks with entries)
tractor = session.get_main_tractor()
tracks = mlt_xml.get_tractor_tracks(tractor)
# Collect clips with their filters
clips = [] # list of {file, in, out, producer_id, playlist_id, vfilters, afilters}
for te in tracks:
prod_id = te.get("producer", "")
if prod_id == "background":
continue
playlist = mlt_xml.find_element_by_id(session.root, prod_id)
if playlist is None:
continue
# Get track-level filters
track_vf, track_af = _get_track_filters(session, prod_id)
entries = mlt_xml.get_playlist_entries(playlist)
for entry in entries:
if entry["type"] != "entry":
continue
producer = mlt_xml.find_element_by_id(session.root, entry["producer"])
if producer is None:
continue
resource = mlt_xml.get_property(producer, "resource", "")
if not resource or not os.path.isfile(resource):
continue
# Get clip-level filters
clip_vf, clip_af = _get_clip_filters(session, entry["producer"])
clips.append({
"file": resource,
"in": entry.get("in"),
"out": entry.get("out"),
"producer_id": entry["producer"],
"playlist_id": prod_id,
"vfilters": clip_vf + track_vf,
"afilters": clip_af + track_af,
})
if not clips:
raise RuntimeError("No renderable clips found in the project")
# Build ffmpeg command with filter_complex
cmd = [ffmpeg_path, "-y"]
# Add each clip as a separate input with trim points
for clip in clips:
if clip["in"]:
cmd.extend(["-ss", clip["in"]])
cmd.extend(["-i", clip["file"]])
if clip["out"] and clip["in"]:
# Duration = out - in; ffmpeg -t is relative to -ss
# We'll handle this in the filter_complex via trim instead
pass
# Build filter_complex
n = len(clips)
filter_parts = []
video_labels = []
audio_labels = []
for i, clip in enumerate(clips):
vlabel = f"v{i}"
alabel = f"a{i}"
# Start with input stream, scale to project resolution
vchain = [f"[{i}:v]scale={proj_width}:{proj_height}:force_original_aspect_ratio=decrease,"
f"pad={proj_width}:{proj_height}:(ow-iw)/2:(oh-ih)/2"]
# Apply trim if we have out point (since -ss already handles in)
if clip["out"] and clip["in"]:
in_sec = _tc_to_seconds(clip["in"])
out_sec = _tc_to_seconds(clip["out"])
duration = out_sec - in_sec
if duration > 0:
vchain.append(f"trim=duration={duration:.3f},setpts=PTS-STARTPTS")
# Apply video filters — merge multiple eq= into one
merged_vf = _merge_eq_filters(clip["vfilters"])
for vf in merged_vf:
vchain.append(vf)
filter_parts.append(",".join(vchain) + f"[{vlabel}]")
video_labels.append(f"[{vlabel}]")
# Audio chain
achain = [f"[{i}:a]asetpts=PTS-STARTPTS"]
if clip["out"] and clip["in"]:
in_sec = _tc_to_seconds(clip["in"])
out_sec = _tc_to_seconds(clip["out"])
duration = out_sec - in_sec
if duration > 0:
achain.append(f"atrim=duration={duration:.3f},asetpts=PTS-STARTPTS")
for af in clip["afilters"]:
achain.append(af)
filter_parts.append(",".join(achain) + f"[{alabel}]")
audio_labels.append(f"[{alabel}]")
# Concat all segments — interleaved order: [v0][a0][v1][a1]...
if n > 1:
concat_in = "".join(
f"{video_labels[i]}{audio_labels[i]}" for i in range(n)
)
filter_parts.append(
f"{concat_in}concat=n={n}:v=1:a=1[vout][aout]"
)
map_video = "[vout]"
map_audio = "[aout]"
else:
map_video = video_labels[0]
map_audio = audio_labels[0]
filter_complex = ";".join(filter_parts)
cmd.extend(["-filter_complex", filter_complex])
cmd.extend(["-map", map_video, "-map", map_audio])
# Encoding settings from preset
vcodec = preset.get("vcodec", "")
acodec = preset.get("acodec", "")
if vcodec:
cmd.extend(["-c:v", vcodec])
if acodec:
cmd.extend(["-c:a", acodec])
if preset.get("crf"):
cmd.extend(["-crf", preset["crf"]])
if preset.get("preset"):
cmd.extend(["-preset", preset["preset"]])
if preset.get("ab"):
cmd.extend(["-b:a", preset["ab"]])
cmd.extend(["-movflags", "+faststart"])
if extra_args:
cmd.extend(extra_args)
cmd.append(output_path)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
if result.returncode != 0:
raise RuntimeError(
f"ffmpeg render failed:\n{result.stderr[-1000:]}\n\n"
f"Command: {' '.join(cmd)}"
)
return {
"action": "render",
"output": output_path,
"method": "ffmpeg-filtergraph",
"success": True,
"clip_count": n,
"filters_applied": sum(len(c["vfilters"]) + len(c["afilters"]) for c in clips),
"size_bytes": os.path.getsize(output_path) if os.path.exists(output_path) else 0,
}
def _generate_render_script(session: Session, output_path: str,
preset: dict,
width: Optional[int], height: Optional[int]) -> dict:
"""When no rendering tools are available, save the project and generate instructions."""
# Save the project to a known location
project_dir = os.path.dirname(output_path)
project_file = os.path.join(project_dir, "_render_project.mlt")
mlt_xml.write_mlt(session.root, project_file)
vcodec = preset.get("vcodec", "libx264")
acodec = preset.get("acodec", "aac")
melt_cmd = (
f"melt {project_file} -consumer avformat:{output_path} "
f"vcodec={vcodec} acodec={acodec}"
)
if preset.get("crf"):
melt_cmd += f" crf={preset['crf']}"
if preset.get("preset"):
melt_cmd += f" preset={preset['preset']}"
return {
"action": "render_script",
"project_file": project_file,
"output": output_path,
"melt_command": melt_cmd,
"note": "Neither melt nor ffmpeg found. Install one and run the command above.",
"install_hint": "apt install melt ffmpeg # or equivalent for your OS",
}
@@ -1,11 +1,13 @@
"""Filter management: apply, remove, configure filters on clips and tracks."""
from typing import Optional
from lxml import etree
import xml.etree.ElementTree as ET
from ..utils.time import parse_time_input, frames_to_timecode
from ..utils import mlt_xml
from ..utils.time import parse_time_input, frames_to_timecode
from .session import Session
from .timeline import real_clip_entries
# Registry of commonly used MLT filters with their parameters
@@ -758,7 +760,7 @@ def get_filter_info(filter_name: str) -> dict:
def _resolve_target(session: Session, track_index: Optional[int] = None,
clip_index: Optional[int] = None) -> etree._Element:
clip_index: Optional[int] = None) -> ET.Element:
"""Resolve the target element for a filter (clip producer or track playlist)."""
if track_index is None:
# Apply to the main tractor (global filter)
@@ -779,8 +781,7 @@ def _resolve_target(session: Session, track_index: Optional[int] = None,
return playlist
# Apply to a specific clip's producer
entries = mlt_xml.get_playlist_entries(playlist)
clip_entries = [e for e in entries if e["type"] == "entry"]
clip_entries = real_clip_entries(mlt_xml.get_playlist_entries(playlist), session.root)
if clip_index < 0 or clip_index >= len(clip_entries):
raise IndexError(f"Clip index {clip_index} out of range")
@@ -791,14 +792,6 @@ def _resolve_target(session: Session, track_index: Optional[int] = None,
return producer
def _find_filter_index(target: etree._Element, service: str) -> tuple[int | None, etree._Element | None]:
"""Find the first filter on a target by MLT service name."""
for index, filt in enumerate(target.findall("filter")):
if mlt_xml.get_property(filt, "mlt_service", "") == service:
return index, filt
return None, None
def add_filter(session: Session, filter_name: str,
track_index: Optional[int] = None,
clip_index: Optional[int] = None,
@@ -831,7 +824,8 @@ def add_filter(session: Session, filter_name: str,
props = params or {}
target = _resolve_target(session, track_index, clip_index)
filt = mlt_xml.add_filter_to_element(target, service, props)
shotcut_filter_name = filter_name if filter_name in FILTER_REGISTRY else None
filt = mlt_xml.add_filter_to_element(target, service, shotcut_filter_name, props)
target_desc = "global"
if track_index is not None and clip_index is not None:
@@ -847,6 +841,8 @@ def add_filter(session: Session, filter_name: str,
"target": target_desc,
"params": props,
}
def remove_filter(session: Session, filter_index: int,
track_index: Optional[int] = None,
clip_index: Optional[int] = None) -> dict:
@@ -942,6 +938,13 @@ def list_filters(session: Session,
return result
def _find_filter_index(target: ET.Element, service: str) -> tuple[int | None, ET.Element | None]:
for index, filt in enumerate(target.findall("filter")):
if mlt_xml.get_property(filt, "mlt_service", "") == service:
return index, filt
return None, None
def set_volume_envelope(
session: Session,
points: list[tuple[str, str]],
@@ -949,14 +952,11 @@ def set_volume_envelope(
track_index: Optional[int] = None,
clip_index: Optional[int] = None,
) -> dict:
"""Create or update a keyframed volume envelope."""
if not points:
raise ValueError("At least one time=level point is required")
session.checkpoint()
profile = session.get_profile()
fps_num = int(profile.get("frame_rate_num", 30000))
fps_den = int(profile.get("frame_rate_den", 1001))
fps_num, fps_den = int(session.get_profile()["frame_rate_num"]), int(session.get_profile()["frame_rate_den"])
normalized: dict[int, str] = {}
for timecode, level in points:
@@ -971,7 +971,7 @@ def set_volume_envelope(
target = _resolve_target(session, track_index, clip_index)
filter_index, filt = _find_filter_index(target, "volume")
if filt is None:
filt = mlt_xml.add_filter_to_element(target, "volume", {"level": envelope})
filt = mlt_xml.add_filter_to_element(target, "volume", properties={"level": envelope})
filter_index = len(target.findall("filter")) - 1
old_value = None
else:
@@ -998,13 +998,10 @@ def duck_volume(
attack: str = "00:00:00.150",
release: str = "00:00:00.250",
) -> dict:
"""Apply a simple ducking envelope over one or more absolute timeline windows."""
if not windows:
raise ValueError("At least one ducking window is required")
profile = session.get_profile()
fps_num = int(profile.get("frame_rate_num", 30000))
fps_den = int(profile.get("frame_rate_den", 1001))
fps_num, fps_den = int(session.get_profile()["frame_rate_num"]), int(session.get_profile()["frame_rate_den"])
attack_frames = parse_time_input(attack, fps_num, fps_den)
release_frames = parse_time_input(release, fps_num, fps_den)
@@ -1023,21 +1020,17 @@ def duck_volume(
result = set_volume_envelope(
session,
[
(frames_to_timecode(frame, fps_num, fps_den), level)
for frame, level in sorted(frame_points.items())
],
[(frames_to_timecode(frame, fps_num, fps_den), level)
for frame, level in sorted(frame_points.items())],
track_index=track_index,
clip_index=clip_index,
)
result.update(
{
"action": "duck_volume",
"windows": sorted_windows,
"normal_level": normal_level,
"duck_level": duck_level,
"attack": attack,
"release": release,
}
)
result.update({
"action": "duck_volume",
"windows": sorted_windows,
"normal_level": normal_level,
"duck_level": duck_level,
"attack": attack,
"release": release,
})
return result
@@ -138,30 +138,116 @@ def list_media(session: Session) -> list[dict]:
if not session.is_open:
raise RuntimeError("No project is open")
producers = mlt_xml.get_all_producers(session.root)
result = []
for prod in producers:
service = mlt_xml.get_property(prod, "mlt_service", "")
resource = mlt_xml.get_property(prod, "resource", "")
# Skip internal producers (black background, etc.)
if service in ("color", "colour") or resource in ("0", ""):
continue
for clip_id, chain in session._bin_chains.items():
resource = mlt_xml.get_property(chain, "resource", "")
result.append({
"id": prod.get("id"),
"clip_id": clip_id,
"resource": resource,
"caption": mlt_xml.get_property(prod, "shotcut:caption", ""),
"service": service or "avformat",
"in": prod.get("in", ""),
"out": prod.get("out", ""),
"caption": mlt_xml.get_property(chain, "shotcut:caption", ""),
"in": chain.get("in", ""),
"out": chain.get("out", ""),
"exists": os.path.isfile(resource) if resource else False,
})
return result
def import_media(session: Session, resource: str,
caption: str | None = None) -> dict:
"""Import a media file into the project bin.
If the file is already imported, returns the existing clip_id.
"""
if not session.is_open:
raise RuntimeError("No project is open")
resource = os.path.abspath(resource)
if not os.path.isfile(resource):
raise FileNotFoundError(f"Media file not found: {resource}")
existing_id = session._clip_resources.get(resource)
if existing_id is not None:
chain = session._bin_chains.get(existing_id)
return {
"action": "import_media",
"clip_id": existing_id,
"source": resource,
"already_imported": True,
"caption": mlt_xml.get_property(chain, "shotcut:caption", "") if chain is not None else "",
}
info = probe_media(resource)
has_video = bool(info.get("video_streams"))
has_audio = bool(info.get("audio_streams"))
video_index = "0" if has_video else "-1"
audio_index = "1" if has_audio and has_video else ("0" if has_audio else "-1")
if not has_video and not has_audio:
video_index, audio_index = "0", "1"
from ..utils.time import frames_to_timecode
from .timeline import _get_fps
fps_num, fps_den = _get_fps(session)
duration = info.get("duration_seconds", 0)
if duration > 0:
out_point = frames_to_timecode(
round(duration * fps_num / fps_den), fps_num, fps_den)
length_tc = frames_to_timecode(
round(duration * fps_num / fps_den) + 1, fps_num, fps_den)
else:
out_point = None
length_tc = None
clip_id = f"clip{session._clip_id_counter}"
session._clip_id_counter += 1
session.checkpoint()
extra = {"video_index": video_index, "audio_index": audio_index}
bin_chain = mlt_xml.create_chain(
session.root, resource,
in_point="00:00:00.000",
out_point=out_point,
caption=caption or os.path.basename(resource),
extra_props=extra,
insert_idx=session._bin_insert_idx,
length=length_tc,
)
session._bin_insert_idx += 1
mlt_xml.add_chain_to_bin(session.root, bin_chain)
session._bin_chains[clip_id] = bin_chain
session._clip_ids[clip_id] = resource
session._clip_resources[resource] = clip_id
return {
"action": "import_media",
"clip_id": clip_id,
"source": resource,
"caption": caption or os.path.basename(resource),
"duration": duration,
"video_streams": len(info.get("video_streams", [])),
"audio_streams": len(info.get("audio_streams", [])),
}
def get_clip_info(session: Session, clip_id: str) -> dict:
"""Get info about an imported clip."""
chain = session._bin_chains.get(clip_id)
if chain is None:
available = ", ".join(sorted(session._bin_chains.keys()))
raise ValueError(f"Clip {clip_id!r} not found. Available: {available}")
resource = mlt_xml.get_property(chain, "resource", "")
return {
"clip_id": clip_id,
"resource": resource,
"caption": mlt_xml.get_property(chain, "shotcut:caption", ""),
"in": chain.get("in", ""),
"out": chain.get("out", ""),
}
def check_media_files(session: Session) -> dict:
"""Check all media files in the project for existence.
@@ -1,8 +1,6 @@
"""Project management operations."""
import os
from typing import Optional
from lxml import etree
from ..utils import mlt_xml
from .session import Session
@@ -62,6 +60,23 @@ PROFILES = {
}
def _get_bin_ids(root):
main_bin = mlt_xml.find_element_by_id(root, "main_bin")
if main_bin is None:
return set()
return {entry.get("producer", "") for entry in main_bin.findall("entry")}
def _get_media_producers(root):
bin_ids = _get_bin_ids(root)
return [
p for p in mlt_xml.get_all_producers(root)
if mlt_xml.get_property(p, "mlt_service") not in ("color", "colour")
and mlt_xml.get_property(p, "resource") not in ("0", "")
and not (p.tag == "chain" and p.get("id", "") not in bin_ids)
]
def new_project(session: Session, profile_name: str = "hd1080p30") -> dict:
"""Create a new blank project.
@@ -104,21 +119,12 @@ def open_project(session: Session, path: str) -> dict:
except RuntimeError:
track_count = 0
# Count producers
producers = mlt_xml.get_all_producers(session.root)
# Filter out internal producers (black, etc.)
media_producers = [
p for p in producers
if mlt_xml.get_property(p, "mlt_service") not in ("color", "colour")
and mlt_xml.get_property(p, "resource") not in ("0", "")
]
return {
"action": "open_project",
"path": session.project_path,
"profile": profile,
"track_count": track_count,
"media_clip_count": len(media_producers),
"media_clip_count": len(_get_media_producers(session.root)),
}
@@ -147,21 +153,16 @@ def project_info(session: Session) -> dict:
profile = session.get_profile()
root = session.root
# Producers
all_producers = mlt_xml.get_all_producers(root)
media_producers = []
for p in all_producers:
service = mlt_xml.get_property(p, "mlt_service")
resource = mlt_xml.get_property(p, "resource", "")
if service not in ("color", "colour") and resource not in ("0", ""):
media_producers.append({
"id": p.get("id"),
"resource": resource,
"caption": mlt_xml.get_property(p, "shotcut:caption", ""),
"in": p.get("in", ""),
"out": p.get("out", ""),
"service": service or "avformat",
})
for p in _get_media_producers(root):
media_producers.append({
"id": p.get("id"),
"resource": mlt_xml.get_property(p, "resource", ""),
"caption": mlt_xml.get_property(p, "shotcut:caption", ""),
"in": p.get("in", ""),
"out": p.get("out", ""),
"service": mlt_xml.get_property(p, "mlt_service") or "avformat",
})
# Tracks
tracks_info = []
@@ -193,7 +194,8 @@ def project_info(session: Session) -> dict:
track_data["type"] = "video"
entries = mlt_xml.get_playlist_entries(playlist)
track_data["clip_count"] = sum(1 for e in entries if e["type"] == "entry")
from .timeline import real_clip_entries
track_data["clip_count"] = len(real_clip_entries(entries, root))
track_data["blank_count"] = sum(1 for e in entries if e["type"] == "blank")
else:
track_data["type"] = "unknown"
@@ -6,11 +6,10 @@ Sessions persist to disk as JSON so they survive process restarts.
import json
import os
import copy
import time
from pathlib import Path
from typing import Optional
from lxml import etree
import xml.etree.ElementTree as ET
from ..utils import mlt_xml
@@ -47,16 +46,38 @@ MAX_UNDO_DEPTH = 50
class Session:
"""Represents a stateful CLI editing session."""
"""Represents a stateful CLI editing session.
Single-session architecture: there is always exactly one Session per process.
- CLI one-shot mode: cli() creates one Session, runs one command, exits.
- REPL mode: the REPL loop holds one Session. Running `new` or `open`
replaces the current project inside that session — the old project is
discarded. There is no way to have multiple concurrent sessions in one
process, so cached node references (main_bin, main_tractor, _track_playlists,
etc.) never become stale due to a different session's edits.
This means global state like mlt_xml._parent_map (which tracks element
parentage for the current tree) is inherently single-session and does not
need per-session isolation.
"""
def __init__(self, session_id: Optional[str] = None):
self.session_id = session_id or f"session_{int(time.time())}"
self.project_path: Optional[str] = None
self.root: Optional[etree._Element] = None
self._undo_stack: list[bytes] = [] # Serialized XML snapshots
self.root: Optional[ET.Element] = None
self._undo_stack: list[bytes] = []
self._redo_stack: list[bytes] = []
self._modified = False
self._metadata: dict = {}
self.main_bin: Optional[ET.Element] = None
self.main_tractor: Optional[ET.Element] = None
self._track_playlists: list[ET.Element] = []
self._bin_chains: dict[str, ET.Element] = {}
self._timeline_insert_idx: int = 0
self._bin_insert_idx: int = 0
self._clip_id_counter: int = 0
self._clip_ids: dict[str, str] = {}
self._clip_resources: dict[str, str] = {}
@property
def is_open(self) -> bool:
@@ -70,7 +91,7 @@ class Session:
"""Capture current state for undo."""
if self.root is None:
return b""
return etree.tostring(self.root, xml_declaration=True, encoding="utf-8")
return ET.tostring(self.root, xml_declaration=True, encoding="utf-8")
def _push_undo(self) -> None:
"""Save current state to undo stack before a mutation."""
@@ -82,9 +103,6 @@ class Session:
self._redo_stack.clear()
def checkpoint(self) -> None:
"""Create a checkpoint before performing a mutation.
Call this before any operation that changes the project.
"""
self._push_undo()
self._modified = True
@@ -92,11 +110,12 @@ class Session:
"""Undo the last operation. Returns True if successful."""
if not self._undo_stack:
return False
# Save current state to redo
self._redo_stack.append(self._snapshot())
# Restore previous state
prev = self._undo_stack.pop()
self.root = etree.fromstring(prev)
mlt_xml._clear_parent_map()
self.root = ET.fromstring(prev)
mlt_xml._register_tree(self.root)
self._resolve_refs()
self._modified = bool(self._undo_stack)
return True
@@ -106,10 +125,42 @@ class Session:
return False
self._undo_stack.append(self._snapshot())
nxt = self._redo_stack.pop()
self.root = etree.fromstring(nxt)
mlt_xml._clear_parent_map()
self.root = ET.fromstring(nxt)
mlt_xml._register_tree(self.root)
self._resolve_refs()
self._modified = True
return True
def _resolve_refs(self) -> None:
self.main_bin = mlt_xml.find_element_by_id(self.root, "main_bin")
self.main_tractor = mlt_xml.get_main_tractor(self.root)
self._track_playlists = []
if self.main_tractor is not None:
for track in mlt_xml.get_tractor_tracks(self.main_tractor):
pid = track.get("producer")
pl = mlt_xml.find_element_by_id(self.root, pid) if pid else None
self._track_playlists.append(pl)
self._bin_chains = {}
self._clip_ids = {}
self._clip_resources = {}
self._clip_id_counter = 0
if self.main_bin is not None:
for entry in self.main_bin.findall("entry"):
chain_id = entry.get("producer")
if chain_id:
chain = mlt_xml.find_element_by_id(self.root, chain_id)
if chain is not None:
resource = mlt_xml.get_property(chain, "resource")
if resource:
clip_id = f"clip{self._clip_id_counter}"
self._clip_id_counter += 1
self._bin_chains[clip_id] = chain
self._clip_ids[clip_id] = resource
self._clip_resources[resource] = clip_id
self._timeline_insert_idx = mlt_xml.find_insert_index_for_timeline_chain(self.root)
self._bin_insert_idx = mlt_xml._find_insert_index_for_bin_chain(self.root)
def new_project(self, profile: Optional[dict] = None) -> None:
"""Create a new blank project."""
if profile is None:
@@ -121,6 +172,7 @@ class Session:
"progressive": "1", "colorspace": "709",
}
self.root = mlt_xml.create_blank_project(profile)
self._resolve_refs()
self.project_path = None
self._undo_stack.clear()
self._redo_stack.clear()
@@ -132,6 +184,7 @@ class Session:
if not os.path.isfile(path):
raise FileNotFoundError(f"Project file not found: {path}")
self.root = mlt_xml.parse_mlt(path)
self._resolve_refs()
self.project_path = path
self._undo_stack.clear()
self._redo_stack.clear()
@@ -159,7 +212,7 @@ class Session:
return {}
return dict(prof.attrib)
def get_main_tractor(self) -> etree._Element:
def get_main_tractor(self) -> ET.Element:
"""Get the main timeline tractor."""
if self.root is None:
raise RuntimeError("No project is open")
@@ -1,27 +1,141 @@
"""Timeline operations: tracks, clips, trimming, splitting, moving."""
import os
import uuid
from typing import Optional
from lxml import etree
import xml.etree.ElementTree as ET
from ..utils import mlt_xml
from ..utils.time import parse_time_input, frames_to_timecode
from .session import Session
def _get_track_playlist(session: Session, track_index: int) -> etree._Element:
def is_transition_entry(entry: ET.Element, root: ET.Element) -> bool:
"""Check if a playlist entry element references a transition sub-tractor."""
if entry.tag != "entry":
return False
prod_id = entry.get("producer", "")
prod = mlt_xml.find_element_by_id(root, prod_id)
return (prod is not None
and prod.tag == "tractor"
and mlt_xml.get_property(prod, "shotcut:transition") is not None)
def is_transition_entry_by_dict(entry_dict: dict, root: ET.Element) -> bool:
"""Check if a playlist entry dict (from get_playlist_entries) references a transition."""
if entry_dict.get("type") != "entry":
return False
prod_id = entry_dict.get("producer", "")
prod = mlt_xml.find_element_by_id(root, prod_id)
return (prod is not None
and prod.tag == "tractor"
and mlt_xml.get_property(prod, "shotcut:transition") is not None)
def _remove_adjacent_transitions(root: ET.Element, playlist: ET.Element,
target_entry: ET.Element,
fps_num: int, fps_den: int) -> None:
"""Remove transitions directly adjacent to a specific playlist entry."""
from . import transitions as trans_mod
children = list(playlist)
idx = None
for i, child in enumerate(children):
if child is target_entry:
idx = i
break
if idx is None:
return
# Check entry before target
if idx > 0:
prev = children[idx - 1]
if prev.tag == "entry" and is_transition_entry(prev, root):
trans_id = prev.get("producer", "")
trans_elem = mlt_xml.find_element_by_id(root, trans_id)
if trans_elem is not None:
trans_mod._remove_transition_and_restore(root, trans_elem, fps_num, fps_den,
skip_producer=target_entry.get("producer", ""))
if prev in list(playlist):
playlist.remove(prev)
# Re-read children after potential removal above
children = list(playlist)
idx = None
for i, child in enumerate(children):
if child is target_entry:
idx = i
break
if idx is None:
return
# Check entry after target
if idx + 1 < len(children):
nxt = children[idx + 1]
if nxt.tag == "entry" and is_transition_entry(nxt, root):
trans_id = nxt.get("producer", "")
trans_elem = mlt_xml.find_element_by_id(root, trans_id)
if trans_elem is not None:
trans_mod._remove_transition_and_restore(root, trans_elem, fps_num, fps_den,
skip_producer=target_entry.get("producer", ""))
if nxt in list(playlist):
playlist.remove(nxt)
def real_clip_entries(entries: list[dict], root: ET.Element) -> list[dict]:
"""Filter playlist entry dicts to real clips, excluding transitions."""
trans_ids = _get_transition_ids(root)
return [e for e in entries
if e["type"] == "entry"
and e.get("producer", "") not in trans_ids]
def _get_transition_ids(root: ET.Element) -> set[str]:
return {c.get("id", "") for c in root
if c.tag == "tractor"
and not mlt_xml.get_property(c, "shotcut")
and mlt_xml.get_property(c, "shotcut:transition")}
def _get_track_playlist(session: Session, track_index: int) -> ET.Element:
"""Get the playlist element for a track by its index."""
tractor = session.get_main_tractor()
tracks = mlt_xml.get_tractor_tracks(tractor)
if track_index < 0 or track_index >= len(tracks):
raise IndexError(f"Track index {track_index} out of range (0-{len(tracks)-1})")
producer_id = tracks[track_index].get("producer")
playlist = mlt_xml.find_element_by_id(session.root, producer_id)
if track_index < 0 or track_index >= len(session._track_playlists):
raise IndexError(f"Track index {track_index} out of range (0-{len(session._track_playlists)-1})")
playlist = session._track_playlists[track_index]
if playlist is None:
raise RuntimeError(f"Playlist {producer_id!r} not found for track {track_index}")
raise RuntimeError(f"No playlist for track {track_index}")
return playlist
def _resolve_insert_index(playlist: ET.Element, position: int,
root: ET.Element) -> int:
"""Map a logical clip position to a physical playlist child index.
Counts real clips (skips transition entries). When the target position
falls between a transition entry and its following clip, backs up to
insert before the transition entry so the transition pair stays intact.
"""
all_children = list(playlist)
children = [c for c in all_children if c.tag != "property"]
real_count = 0
for i, child in enumerate(children):
if child.tag == "entry" and is_transition_entry(child, root):
continue
if child.tag == "blank":
continue
if real_count == position:
idx = i
while idx > 0:
prev = children[idx - 1]
if prev.tag == "entry" and is_transition_entry(prev, root):
idx -= 1
else:
break
return all_children.index(children[idx])
real_count += 1
return len(all_children)
def _get_fps(session: Session) -> tuple[int, int]:
"""Get fps_num, fps_den from the project profile."""
profile = session.get_profile()
@@ -31,11 +145,9 @@ def _get_fps(session: Session) -> tuple[int, int]:
def _entry_duration_frames(session: Session, entry: dict) -> int:
"""Return duration in frames for a playlist entry or blank."""
fps_num, fps_den = _get_fps(session)
if entry["type"] == "blank":
return parse_time_input(entry["length"], fps_num, fps_den)
in_point = entry.get("in") or "00:00:00.000"
out_point = entry.get("out")
if not out_point:
@@ -44,13 +156,8 @@ def _entry_duration_frames(session: Session, entry: dict) -> int:
def _absolute_insertion_point(
session: Session, playlist: etree._Element, at_time: str
session: Session, playlist: ET.Element, at_time: str
) -> tuple[int, int, int]:
"""Resolve where a new clip can start on a track timeline.
Returns:
(insert_child_index, leading_blank_frames, trailing_blank_frames)
"""
fps_num, fps_den = _get_fps(session)
target = parse_time_input(at_time, fps_num, fps_den)
if target < 0:
@@ -64,12 +171,15 @@ def _absolute_insertion_point(
duration = _entry_duration_frames(session, entry)
start = timeline_cursor
end = start + duration
prop_offset = sum(1 for c in children[:sum(1 for e in entries[:idx]
if e is entry)]
if c.tag == "property")
if target == start:
return idx + len([c for c in children[: idx] if c.tag == "property"]), 0, 0
return idx + prop_offset, 0, 0
if entry["type"] == "blank" and start < target < end:
leading = target - start
trailing = end - target
return idx + len([c for c in children[: idx] if c.tag == "property"]), leading, trailing
return idx + prop_offset, leading, trailing
if entry["type"] == "entry" and start < target < end:
raise RuntimeError(
f"Timeline position {at_time} overlaps an existing clip on track; "
@@ -80,6 +190,74 @@ def _absolute_insertion_point(
return len(children), max(0, target - timeline_cursor), 0
def _prepare_insert_index(playlist: ET.Element, position: int,
session: Session) -> int:
from .transitions import _remove_transition_and_restore
insert_idx = _resolve_insert_index(playlist, position, session.root)
children = list(playlist)
if insert_idx < len(children):
child_at = children[insert_idx]
if child_at.tag == "entry" and is_transition_entry(child_at, session.root):
trans_id = child_at.get("producer")
trans_tractor = mlt_xml.find_element_by_id(session.root, trans_id)
if trans_tractor is not None:
fps_num, fps_den = _get_fps(session)
_remove_transition_and_restore(
session.root, trans_tractor, fps_num, fps_den)
insert_idx = _resolve_insert_index(playlist, position, session.root)
return insert_idx
def _update_tractor_out(session: Session) -> None:
"""Update main tractor out to match the longest track duration."""
fps_num, fps_den = _get_fps(session)
tractor = session.get_main_tractor()
max_frames = 0
for track_elem in mlt_xml.get_tractor_tracks(tractor):
playlist_id = track_elem.get("producer")
if not playlist_id or playlist_id == "background":
continue
playlist = mlt_xml.find_element_by_id(session.root, playlist_id)
if playlist is None:
continue
track_frames = 0
for child in playlist:
if child.tag == "entry":
in_tc = child.get("in", "00:00:00.000")
out_tc = child.get("out")
if out_tc is None:
producer_id = child.get("producer", "")
producer = mlt_xml.find_element_by_id(session.root, producer_id)
if producer is not None:
out_tc = producer.get("out", "00:00:00.000")
else:
out_tc = "00:00:00.000"
track_frames += parse_time_input(out_tc, fps_num, fps_den)
track_frames -= parse_time_input(in_tc, fps_num, fps_den)
elif child.tag == "blank":
track_frames += parse_time_input(child.get("length", "00:00:00.000"), fps_num, fps_den)
max_frames = max(max_frames, track_frames)
out_tc = frames_to_timecode(max_frames, fps_num, fps_den) if max_frames > 0 else "00:00:00.000"
mlt_xml.set_tractor_out(session.root, out_tc)
# Sync background producer and playlist entry to match — melt ignores
# tractor out and renders until the longest track playlist entry ends.
bg_producer = mlt_xml.find_element_by_id(session.root, "black")
if bg_producer is not None:
bg_producer.set("out", out_tc)
mlt_xml.set_property(bg_producer, "length",
frames_to_timecode(max_frames + 1, fps_num, fps_den)
if max_frames > 0 else "00:00:00.040")
bg_playlist = mlt_xml.find_element_by_id(session.root, "background")
if bg_playlist is not None:
for entry in bg_playlist.findall("entry"):
entry.set("out", out_tc)
def add_track(session: Session, track_type: str = "video",
name: str = "") -> dict:
"""Add a new track to the timeline.
@@ -100,6 +278,13 @@ def add_track(session: Session, track_type: str = "video",
playlist_id, track_index = mlt_xml.add_track_to_tractor(
session.root, tractor, track_type, name
)
playlist = mlt_xml.find_element_by_id(session.root, playlist_id)
while len(session._track_playlists) < track_index:
session._track_playlists.append(None)
if len(session._track_playlists) == track_index:
session._track_playlists.append(playlist)
else:
session._track_playlists[track_index] = playlist
return {
"action": "add_track",
@@ -129,9 +314,16 @@ def remove_track(session: Session, track_index: int) -> dict:
track_elem = tracks[track_index]
producer_id = track_elem.get("producer")
# Remove the track from multitrack
# Remove the track from tractor (directly or from multitrack)
multitrack = tractor.find("multitrack")
multitrack.remove(track_elem)
if multitrack is not None:
multitrack.remove(track_elem)
else:
tractor.remove(track_elem)
# Remove sub-tractor transitions whose entries were in this playlist
from . import transitions as trans_mod
trans_mod.remove_transitions_for_playlist(session.root, producer_id)
# Remove the associated playlist
playlist = mlt_xml.find_element_by_id(session.root, producer_id)
@@ -144,6 +336,33 @@ def remove_track(session: Session, track_index: int) -> dict:
if b_track == str(track_index):
tractor.remove(trans)
# Fix a_track referencing the deleted track, then decrement higher indices
remaining_tracks = mlt_xml.get_tractor_tracks(tractor)
for trans in tractor.findall("transition"):
a_track_val = mlt_xml.get_property(trans, "a_track")
b_track_val = mlt_xml.get_property(trans, "b_track")
if a_track_val is not None and int(a_track_val) == track_index:
new_a = 0
for i in range(track_index - 1, -1, -1):
if i < len(remaining_tracks):
pl = mlt_xml.find_element_by_id(
session.root, remaining_tracks[i].get("producer", ""))
if pl is not None and mlt_xml.get_property(pl, "shotcut:video"):
new_a = i
break
mlt_xml.set_property(trans, "a_track", str(new_a))
if mlt_xml.get_property(trans, "mlt_service") == "qtblend":
mlt_xml.set_property(trans, "disable", "1" if new_a == 0 else "0")
if a_track_val is not None and int(a_track_val) > track_index:
mlt_xml.set_property(trans, "a_track", str(int(a_track_val) - 1))
if b_track_val is not None and int(b_track_val) > track_index:
mlt_xml.set_property(trans, "b_track", str(int(b_track_val) - 1))
_update_tractor_out(session)
if track_index < len(session._track_playlists):
session._track_playlists.pop(track_index)
return {
"action": "remove_track",
"track_index": track_index,
@@ -184,8 +403,7 @@ def list_tracks(session: Session) -> list[dict]:
info["type"] = "video"
entries = mlt_xml.get_playlist_entries(playlist)
clip_entries = [e for e in entries if e["type"] == "entry"]
info["clip_count"] = len(clip_entries)
info["clip_count"] = len(real_clip_entries(entries, session.root))
else:
info["type"] = "unknown"
info["clip_count"] = 0
@@ -195,80 +413,101 @@ def list_tracks(session: Session) -> list[dict]:
return result
def add_clip(session: Session, resource: str, track_index: int,
def add_clip(session: Session, clip_id: str, track_index: int,
in_point: Optional[str] = None,
out_point: Optional[str] = None,
position: Optional[int] = None,
at_time: Optional[str] = None,
caption: Optional[str] = None) -> dict:
"""Add a media clip to a track.
"""Add a clip to a track by referencing an imported media clip_id.
Args:
session: Active session
resource: Path to the media file
track_index: Track to add the clip to
in_point: Trim in point (timecode or frames)
out_point: Trim out point (timecode or frames)
position: Insert position (clip index on track), None = append
at_time: Absolute timeline start time for the clip
caption: Display name for the clip
The timeline chain is shared — same clip_id always maps to the same chain.
Each call creates a new playlist entry with its own in/out range.
"""
resource = os.path.abspath(resource)
if not os.path.isfile(resource):
raise FileNotFoundError(f"Media file not found: {resource}")
if position is not None and at_time is not None:
raise ValueError("Use either position or at_time, not both")
raise ValueError("Cannot specify both position and at_time")
bin_chain = session._bin_chains.get(clip_id)
if bin_chain is None:
available = ", ".join(sorted(session._bin_chains.keys()))
raise ValueError(
f"Clip {clip_id!r} not imported. Available: {available}. "
f"Use 'media import' first."
)
resource = mlt_xml.get_property(bin_chain, "resource", "")
session.checkpoint()
# Create a producer for this clip
producer = mlt_xml.create_producer(
session.root, resource,
in_point=in_point or "00:00:00.000",
out_point=out_point,
caption=caption,
)
# Reuse timeline chain for same clip_id
timeline_chain_id = f"tl_{clip_id}"
timeline_chain = mlt_xml.find_element_by_id(session.root, timeline_chain_id)
if timeline_chain is None or mlt_xml.get_parent(timeline_chain) is None:
length_tc = mlt_xml.get_property(bin_chain, "length")
source_out = bin_chain.get("out") or length_tc
video_index = mlt_xml.get_property(bin_chain, "video_index") or "0"
audio_index = mlt_xml.get_property(bin_chain, "audio_index") or "1"
timeline_chain = mlt_xml.create_chain(
session.root, resource,
in_point="00:00:00.000",
out_point=source_out,
caption=caption or os.path.basename(resource),
extra_props={"video_index": video_index, "audio_index": audio_index},
insert_idx=session._timeline_insert_idx,
length=length_tc,
id_override=timeline_chain_id,
)
session._timeline_insert_idx += 1
# Add entry to the track's playlist
playlist = _get_track_playlist(session, track_index)
final_in = in_point or timeline_chain.get("in", "00:00:00.000")
final_out = out_point or timeline_chain.get("out")
if at_time is not None:
insert_child_index, leading_blank_frames, trailing_blank_frames = _absolute_insertion_point(
session, playlist, at_time
)
fps_num, fps_den = _get_fps(session)
if leading_blank_frames:
blank = etree.Element("blank")
blank.set("length", frames_to_timecode(leading_blank_frames, fps_num, fps_den))
playlist.insert(insert_child_index, blank)
insert_child_index += 1
entry = etree.Element("entry")
entry.set("producer", producer.get("id"))
if in_point:
entry.set("in", in_point)
if out_point:
entry.set("out", out_point)
playlist.insert(insert_child_index, entry)
if trailing_blank_frames:
blank = etree.Element("blank")
blank.set("length", frames_to_timecode(trailing_blank_frames, fps_num, fps_den))
playlist.insert(insert_child_index + 1, blank)
else:
entry = mlt_xml.add_entry_to_playlist(
playlist, producer.get("id"),
in_point=in_point,
out_point=out_point,
position=position,
insert_idx, leading_blank, trailing_blank = _absolute_insertion_point(
session, playlist, at_time)
if leading_blank > 0:
blank = ET.SubElement(playlist, "blank")
blank.set("length", frames_to_timecode(leading_blank, fps_num, fps_den))
mlt_xml._set_parent(blank, playlist)
playlist.remove(blank)
playlist.insert(insert_idx, blank)
insert_idx += 1
mlt_xml.add_entry_to_playlist(
playlist, timeline_chain.get("id"),
in_point=final_in, out_point=final_out,
insert_before=insert_idx)
if trailing_blank > 0:
blank = ET.SubElement(playlist, "blank")
blank.set("length", frames_to_timecode(trailing_blank, fps_num, fps_den))
mlt_xml._set_parent(blank, playlist)
playlist.remove(blank)
playlist.insert(insert_idx + 1, blank)
elif position is not None:
insert_idx = _prepare_insert_index(playlist, position, session)
mlt_xml.add_entry_to_playlist(
playlist, timeline_chain.get("id"),
in_point=final_in, out_point=final_out,
insert_before=insert_idx,
)
else:
mlt_xml.add_entry_to_playlist(
playlist, timeline_chain.get("id"),
in_point=final_in, out_point=final_out,
)
_update_tractor_out(session)
return {
"action": "add_clip",
"producer_id": producer.get("id"),
"clip_id": clip_id,
"chain_id": timeline_chain.get("id"),
"track_index": track_index,
"resource": resource,
"in": in_point,
"out": out_point,
"in": final_in,
"out": final_out,
"position": position,
"at_time": at_time,
"caption": caption or os.path.basename(resource),
@@ -288,58 +527,72 @@ def remove_clip(session: Session, track_index: int, clip_index: int,
playlist = _get_track_playlist(session, track_index)
entries = mlt_xml.get_playlist_entries(playlist)
# Find the entry at clip_index
clip_entries = [e for e in entries if e["type"] == "entry"]
# Find the entry at clip_index (skip transition entries)
clip_entries = real_clip_entries(entries, session.root)
if clip_index < 0 or clip_index >= len(clip_entries):
raise IndexError(
f"Clip index {clip_index} out of range (0-{len(clip_entries)-1})"
)
# Find the actual XML element
entry_count = 0
target_entry = clip_entries[clip_index]
fps_num, fps_den = _get_fps(session)
# Find the actual XML element by walking the playlist and matching clip_index
real_idx = 0
target_child = None
for child in list(playlist):
if child.tag == "entry":
if entry_count == clip_index:
producer_id = child.get("producer", "")
if ripple:
playlist.remove(child)
else:
# Replace with a blank of similar duration
in_tc = child.get("in", "00:00:00.000")
out_tc = child.get("out", "00:00:00.000")
playlist.remove(child)
# Calculate duration
fps_num, fps_den = _get_fps(session)
in_frames = parse_time_input(in_tc, fps_num, fps_den)
out_frames = parse_time_input(out_tc, fps_num, fps_den)
duration_frames = out_frames - in_frames
if duration_frames > 0:
duration_tc = frames_to_timecode(duration_frames, fps_num, fps_den)
blank = etree.Element("blank")
blank.set("length", duration_tc)
# Insert at same position
entries_seen = 0
insert_pos = 0
for j, ch in enumerate(list(playlist)):
if ch.tag in ("entry", "blank"):
if entries_seen == clip_index:
insert_pos = j
break
entries_seen += 1
else:
insert_pos = len(list(playlist))
playlist.insert(insert_pos, blank)
if child.tag != "entry":
continue
if is_transition_entry(child, session.root):
continue
if real_idx == clip_index:
target_child = child
break
real_idx += 1
return {
"action": "remove_clip",
"track_index": track_index,
"clip_index": clip_index,
"producer_id": producer_id,
"ripple": ripple,
}
entry_count += 1
if target_child is None:
raise RuntimeError("Failed to find clip element")
raise RuntimeError("Failed to find clip element")
# Remove transitions adjacent to the specific entry (not global producer search)
_remove_adjacent_transitions(session.root, playlist, target_child, fps_num, fps_den)
producer_id = target_child.get("producer", "")
if ripple:
playlist.remove(target_child)
else:
in_tc = target_child.get("in", "00:00:00.000")
out_tc = target_child.get("out", "00:00:00.000")
playlist.remove(target_child)
in_frames = parse_time_input(in_tc, fps_num, fps_den)
out_frames = parse_time_input(out_tc, fps_num, fps_den)
duration_frames = out_frames - in_frames
if duration_frames > 0:
duration_tc = frames_to_timecode(duration_frames, fps_num, fps_den)
blank = ET.Element("blank")
blank.set("length", duration_tc)
entries_seen = 0
insert_pos = 0
for j, ch in enumerate(list(playlist)):
if ch.tag in ("entry", "blank"):
if ch.tag == "entry" and is_transition_entry(ch, session.root):
continue
if entries_seen == clip_index:
insert_pos = j
break
entries_seen += 1
else:
insert_pos = len(list(playlist))
playlist.insert(insert_pos, blank)
_update_tractor_out(session)
return {
"action": "remove_clip",
"track_index": track_index,
"clip_index": clip_index,
"producer_id": producer_id,
"ripple": ripple,
}
def move_clip(session: Session, from_track: int, clip_index: int,
@@ -360,7 +613,7 @@ def move_clip(session: Session, from_track: int, clip_index: int,
entry_count = 0
clip_element = None
for child in list(src_playlist):
if child.tag == "entry":
if child.tag == "entry" and not is_transition_entry(child, session.root):
if entry_count == clip_index:
clip_element = child
break
@@ -369,8 +622,13 @@ def move_clip(session: Session, from_track: int, clip_index: int,
if clip_element is None:
raise IndexError(f"Clip index {clip_index} not found on track {from_track}")
# Copy the entry data
producer_id = clip_element.get("producer")
from . import transitions as trans_mod
fps_num, fps_den = _get_fps(session)
trans_mod.remove_transitions_for_clip(session.root, producer_id, fps_num, fps_den)
# Read in/out AFTER transition restoration
in_point = clip_element.get("in")
out_point = clip_element.get("out")
@@ -379,19 +637,27 @@ def move_clip(session: Session, from_track: int, clip_index: int,
# Add to destination
dst_playlist = _get_track_playlist(session, to_track)
mlt_xml.add_entry_to_playlist(
dst_playlist, producer_id,
in_point=in_point, out_point=out_point,
position=to_position,
)
if to_position is not None:
insert_idx = _prepare_insert_index(dst_playlist, to_position, session)
mlt_xml.add_entry_to_playlist(
dst_playlist, producer_id,
in_point=in_point, out_point=out_point,
insert_before=insert_idx,
)
else:
mlt_xml.add_entry_to_playlist(
dst_playlist, producer_id,
in_point=in_point, out_point=out_point,
)
_update_tractor_out(session)
return {
"action": "move_clip",
"from_track": from_track,
"clip_index": clip_index,
"to_track": to_track,
"to_position": to_position,
"producer_id": producer_id,
"chain_id": producer_id,
}
@@ -411,7 +677,7 @@ def trim_clip(session: Session, track_index: int, clip_index: int,
entry_count = 0
for child in list(playlist):
if child.tag == "entry":
if child.tag == "entry" and not is_transition_entry(child, session.root):
if entry_count == clip_index:
old_in = child.get("in")
old_out = child.get("out")
@@ -419,6 +685,14 @@ def trim_clip(session: Session, track_index: int, clip_index: int,
child.set("in", in_point)
if out_point is not None:
child.set("out", out_point)
from . import transitions as trans_mod
fps_num, fps_den = _get_fps(session)
trans_mod.retime_transitions_for_clip(
session.root, child.get("producer"),
out_point, in_point, fps_num, fps_den)
_update_tractor_out(session)
return {
"action": "trim_clip",
"track_index": track_index,
@@ -446,53 +720,57 @@ def split_clip(session: Session, track_index: int, clip_index: int,
playlist = _get_track_playlist(session, track_index)
entry_count = 0
for i, child in enumerate(list(playlist)):
if child.tag == "entry":
for child in list(playlist):
if child.tag == "entry" and not is_transition_entry(child, session.root):
if entry_count == clip_index:
producer_id = child.get("producer")
old_in = child.get("in", "00:00:00.000")
from . import transitions as trans_mod
fps_num, fps_den = _get_fps(session)
trans_mod.remove_transitions_for_clip(session.root, producer_id, fps_num, fps_den)
# Read out AFTER transition restoration
old_out = child.get("out")
if old_out is None:
raise RuntimeError("Cannot split clip without out point")
# First part: original in → split point
# First part: original in → split point (AFTER transition restore)
child.set("out", at)
# Second part: split point → original out
# Create a copy of the producer
original_producer = mlt_xml.find_element_by_id(session.root, producer_id)
if original_producer is None:
raise RuntimeError(f"Producer {producer_id!r} not found")
# Create a copy of the timeline chain
original_chain = mlt_xml.find_element_by_id(session.root, producer_id)
if original_chain is None:
raise RuntimeError(f"Chain {producer_id!r} not found")
new_producer = mlt_xml.deep_copy_element(original_producer)
new_prod_id = mlt_xml.new_id("producer")
new_producer.set("id", new_prod_id)
mlt_xml.set_property(new_producer, "shotcut:uuid",
__import__("uuid").uuid4().hex)
new_chain = mlt_xml.deep_copy_element(original_chain)
new_chain_id = mlt_xml.new_id("chain")
new_chain.set("id", new_chain_id)
mlt_xml.set_property(new_chain, "shotcut:uuid",
uuid.uuid4().hex)
# Insert producer in document
tractor = session.get_main_tractor()
tractor_idx = list(session.root).index(tractor)
session.root.insert(tractor_idx, new_producer)
# Insert new entry after current one
new_entry = etree.Element("entry")
new_entry.set("producer", new_prod_id)
# Insert chain before track playlists
insert_idx = mlt_xml.find_insert_index_for_timeline_chain(session.root)
session.root.insert(insert_idx, new_chain)
mlt_xml._register_tree(new_chain, session.root)
new_entry = ET.Element("entry")
new_entry.set("producer", new_chain_id)
new_entry.set("in", at)
new_entry.set("out", old_out)
# Find the position of current child and insert after
playlist_children = list(playlist)
current_idx = playlist_children.index(child)
playlist.insert(current_idx + 1, new_entry)
_update_tractor_out(session)
return {
"action": "split_clip",
"track_index": track_index,
"clip_index": clip_index,
"at": at,
"first_clip": {"producer": producer_id, "in": old_in, "out": at},
"second_clip": {"producer": new_prod_id, "in": at, "out": old_out},
"first_clip": {"chain_id": producer_id, "in": old_in, "out": at},
"second_clip": {"chain_id": new_chain_id, "in": at, "out": old_out},
}
entry_count += 1
@@ -509,9 +787,10 @@ def list_clips(session: Session, track_index: int) -> list[dict]:
entries = mlt_xml.get_playlist_entries(playlist)
result = []
trans_ids = _get_transition_ids(session.root)
clip_idx = 0
for entry in entries:
if entry["type"] == "entry":
if entry["type"] == "entry" and entry.get("producer", "") not in trans_ids:
# Look up producer info
producer = mlt_xml.find_element_by_id(session.root, entry["producer"])
caption = ""
@@ -522,7 +801,7 @@ def list_clips(session: Session, track_index: int) -> list[dict]:
result.append({
"clip_index": clip_idx,
"producer_id": entry["producer"],
"chain_id": entry["producer"],
"in": entry["in"],
"out": entry["out"],
"caption": caption,
@@ -1,10 +1,14 @@
"""Transition management: add, remove, configure transitions between clips."""
from __future__ import annotations
import xml.etree.ElementTree as ET
from typing import Optional
from lxml import etree
from ..utils import mlt_xml
from ..utils.time import frames_to_timecode, parse_time_input
from .session import Session
from .timeline import _get_track_playlist, _get_fps, is_transition_entry
# Registry of available transition types
@@ -164,35 +168,90 @@ def get_transition_info(transition_name: str) -> dict:
def add_transition(session: Session, transition_name: str,
track_a: int, track_b: int,
in_point: Optional[str] = None,
out_point: Optional[str] = None,
track_index: int, clip_a_index: int,
duration_frames: int = 14,
params: Optional[dict] = None) -> dict:
"""Add a transition between two tracks.
"""Add a transition between two adjacent clips on a track.
In MLT, transitions blend between two tracks over a time range.
The clips must overlap on the timeline for the transition to be visible.
Args:
session: Active session
transition_name: Name from TRANSITION_REGISTRY or raw MLT service
track_a: Source track index (bottom/background)
track_b: Destination track index (top/foreground)
in_point: Start timecode of the transition
out_point: End timecode of the transition
params: Parameter overrides
Uses Shotcut's sub-tractor format so the transition is visible
and editable in Shotcut's timeline UI.
"""
session.checkpoint()
fps_num, fps_den = _get_fps(session)
playlist = _get_track_playlist(session, track_index)
tractor = session.get_main_tractor()
tracks = mlt_xml.get_tractor_tracks(tractor)
# Collect entry elements in playlist order, excluding existing transitions
entries = [c for c in playlist if c.tag in ("entry", "blank")]
clip_entries = [e for e in entries if e.tag == "entry" and not is_transition_entry(e, session.root)]
if clip_a_index < 0 or clip_a_index >= len(clip_entries) - 1:
raise IndexError(
f"Need two adjacent clips for transition; "
f"clip_a_index {clip_a_index} out of range (0-{len(clip_entries)-2})"
)
if track_a < 0 or track_a >= len(tracks):
raise IndexError(f"Track A index {track_a} out of range")
if track_b < 0 or track_b >= len(tracks):
raise IndexError(f"Track B index {track_b} out of range")
entry_a = clip_entries[clip_a_index]
entry_b = clip_entries[clip_a_index + 1]
# Resolve transition from registry or use as raw service
# Verify no blanks or existing transitions between the two clips
found_a = False
for child in playlist:
if child is entry_a:
found_a = True
continue
if found_a:
if child is entry_b:
break
if child.tag == "blank":
raise ValueError(
f"Cannot add transition: blank gap between clip {clip_a_index} "
f"and {clip_a_index + 1}"
)
if child.tag == "entry" and is_transition_entry(child, session.root):
raise ValueError(
f"Cannot add transition: a transition already exists "
f"between clip {clip_a_index} and {clip_a_index + 1}"
)
chain_a_id = entry_a.get("producer", "")
chain_b_id = entry_b.get("producer", "")
# Parse current in/out points
src_a_in = parse_time_input(entry_a.get("in", "00:00:00.000"), fps_num, fps_den)
src_a_out = parse_time_input(entry_a.get("out", "00:00:00.000"), fps_num, fps_den)
src_b_in = parse_time_input(entry_b.get("in", "00:00:00.000"), fps_num, fps_den)
src_b_out = parse_time_input(entry_b.get("out", "00:00:00.000"), fps_num, fps_den)
dur_a = src_a_out - src_a_in
dur_b = src_b_out - src_b_in
trans_frames = min(duration_frames, dur_a, dur_b)
if trans_frames <= 0:
raise RuntimeError("Clips too short for transition")
half_a = (trans_frames + 1) // 2
half_b = trans_frames - half_a
trans_tc = frames_to_timecode(trans_frames, fps_num, fps_den)
new_src_a_out = src_a_out - half_a
new_src_b_in = src_b_in + half_b
new_src_a_out_tc = frames_to_timecode(new_src_a_out, fps_num, fps_den)
new_src_b_in_tc = frames_to_timecode(new_src_b_in, fps_num, fps_den)
# Track references inside the transition tractor must span the full
# transition duration, not just half. Each track pulls from the
# trimmed-off portion of its source clip.
track_a_in = max(src_a_in, src_a_out - trans_frames)
track_a_out = src_a_out
track_b_in = src_b_in
track_b_out = min(src_b_out, src_b_in + trans_frames)
track_a_in_tc = frames_to_timecode(track_a_in, fps_num, fps_den)
track_a_out_tc = frames_to_timecode(track_a_out, fps_num, fps_den)
track_b_in_tc = frames_to_timecode(track_b_in, fps_num, fps_den)
track_b_out_tc = frames_to_timecode(track_b_out, fps_num, fps_den)
# Resolve transition service and params
if transition_name in TRANSITION_REGISTRY:
reg = TRANSITION_REGISTRY[transition_name]
service = reg["service"]
@@ -205,44 +264,90 @@ def add_transition(session: Session, transition_name: str,
service = transition_name
props = params or {}
# Create the transition element
trans = etree.SubElement(tractor, "transition")
trans_id = mlt_xml.new_id("transition")
trans.set("id", trans_id)
if in_point:
trans.set("in", in_point)
if out_point:
trans.set("out", out_point)
# Create sub-tractor (Shotcut format: has in/out, shotcut:transition property)
trans_tractor = ET.Element("tractor")
trans_id = mlt_xml.new_id("tractor")
trans_tractor.set("id", trans_id)
trans_tractor.set("in", "00:00:00.000")
trans_tractor.set("out", trans_tc)
mlt_xml.set_property(trans_tractor, "shotcut:transition", "lumaMix")
mlt_xml.set_property(trans, "a_track", str(track_a))
mlt_xml.set_property(trans, "b_track", str(track_b))
mlt_xml.set_property(trans, "mlt_service", service)
tr_a = ET.SubElement(trans_tractor, "track")
tr_a.set("producer", chain_a_id)
tr_a.set("in", track_a_in_tc)
tr_a.set("out", track_a_out_tc)
for key, val in props.items():
mlt_xml.set_property(trans, key, str(val))
tr_b = ET.SubElement(trans_tractor, "track")
tr_b.set("producer", chain_b_id)
tr_b.set("in", track_b_in_tc)
tr_b.set("out", track_b_out_tc)
# Luma (video) transition
luma = ET.SubElement(trans_tractor, "transition")
luma.set("id", mlt_xml.new_id("transition"))
luma.set("out", trans_tc)
mlt_xml.set_property(luma, "a_track", "0")
mlt_xml.set_property(luma, "b_track", "1")
mlt_xml.set_property(luma, "mlt_service", service)
mlt_xml.set_property(luma, "factory", "loader")
mlt_xml.set_property(luma, "progressive", "1")
mlt_xml.set_property(luma, "alpha_over", "1")
mlt_xml.set_property(luma, "fix_background_alpha", "1")
mlt_xml.set_property(luma, "invert", "0")
for k, v in props.items():
mlt_xml.set_property(luma, k, str(v))
# Mix (audio) transition — skip if the main service is already mix
if service != "mix":
mix = ET.SubElement(trans_tractor, "transition")
mix.set("id", mlt_xml.new_id("transition"))
mix.set("out", trans_tc)
mlt_xml.set_property(mix, "a_track", "0")
mlt_xml.set_property(mix, "b_track", "1")
mlt_xml.set_property(mix, "start", "-1")
mlt_xml.set_property(mix, "accepts_blanks", "1")
mlt_xml.set_property(mix, "mlt_service", "mix")
# Insert sub-tractor BEFORE the playlist that references it
root = session.root
for idx, child in enumerate(root):
if child is playlist:
root.insert(idx, trans_tractor)
mlt_xml._register_tree(trans_tractor, root)
break
# Trim original entries so their trimmed-off portions feed the transition
entry_a.set("out", new_src_a_out_tc)
entry_b.set("in", new_src_b_in_tc)
# Insert transition entry between the two clips in the playlist
trans_entry = ET.Element("entry")
trans_entry.set("producer", trans_id)
trans_entry.set("in", "00:00:00.000")
trans_entry.set("out", trans_tc)
for i, child in enumerate(list(playlist)):
if child is entry_a:
playlist.insert(i + 1, trans_entry)
break
return {
"action": "add_transition",
"transition_name": transition_name,
"service": service,
"transition_id": trans_id,
"track_a": track_a,
"track_b": track_b,
"in_point": in_point,
"out_point": out_point,
"tractor_id": trans_id,
"track_index": track_index,
"clip_a_index": clip_a_index,
"duration_frames": trans_frames,
"params": props,
}
def remove_transition(session: Session, transition_index: int) -> dict:
"""Remove a transition by index.
Only removes user-added transitions, not the system compositing
transitions (always_active mix/blend).
"""
"""Remove a transition by index and restore trimmed clip lengths."""
session.checkpoint()
tractor = session.get_main_tractor()
transitions = _get_user_transitions(tractor)
fps_num, fps_den = _get_fps(session)
transitions = _get_user_transitions(session.root)
if transition_index < 0 or transition_index >= len(transitions):
raise IndexError(f"Transition index {transition_index} out of range "
@@ -250,14 +355,13 @@ def remove_transition(session: Session, transition_index: int) -> dict:
trans = transitions[transition_index]
trans_id = trans.get("id")
service = mlt_xml.get_property(trans, "mlt_service", "")
tractor.remove(trans)
_remove_transition_and_restore(session.root, trans, fps_num, fps_den)
return {
"action": "remove_transition",
"transition_index": transition_index,
"transition_id": trans_id,
"service": service,
}
@@ -265,15 +369,26 @@ def set_transition_param(session: Session, transition_index: int,
param_name: str, param_value: str) -> dict:
"""Set a parameter on a transition."""
session.checkpoint()
tractor = session.get_main_tractor()
transitions = _get_user_transitions(tractor)
transitions = _get_user_transitions(session.root)
if transition_index < 0 or transition_index >= len(transitions):
raise IndexError(f"Transition index {transition_index} out of range")
trans = transitions[transition_index]
old_value = mlt_xml.get_property(trans, param_name)
mlt_xml.set_property(trans, param_name, param_value)
target = None
for t in trans.findall("transition"):
svc = mlt_xml.get_property(t, "mlt_service", "")
if svc != "mix":
target = t
break
if target is None:
children = trans.findall("transition")
if not children:
raise RuntimeError("No editable transition found")
target = children[0]
old_value = mlt_xml.get_property(target, param_name)
mlt_xml.set_property(target, param_name, param_value)
return {
"action": "set_transition_param",
@@ -285,48 +400,264 @@ def set_transition_param(session: Session, transition_index: int,
def list_transitions(session: Session) -> list[dict]:
"""List all user-added transitions on the timeline."""
tractor = session.get_main_tractor()
transitions = _get_user_transitions(tractor)
"""List all transitions on the timeline."""
transitions = _get_user_transitions(session.root)
result = []
for i, trans in enumerate(transitions):
service = mlt_xml.get_property(trans, "mlt_service", "")
a_track = mlt_xml.get_property(trans, "a_track", "")
b_track = mlt_xml.get_property(trans, "b_track", "")
service = ""
mix_service = ""
props = {}
for prop in trans.findall("property"):
name = prop.get("name", "")
if name and name not in ("mlt_service", "a_track", "b_track",
"always_active", "sum"):
props[name] = prop.text or ""
for t in trans.findall("transition"):
svc = mlt_xml.get_property(t, "mlt_service", "")
if svc == "mix":
if not mix_service:
mix_service = svc
else:
if not service:
service = svc
for prop in t.findall("property"):
name = prop.get("name", "")
if name and name not in ("mlt_service", "a_track", "b_track"):
props[name] = prop.text or ""
if not service:
service = mix_service
track_ids = [tr.get("producer", "") for tr in trans.findall("track")]
result.append({
"index": i,
"id": trans.get("id"),
"service": service,
"track_a": a_track,
"track_b": b_track,
"track_producers": track_ids,
"in": trans.get("in"),
"out": trans.get("out"),
"params": props,
})
return result
def _get_user_transitions(tractor: etree._Element) -> list[etree._Element]:
"""Get transitions that are user-added (not system compositing ones).
def _get_user_transitions(root: ET.Element) -> list[ET.Element]:
"""Get all sub-tractor transitions (Shotcut editable format).
System transitions have always_active=1 and are auto-created
when tracks are added. User transitions have explicit in/out points
or don't have always_active.
Excludes the main timeline tractor (identified by the ``shotcut`` property)
rather than hard-coding an id, because real Shotcut projects may assign
any id to the main tractor (e.g. tractor1) while using tractor0 for a
transition sub-tractor.
"""
all_transitions = tractor.findall("transition")
user_transitions = []
for t in all_transitions:
always_active = mlt_xml.get_property(t, "always_active", "0")
if always_active != "1":
user_transitions.append(t)
return user_transitions
result = []
for child in root:
if child.tag == "tractor" and not mlt_xml.get_property(child, "shotcut"):
if mlt_xml.get_property(child, "shotcut:transition"):
result.append(child)
return result
def _find_transitions_for_producer(root: ET.Element, producer_id: str) -> list[ET.Element]:
"""Find all sub-tractor transitions that reference a producer."""
result = []
for trans in _get_user_transitions(root):
for track in trans.findall("track"):
if track.get("producer") == producer_id:
result.append(trans)
break
return result
def _compute_restoration_gains(trans: ET.Element, entry_a: ET.Element | None,
entry_b: ET.Element | None,
fps_num: int, fps_den: int) -> tuple[int, int]:
tracks = trans.findall("track")
if len(tracks) >= 2 and entry_a is not None and entry_b is not None:
track_a = tracks[0]
track_b = tracks[1]
gain_a = parse_time_input(track_a.get("out", "00:00:00.000"), fps_num, fps_den) \
- parse_time_input(entry_a.get("out", "00:00:00.000"), fps_num, fps_den)
gain_b = parse_time_input(entry_b.get("in", "00:00:00.000"), fps_num, fps_den) \
- parse_time_input(track_b.get("in", "00:00:00.000"), fps_num, fps_den)
if gain_a > 0 or gain_b > 0:
return gain_a, gain_b
trans_frames = parse_time_input(trans.get("out", "00:00:00.000"), fps_num, fps_den)
half_a = (trans_frames + 1) // 2
half_b = trans_frames - half_a
return half_a, half_b
def _remove_transition_and_restore(root: ET.Element, trans: ET.Element,
fps_num: int, fps_den: int,
skip_producer: str = "") -> None:
"""Remove a transition tractor and restore trimmed clip lengths.
skip_producer: if set, don't restore frames to this producer's clip entry
(the user has already trimmed it).
"""
trans_id = trans.get("id")
track_producers = [t.get("producer", "") for t in trans.findall("track")]
for child in list(root):
if child.tag != "playlist":
continue
children = list(child)
for i, entry in enumerate(children):
if entry.tag == "entry" and entry.get("producer") == trans_id:
entry_a = None
for j in range(i - 1, -1, -1):
if children[j].tag == "entry" and children[j].get("producer", "") in track_producers:
entry_a = children[j]
break
entry_b = None
for j in range(i + 1, len(children)):
if children[j].tag == "entry" and children[j].get("producer", "") in track_producers:
entry_b = children[j]
break
gain_a, gain_b = _compute_restoration_gains(
trans, entry_a, entry_b, fps_num, fps_den)
child.remove(entry)
if entry_a is not None and gain_a > 0 and entry_a.get("producer", "") != skip_producer:
a_out = parse_time_input(entry_a.get("out", "00:00:00.000"),
fps_num, fps_den)
entry_a.set("out", frames_to_timecode(a_out + gain_a,
fps_num, fps_den))
if entry_b is not None and gain_b > 0 and entry_b.get("producer", "") != skip_producer:
b_in = parse_time_input(entry_b.get("in", "00:00:00.000"),
fps_num, fps_den)
new_b_in = max(0, b_in - gain_b)
entry_b.set("in", frames_to_timecode(new_b_in,
fps_num, fps_den))
break
root.remove(trans)
def _return_frames_to_other_clip(root: ET.Element, trans: ET.Element,
producer_id: str, freed_frames: int,
fps_num: int, fps_den: int) -> None:
"""Return freed frames to the other clip participating in a transition."""
if freed_frames <= 0:
return
tracks = trans.findall("track")
for t in tracks:
other_id = t.get("producer", "")
if other_id != producer_id:
for child in root:
if child.tag != "playlist":
continue
for entry in child.findall("entry"):
if entry.get("producer") == other_id:
if t is tracks[1]:
cur_in = parse_time_input(entry.get("in", "00:00:00.000"),
fps_num, fps_den)
entry.set("in", frames_to_timecode(max(0, cur_in - freed_frames),
fps_num, fps_den))
else:
cur_out = parse_time_input(entry.get("out", "00:00:00.000"),
fps_num, fps_den)
entry.set("out", frames_to_timecode(cur_out + freed_frames,
fps_num, fps_den))
return
def _update_playlist_entry_out(root: ET.Element, trans_id: str, new_out: str) -> None:
"""Update the playlist entry referencing a transition to match new out."""
if not trans_id:
return
for child in root:
if child.tag != "playlist":
continue
for entry in child.findall("entry"):
if entry.get("producer") == trans_id:
entry.set("out", new_out)
return
def remove_transitions_for_clip(root: ET.Element, producer_id: str,
fps_num: int, fps_den: int) -> None:
"""Remove all transitions that reference a clip producer."""
for trans in _find_transitions_for_producer(root, producer_id):
_remove_transition_and_restore(root, trans, fps_num, fps_den)
def remove_transitions_for_playlist(root: ET.Element, playlist_id: str) -> None:
"""Remove all transitions whose entries appear in a given playlist."""
playlist = mlt_xml.find_element_by_id(root, playlist_id)
if playlist is None:
return
transition_ids = set()
for entry in playlist.findall("entry"):
prod_id = entry.get("producer", "")
prod = mlt_xml.find_element_by_id(root, prod_id)
if prod is not None and prod.tag == "tractor" and mlt_xml.get_property(prod, "shotcut:transition"):
transition_ids.add(prod_id)
for trans_id in transition_ids:
trans = mlt_xml.find_element_by_id(root, trans_id)
if trans is not None:
root.remove(trans)
def retime_transitions_for_clip(root: ET.Element, producer_id: str,
new_out: Optional[str],
new_in: Optional[str],
fps_num: int, fps_den: int) -> None:
"""Update transition track in/out when a clip is trimmed."""
for trans in _find_transitions_for_producer(root, producer_id):
tracks = trans.findall("track")
for track in tracks:
if track.get("producer") == producer_id:
if new_out is not None:
track_out = parse_time_input(track.get("out", "00:00:00.000"),
fps_num, fps_den)
clip_out = parse_time_input(new_out, fps_num, fps_den)
if track_out > clip_out:
track_in_frames = parse_time_input(track.get("in", "0"),
fps_num, fps_den)
old_dur = track_out - track_in_frames
track.set("out", new_out)
trans_dur = clip_out - track_in_frames
if trans_dur <= 0:
_remove_transition_and_restore(root, trans, fps_num, fps_den,
skip_producer=producer_id)
break
elif trans_dur < old_dur:
new_out_tc = frames_to_timecode(trans_dur, fps_num, fps_den)
trans.set("out", new_out_tc)
for inner_t in trans.findall("transition"):
inner_t.set("out", new_out_tc)
_update_playlist_entry_out(root, trans.get("id"), new_out_tc)
freed = old_dur - trans_dur
_return_frames_to_other_clip(root, trans, producer_id,
freed, fps_num, fps_den)
if new_in is not None:
track_in = parse_time_input(track.get("in", "00:00:00.000"),
fps_num, fps_den)
clip_in = parse_time_input(new_in, fps_num, fps_den)
if track_in < clip_in:
track_out_frames = parse_time_input(track.get("out", "0"),
fps_num, fps_den)
old_dur = track_out_frames - track_in
track.set("in", new_in)
new_dur = track_out_frames - clip_in
if new_dur <= 0:
_remove_transition_and_restore(root, trans, fps_num, fps_den,
skip_producer=producer_id)
break
elif new_dur < old_dur:
new_out_tc = frames_to_timecode(new_dur, fps_num, fps_den)
trans.set("out", new_out_tc)
for inner_t in trans.findall("transition"):
inner_t.set("out", new_out_tc)
_update_playlist_entry_out(root, trans.get("id"), new_out_tc)
freed = old_dur - new_dur
_return_frames_to_other_clip(root, trans, producer_id,
freed, fps_num, fps_den)
# Re-anchor the other track's in to keep it at the cut point
other = tracks[0] if track is tracks[1] else tracks[1]
other_in = parse_time_input(
other.get("in", "00:00:00.000"), fps_num, fps_den)
other.set("in", frames_to_timecode(
other_in + freed, fps_num, fps_den))
break
@@ -17,6 +17,7 @@ Usage:
import sys
import os
import json
import shlex
import click
from typing import Optional
@@ -114,7 +115,6 @@ def handle_error(func):
_repl_mode = False
_auto_save = False
_dry_run = False
@@ -126,25 +126,19 @@ _dry_run = False
@click.option("--json", "json_mode", is_flag=True, help="Output in JSON format")
@click.option("--session", "session_id", default=None, help="Session ID to use/resume")
@click.option("--project", "project_path", default=None, help="Open a project file")
@click.option("-s", "--save", "auto_save", is_flag=True,
help="Auto-save project after each mutation command (one-shot mode)")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, json_mode, session_id, project_path, auto_save, dry_run):
def cli(ctx, json_mode, session_id, project_path, dry_run):
"""Shotcut CLI — Video editing from the command line.
A stateful CLI for manipulating Shotcut/MLT video projects.
Designed for AI agents and power users.
Run without a subcommand to enter interactive REPL mode.
Use -s/--save to automatically save changes after each mutation command.
This is useful in one-shot mode where each command runs in a new process.
"""
global _json_output, _session, _auto_save, _dry_run
global _json_output, _session, _dry_run
_json_output = json_mode
_auto_save = auto_save
_dry_run = dry_run
if session_id:
@@ -164,15 +158,15 @@ def cli(ctx, json_mode, session_id, project_path, auto_save, dry_run):
def _auto_save_callback():
"""Auto-save callback that runs after each command."""
global _auto_save, _session, _dry_run
global _session, _dry_run
if _dry_run:
return
if _auto_save and _session and _session.is_open and _session.is_modified:
# Don't auto-save if we're in REPL mode (user can explicitly save)
if _session and _session.is_open and _session.is_modified:
if not _repl_mode:
try:
_session.save_project()
click.echo(f"Auto-saved to: {_session.project_path}")
if not _json_output:
click.echo(f"Auto-saved to: {_session.project_path}", err=True)
except Exception as e:
click.echo(f"Auto-save failed: {e}", err=True)
@@ -349,7 +343,7 @@ def timeline_remove_track(track_index):
@timeline.command("add-clip")
@click.argument("resource")
@click.argument("clip_id")
@click.option("--track", "track_index", required=True, type=int, help="Track index")
@click.option("--in", "in_point", default=None, help="In point (timecode)")
@click.option("--out", "out_point", default=None, help="Out point (timecode)")
@@ -357,12 +351,12 @@ def timeline_remove_track(track_index):
@click.option("--at", "at_time", default=None, help="Absolute timeline start time")
@click.option("--caption", default=None, help="Display name")
@handle_error
def timeline_add_clip(resource, track_index, in_point, out_point, position, at_time, caption):
"""Add a media clip to a track."""
def timeline_add_clip(clip_id, track_index, in_point, out_point, position, at_time, caption):
"""Add an imported clip to a track by clip_id."""
session = get_session()
result = tl_mod.add_clip(session, resource, track_index,
result = tl_mod.add_clip(session, clip_id, track_index,
in_point, out_point, position, at_time, caption)
output(result, f"Added clip to track {track_index}")
output(result, f"Added clip {clip_id} to track {track_index}")
@timeline.command("remove-clip")
@@ -523,6 +517,7 @@ def filter_add(filter_name, track_index, clip_index, params):
param_dict if param_dict else None)
output(result, f"Added filter '{filter_name}'")
@filter_group.command("remove")
@click.argument("filter_index", type=int)
@click.option("--track", "track_index", default=None, type=int,
@@ -576,24 +571,18 @@ def filter_list(track_index, clip_index):
@click.option("--clip", "clip_index", default=None, type=int,
help="Clip index on track (omit for track-level)")
@click.option("--point", "points", multiple=True, required=True,
help="Envelope point as TIME=LEVEL (repeatable)")
help="Time=level pair, e.g. 00:00:00.000=1.0")
@handle_error
def filter_volume_envelope(track_index, clip_index, points):
"""Create or update a keyframed volume envelope."""
"""Create or replace a keyframed volume envelope on a track or clip."""
session = get_session()
parsed = []
for point in points:
if "=" not in point:
raise ValueError(f"Invalid point format: {point!r}. Use TIME=LEVEL")
timecode, level = point.split("=", 1)
for p in points:
timecode, level = p.split("=", 1)
parsed.append((timecode, level))
result = filt_mod.set_volume_envelope(
session,
parsed,
track_index=track_index,
clip_index=clip_index,
)
output(result, "Updated volume envelope")
session, parsed, track_index=track_index, clip_index=clip_index)
output(result, "Set volume envelope")
@filter_group.command("duck")
@@ -602,35 +591,31 @@ def filter_volume_envelope(track_index, clip_index, points):
@click.option("--clip", "clip_index", default=None, type=int,
help="Clip index on track (omit for track-level)")
@click.option("--window", "windows", multiple=True, required=True,
help="Ducking window as START:END (repeatable)")
help="Ducking window START..END")
@click.option("--normal", "normal_level", default=1.0, show_default=True, type=float,
help="Volume outside ducking windows")
help="Normal volume level")
@click.option("--duck", "duck_level", default=0.25, show_default=True, type=float,
help="Volume inside ducking windows")
help="Ducked volume level")
@click.option("--attack", default="00:00:00.150", show_default=True,
help="Fade-down duration before each window")
help="Attack time (fade to duck)")
@click.option("--release", default="00:00:00.250", show_default=True,
help="Fade-up duration after each window")
help="Release time (fade from duck)")
@handle_error
def filter_duck(track_index, clip_index, windows, normal_level, duck_level, attack, release):
"""Apply a simple ducking envelope over one or more windows."""
session = get_session()
parsed = []
for window in windows:
if ":" not in window:
raise ValueError(f"Invalid window format: {window!r}. Use START:END")
start, end = window.rsplit(":", 1)
parsed.append((start, end))
if ".." not in window:
click.echo("Usage: duck --window START..END ...", err=True)
return
start_tc, end_tc = window.split("..", 1)
parsed.append((start_tc, end_tc))
result = filt_mod.duck_volume(
session,
parsed,
track_index=track_index,
clip_index=clip_index,
normal_level=normal_level,
duck_level=duck_level,
attack=attack,
release=release,
)
session, parsed,
track_index=track_index, clip_index=clip_index,
normal_level=normal_level, duck_level=duck_level,
attack=attack, release=release)
output(result, "Applied ducking envelope")
@@ -671,6 +656,17 @@ def media_check():
output(result)
@media.command("import")
@click.argument("resource")
@click.option("--caption", default=None, help="Display caption")
@handle_error
def media_import(resource, caption):
"""Import a media file into the project bin."""
session = get_session()
result = media_mod.import_media(session, resource, caption)
output(result, f"Imported {os.path.basename(resource)} as {result['clip_id']}")
@media.command("thumbnail")
@click.argument("filepath")
@click.option("-o", "--output", "output_path", required=True,
@@ -758,15 +754,16 @@ def transition_info(transition_name):
@transition_group.command("add")
@click.argument("transition_name")
@click.option("--track-a", required=True, type=int, help="Source track (background)")
@click.option("--track-b", required=True, type=int, help="Destination track (foreground)")
@click.option("--in", "in_point", default=None, help="Start timecode")
@click.option("--out", "out_point", default=None, help="End timecode")
@click.option("--track", "track_index", required=True, type=int, help="Track index")
@click.option("--clip", "clip_a_index", required=True, type=int,
help="Index of first clip (transition is between clip and clip+1)")
@click.option("--duration", "duration_frames", default=14, type=int,
help="Transition duration in frames (default: 14)")
@click.option("--param", "params", multiple=True,
help="Parameter as name=value (repeatable)")
@handle_error
def transition_add(transition_name, track_a, track_b, in_point, out_point, params):
"""Add a transition between two tracks."""
def transition_add(transition_name, track_index, clip_a_index, duration_frames, params):
"""Add a transition between two adjacent clips on a track."""
session = get_session()
param_dict = {}
for p in params:
@@ -775,9 +772,11 @@ def transition_add(transition_name, track_a, track_b, in_point, out_point, param
key, val = p.split("=", 1)
param_dict[key] = val
result = trans_mod.add_transition(session, transition_name, track_a, track_b,
in_point, out_point,
param_dict if param_dict else None)
result = trans_mod.add_transition(
session, transition_name, track_index, clip_a_index,
duration_frames,
param_dict if param_dict else None
)
output(result, f"Added transition '{transition_name}'")
@@ -953,6 +952,7 @@ def repl(project_path):
_repl_mode = True
s = get_session()
if project_path:
s.open_project(project_path)
@@ -962,7 +962,7 @@ def repl(project_path):
if project_path:
skin.info(f"Opened: {project_path}")
print()
print()
try:
_run_repl(s, skin)
@@ -989,7 +989,8 @@ def _run_repl(s: Session, skin):
"tracks": "List timeline tracks",
"show": "Show timeline overview",
"add-track <video|audio> [name]": "Add a track",
"add-clip <file> <track> [in] [out] [--at tc]": "Add clip to track",
"add-clip <clip_id> <track> [in] [out] [--at time]": "Add imported clip to track",
"media import <file> [--caption name]": "Import media file into project bin",
"clips <track>": "List clips on a track",
"remove-clip <track> <clip>": "Remove a clip",
"trim <track> <clip> [--in tc] [--out tc]": "Trim a clip",
@@ -1030,7 +1031,11 @@ def _run_repl(s: Session, skin):
if not line:
continue
parts = line.split()
try:
parts = shlex.split(line)
except ValueError:
click.echo("Error: unmatched quotes")
continue
cmd = parts[0].lower()
args = parts[1:]
@@ -1091,23 +1096,23 @@ def _run_repl(s: Session, skin):
output(result, f"Added {ttype} track")
elif cmd == "add-clip":
at_time = None
if "--at" in args:
at_index = args.index("--at")
if at_index + 1 >= len(args):
click.echo("Usage: add-clip <file> <track> [in] [out] [--at tc]")
continue
at_time = args[at_index + 1]
args = args[:at_index] + args[at_index + 2:]
if len(args) < 2:
click.echo("Usage: add-clip <file> <track> [in] [out] [--at tc]")
click.echo("Usage: add-clip <clip_id> <track> [in] [out] [--at time]")
continue
resource = args[0]
clip_id = args[0]
track = int(args[1])
in_pt = args[2] if len(args) > 2 else None
out_pt = args[3] if len(args) > 3 else None
result = tl_mod.add_clip(s, resource, track, in_pt, out_pt, at_time=at_time)
output(result, f"Added clip to track {track}")
in_pt = args[2] if len(args) > 2 and not args[2].startswith("--") else None
out_pt = args[3] if len(args) > 3 and not args[3].startswith("--") else None
at_time = None
i = 2
while i < len(args):
if args[i] == "--at" and i + 1 < len(args):
at_time = args[i + 1]
i += 2
else:
i += 1
result = tl_mod.add_clip(s, clip_id, track, in_pt, out_pt, at_time=at_time)
output(result, f"Added clip {clip_id} to track {track}")
elif cmd == "clips":
if not args:
@@ -1248,18 +1253,20 @@ def _run_repl(s: Session, skin):
elif args[i] == "--clip" and i + 1 < len(args):
clip_idx = int(args[i + 1])
i += 2
else:
if "=" not in args[i]:
click.echo("Usage: volume-envelope [--track n] [--clip n] TIME=LEVEL ...")
break
elif "=" in args[i]:
timecode, level = args[i].split("=", 1)
points.append((timecode, level))
i += 1
else:
click.echo("Usage: volume-envelope [--track n] [--clip n] TIME=LEVEL ...")
break
else:
if not points:
click.echo("Usage: volume-envelope [--track n] [--clip n] TIME=LEVEL ...")
continue
result = filt_mod.set_volume_envelope(
s, points, track_index=track_idx, clip_index=clip_idx
)
output(result)
s, points, track_index=track_idx, clip_index=clip_idx)
output(result, "Set volume envelope")
elif cmd == "duck":
track_idx = None
@@ -1289,25 +1296,21 @@ def _run_repl(s: Session, skin):
elif args[i] == "--release" and i + 1 < len(args):
release = args[i + 1]
i += 2
else:
if ":" not in args[i]:
click.echo("Usage: duck [--track n] [--clip n] START:END ...")
break
start, end = args[i].rsplit(":", 1)
windows.append((start, end))
elif ".." in args[i]:
start_tc, end_tc = args[i].split("..", 1)
windows.append((start_tc, end_tc))
i += 1
else:
result = filt_mod.duck_volume(
s,
windows,
track_index=track_idx,
clip_index=clip_idx,
normal_level=normal_level,
duck_level=duck_level,
attack=attack,
release=release,
)
output(result)
else:
i += 1
if not windows:
click.echo("Usage: duck [--track n] [--clip n] START..END ...")
continue
result = filt_mod.duck_volume(
s, windows,
track_index=track_idx, clip_index=clip_idx,
normal_level=normal_level, duck_level=duck_level,
attack=attack, release=release)
output(result, "Applied ducking envelope")
elif cmd == "filter-info":
if not args:
@@ -1321,6 +1324,21 @@ def _run_repl(s: Session, skin):
result = filt_mod.list_available_filters(cat)
output(result)
elif cmd == "media" and args and args[0] == "import":
if len(args) < 2:
click.echo("Usage: media import <file> [--caption name]")
continue
caption = None
i = 2
while i < len(args):
if args[i] == "--caption" and i + 1 < len(args):
caption = args[i + 1]
i += 2
else:
i += 1
result = media_mod.import_media(s, args[1], caption)
output(result, f"Imported {os.path.basename(args[1])} as {result['clip_id']}")
elif cmd == "media":
result = media_mod.list_media(s)
output(result)
@@ -49,9 +49,6 @@ cli-anything-shotcut
# Enter commands interactively with tab-completion and history
```
The REPL exposes the same practical helpers as command mode, including
`add-clip ... --at`, `volume-envelope`, and `duck`.
## Command Groups
@@ -80,7 +77,7 @@ Timeline operations: tracks, clips, trimming.
| `tracks` | List all tracks |
| `add-track` | Add a new track to the timeline |
| `remove-track` | Remove a track by index |
| `add-clip` | Add a media clip to a track; supports `--at` for absolute timeline placement |
| `add-clip` | Add an imported clip to a track by clip_id; supports `--at` for absolute placement |
| `remove-clip` | Remove a clip from a track |
| `move-clip` | Move a clip between tracks or positions |
| `trim` | Trim a clip's in/out points |
@@ -103,9 +100,9 @@ Filter operations: add, remove, configure effects.
| `add` | Add a filter to a clip, track, or globally |
| `remove` | Remove a filter by index |
| `set` | Set a parameter on a filter |
| `list` | List active filters on a target |
| `volume-envelope` | Create or replace a keyframed volume envelope on a track or clip |
| `duck` | Build a practical ducking envelope over one or more time windows |
| `list` | List active filters on a target |
### Media
@@ -114,6 +111,7 @@ Media operations: probe, list, check files.
| Command | Description |
|---------|-------------|
| `import` | Import a media file into the project bin |
| `probe` | Analyze a media file's properties |
| `list` | List all media clips in the current project |
| `check` | Check all media files for existence |
@@ -207,49 +205,41 @@ Export the project to a final output format.
cli-anything-shotcut --project myproject.json export render output.mp4 --overwrite
```
### Deterministic Timeline Reconstruction
For rebuilds, prefer absolute placement over append-only clip insertion:
```bash
cli-anything-shotcut --project myproject.json -s timeline add-clip intro.mp4 \
cli-anything-shotcut --project myproject.mlt media import intro.mp4
cli-anything-shotcut --project myproject.mlt timeline add-clip clip0 \
--track 1 --in 00:00:00.000 --out 00:00:04.000 --at 00:00:00.000
cli-anything-shotcut --project myproject.json -s timeline add-clip broll.mp4 \
cli-anything-shotcut --project myproject.mlt media import broll.mp4
cli-anything-shotcut --project myproject.mlt timeline add-clip clip1 \
--track 1 --in 00:00:10.000 --out 00:00:16.000 --at 00:00:08.000
```
Notes:
- `--at` inserts blanks automatically when the target time lands in empty space.
- The CLI rejects overlap with an existing clip instead of silently changing the timeline.
- For agent-built timelines, prefer explicit `--in` and `--out` values so later absolute placement remains unambiguous.
- The CLI rejects overlap with an existing clip.
- Prefer explicit `--in` and `--out` values so later absolute placement remains unambiguous.
### Audio Automation
The released CLI now includes higher-level audio automation helpers:
```bash
cli-anything-shotcut --project myproject.json -s filter volume-envelope \
cli-anything-shotcut --project myproject.mlt filter volume-envelope \
--track 2 \
--point 00:00:00.000=1.0 \
--point 00:00:03.000=0.35 \
--point 00:00:05.000=1.0
cli-anything-shotcut --project myproject.json -s filter duck \
cli-anything-shotcut --project myproject.mlt filter duck \
--track 2 \
--window 00:00:06.000:00:00:09.000 \
--window 00:00:15.000:00:00:18.000 \
--normal 1.0 --duck 0.25 \
--attack 00:00:00.150 --release 00:00:00.250
--window 00:00:06.000..00:00:09.000 \
--window 00:00:15.000..00:00:18.000 \
--normal 1.0 --duck 0.25
```
Keyframed `volume` filters now export as ffmpeg `volume=` expressions instead of
collapsing to a simple fade. This is materially better, but you should still
review final renders when automation is editorially important.
## State Management
The CLI maintains session state with:
- **Undo/Redo**: Up to 50 levels of history
@@ -291,4 +281,4 @@ When using this CLI programmatically:
## Version
1.0.0
1.0.0
@@ -0,0 +1,112 @@
"""Shared fixtures for shotcut tests."""
import os
import subprocess
import tempfile
import pytest
from cli_anything.shotcut.core.session import Session
from cli_anything.shotcut.core import project as proj_mod
from cli_anything.shotcut.core import timeline as tl_mod
from cli_anything.shotcut.core import media as media_mod
PROFILE_HD1080 = {
"width": "1920", "height": "1080",
"frame_rate_num": "30000", "frame_rate_den": "1001",
"sample_aspect_num": "1", "sample_aspect_den": "1",
"display_aspect_num": "16", "display_aspect_den": "9",
"progressive": "1", "colorspace": "709",
}
def generate_video(path=None):
"""Generate a 10-second test video using ffmpeg."""
if path is None:
fd, path = tempfile.mkstemp(suffix=".mp4", prefix="shotcut_test_")
os.close(fd)
subprocess.run([
"ffmpeg", "-y",
"-f", "lavfi", "-i", "color=c=red:s=1920x1080:d=10:r=30000/1001",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-preset", "ultrafast", "-crf", "35",
path,
], check=True, capture_output=True)
return path
@pytest.fixture(scope="session")
def video():
"""Session-scoped test video generated by ffmpeg."""
path = generate_video()
yield path
os.unlink(path)
@pytest.fixture
def dummy_file(tmp_path):
"""Temp .mp4 with dummy bytes for unit tests."""
p = tmp_path / "dummy.mp4"
p.write_bytes(b"dummy")
return str(p)
@pytest.fixture
def session():
"""Session with a new hd1080p30 project."""
s = Session()
proj_mod.new_project(s, "hd1080p30")
return s
@pytest.fixture
def session_with_track(session):
"""Session with one video track."""
tl_mod.add_track(session, "video")
return session
@pytest.fixture
def imported_clip(session_with_track, dummy_file):
"""Import a dummy media file and return its clip_id."""
result = media_mod.import_media(session_with_track, dummy_file)
return result["clip_id"]
@pytest.fixture
def session_with_clip(session_with_track, imported_clip):
"""Session with one video track and one clip."""
tl_mod.add_clip(session_with_track, imported_clip, 1,
in_point="00:00:00.000", out_point="00:00:05.000")
return session_with_track
@pytest.fixture
def session_with_two_clips(session_with_track, imported_clip):
"""Session with one video track and two 10s clips."""
for _ in range(2):
tl_mod.add_clip(session_with_track, imported_clip, 1,
in_point="00:00:00.000", out_point="00:00:10.000")
return session_with_track
@pytest.fixture
def session_with_three_clips(session_with_track, imported_clip):
"""Session with one video track and three 10s clips."""
for _ in range(3):
tl_mod.add_clip(session_with_track, imported_clip, 1,
in_point="00:00:00.000", out_point="00:00:10.000")
return session_with_track
@pytest.fixture
def session_with_two_tracks(session, dummy_file):
"""Session with two video tracks, each with one clip."""
tl_mod.add_track(session, "video")
tl_mod.add_track(session, "video")
clip_id = media_mod.import_media(session, dummy_file)["clip_id"]
tl_mod.add_clip(session, clip_id, 1,
in_point="00:00:00.000", out_point="00:00:05.000")
tl_mod.add_clip(session, clip_id, 2,
in_point="00:00:00.000", out_point="00:00:05.000")
return session
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,41 +1,78 @@
"""MLT XML parsing and generation utilities.
This module handles all low-level MLT XML manipulation. It understands the MLT
XML schema and provides helper functions for common operations.
This module handles all low-level MLT XML manipulation using
xml.etree.ElementTree from the Python standard library.
"""
import copy
import uuid
from lxml import etree
import xml.etree.ElementTree as ET
from typing import Optional
# Global parent mapping because ET.Element has no getparent().
# Safe for single-session architecture: only one Session/MLT tree exists per
# process. See Session class docstring in session.py for details.
_parent_map: dict[int, Optional[ET.Element]] = {}
def _clear_parent_map() -> None:
_parent_map.clear()
def _set_parent(child: ET.Element, parent: Optional[ET.Element]) -> None:
_parent_map[id(child)] = parent
def _remove_parent(child: ET.Element) -> None:
_parent_map.pop(id(child), None)
def _register_tree(root: ET.Element, parent: Optional[ET.Element] = None) -> None:
_set_parent(root, parent)
for child in root:
_register_tree(child, root)
def _unregister_tree(root: ET.Element) -> None:
_parent_map.pop(id(root), None)
for child in root:
_unregister_tree(child)
def get_parent(element: ET.Element) -> Optional[ET.Element]:
return _parent_map.get(id(element))
def new_id(prefix: str = "producer") -> str:
"""Generate a unique MLT element ID."""
return f"{prefix}_{uuid.uuid4().hex[:8]}"
def parse_mlt(filepath: str) -> etree._Element:
def parse_mlt(filepath: str) -> ET.Element:
"""Parse an MLT XML file and return the root element."""
parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(filepath, parser)
return tree.getroot()
_clear_parent_map()
tree = ET.parse(filepath)
root = tree.getroot()
_register_tree(root)
return root
def write_mlt(root: etree._Element, filepath: str) -> None:
def write_mlt(root: ET.Element, filepath: str) -> None:
"""Write an MLT XML tree to a file."""
tree = etree.ElementTree(root)
tree.write(filepath, xml_declaration=True, encoding="utf-8",
pretty_print=True)
pretty = copy.deepcopy(root)
ET.indent(pretty, space=" ")
tree = ET.ElementTree(pretty)
tree.write(filepath, xml_declaration=True, encoding="utf-8")
def mlt_to_string(root: etree._Element) -> str:
def mlt_to_string(root: ET.Element) -> str:
"""Serialize an MLT XML tree to a string."""
return etree.tostring(root, xml_declaration=True, encoding="utf-8",
pretty_print=True).decode("utf-8")
pretty = copy.deepcopy(root)
ET.indent(pretty, space=" ")
return ET.tostring(pretty, xml_declaration=True, encoding="utf-8").decode("utf-8")
def get_property(element: etree._Element, name: str,
def get_property(element: ET.Element, name: str,
default: Optional[str] = None) -> Optional[str]:
"""Get a property value from an MLT element."""
prop = element.find(f"property[@name='{name}']")
@@ -44,56 +81,54 @@ def get_property(element: etree._Element, name: str,
return default
def set_property(element: etree._Element, name: str, value: str) -> None:
def set_property(element: ET.Element, name: str, value: str) -> None:
"""Set a property on an MLT element, creating it if needed."""
prop = element.find(f"property[@name='{name}']")
if prop is None:
prop = etree.SubElement(element, "property")
prop.set("name", name)
if prop is not None:
prop.text = str(value)
return
prop = ET.SubElement(element, "property")
prop.set("name", name)
prop.text = str(value)
_set_parent(prop, element)
def remove_property(element: etree._Element, name: str) -> bool:
def remove_property(element: ET.Element, name: str) -> bool:
"""Remove a property from an MLT element. Returns True if found."""
prop = element.find(f"property[@name='{name}']")
if prop is not None:
element.remove(prop)
_remove_parent(prop)
return True
return False
def find_element_by_id(root: etree._Element, element_id: str) -> Optional[etree._Element]:
"""Find any element by its id attribute."""
result = root.xpath(f"//*[@id='{element_id}']")
return result[0] if result else None
def find_element_by_id(root: ET.Element, element_id: str) -> Optional[ET.Element]:
return root.find(f".//*[@id='{element_id}']")
def get_all_producers(root: etree._Element) -> list[etree._Element]:
"""Get all producer elements from the MLT document."""
return root.findall(".//producer")
def get_all_producers(root: ET.Element) -> list[ET.Element]:
"""Get all producer and chain elements from the MLT document."""
return root.findall(".//producer") + root.findall(".//chain")
def get_all_playlists(root: etree._Element) -> list[etree._Element]:
def get_all_playlists(root: ET.Element) -> list[ET.Element]:
"""Get all playlist elements."""
return root.findall(".//playlist")
def get_all_tractors(root: etree._Element) -> list[etree._Element]:
def get_all_tractors(root: ET.Element) -> list[ET.Element]:
"""Get all tractor elements."""
return root.findall(".//tractor")
def get_all_filters(root: etree._Element) -> list[etree._Element]:
def get_all_filters(root: ET.Element) -> list[ET.Element]:
"""Get all filter elements."""
return root.findall(".//filter")
def get_main_tractor(root: etree._Element) -> Optional[etree._Element]:
"""Find the main timeline tractor.
In Shotcut projects, this is typically the last tractor or the one
referenced by the root's 'producer' attribute.
"""
def get_main_tractor(root: ET.Element) -> Optional[ET.Element]:
"""Find the main timeline tractor."""
main_id = root.get("producer")
if main_id:
elem = find_element_by_id(root, main_id)
@@ -104,30 +139,65 @@ def get_main_tractor(root: etree._Element) -> Optional[etree._Element]:
return tractors[-1] if tractors else None
def get_tractor_tracks(tractor: etree._Element) -> list[etree._Element]:
"""Get the track elements from a tractor's multitrack."""
def get_tractor_tracks(tractor: ET.Element) -> list[ET.Element]:
"""Get the track elements from a tractor."""
tracks = tractor.findall("track")
if tracks:
return tracks
multitrack = tractor.find("multitrack")
if multitrack is None:
return []
return multitrack.findall("track")
def create_blank_project(profile: dict) -> etree._Element:
"""Create a minimal blank MLT project.
def _find_insert_index_for_bin_chain(root: ET.Element) -> int:
"""Find insertion index for a bin chain (before main_bin)."""
for i, child in enumerate(root):
if child.tag == "playlist" and child.get("id") == "main_bin":
return i
return 1 # After profile
Args:
profile: dict with keys like width, height, frame_rate_num,
frame_rate_den, sample_aspect_num, sample_aspect_den,
display_aspect_num, display_aspect_den, colorspace
def find_insert_index_for_timeline_chain(root: ET.Element) -> int:
"""Find insertion index for a timeline chain (after background, before tracks)."""
found_bg = False
for i, child in enumerate(root):
if child.tag == "playlist" and child.get("id") == "background":
found_bg = True
continue
if found_bg and child.tag == "playlist":
return i
# Fallback: before first tractor
for i, child in enumerate(root):
if child.tag == "tractor":
return i
return len(root)
def _find_insert_index_for_playlist(root: ET.Element) -> int:
"""Find the insertion index for a new track playlist.
Playlists should be inserted before the main tractor (last tractor),
skipping any sub-tractor transitions that precede existing playlists.
"""
root = etree.Element("mlt")
# Find the main tractor: the one with the "shotcut" property
for i, child in enumerate(root):
if child.tag == "tractor" and get_property(child, "shotcut"):
return i
return len(root)
def create_blank_project(profile: dict) -> ET.Element:
"""Create a minimal blank MLT project."""
_clear_parent_map()
root = ET.Element("mlt")
root.set("LC_NUMERIC", "C")
root.set("version", "7.0.0")
root.set("title", "Shotcut")
root.set("version", "7.36.1")
root.set("title", "Shotcut version 26.2.26")
root.set("producer", "main_bin")
# Profile
prof = etree.SubElement(root, "profile")
prof = ET.SubElement(root, "profile")
prof.set("description", f"{profile.get('width', 1920)}x{profile.get('height', 1080)} "
f"{profile.get('frame_rate_num', 30000)}/{profile.get('frame_rate_den', 1001)}fps")
for key in ["width", "height", "frame_rate_num", "frame_rate_den",
@@ -138,60 +208,113 @@ def create_blank_project(profile: dict) -> etree._Element:
prof.set(key, str(profile[key]))
# Main bin playlist (holds source clips for reference)
main_bin = etree.SubElement(root, "playlist")
main_bin = ET.SubElement(root, "playlist")
main_bin.set("id", "main_bin")
set_property(main_bin, "xml_retain", "1")
_set_parent(main_bin, root)
# Background producer (black)
bg = etree.SubElement(root, "producer")
bg = ET.SubElement(root, "producer")
bg.set("id", "black")
bg.set("in", "00:00:00.000")
bg.set("out", "04:00:00.000")
set_property(bg, "length", "04:00:00.040")
set_property(bg, "eof", "pause")
set_property(bg, "resource", "0")
set_property(bg, "aspect_ratio", "1")
set_property(bg, "mlt_service", "color")
set_property(bg, "mlt_image_format", "rgba")
set_property(bg, "set.test_audio", "0")
_set_parent(bg, root)
# Background playlist
bg_playlist = etree.SubElement(root, "playlist")
bg_playlist = ET.SubElement(root, "playlist")
bg_playlist.set("id", "background")
entry = etree.SubElement(bg_playlist, "entry")
entry = ET.SubElement(bg_playlist, "entry")
entry.set("producer", "black")
entry.set("in", "00:00:00.000")
entry.set("out", "04:00:00.000")
_set_parent(entry, bg_playlist)
_set_parent(bg_playlist, root)
# Main tractor (timeline)
tractor = etree.SubElement(root, "tractor")
tractor = ET.SubElement(root, "tractor")
tractor.set("id", "tractor0")
tractor.set("title", "Shotcut version 26.2.26")
tractor.set("in", "00:00:00.000")
tractor.set("out", "00:00:00.000")
set_property(tractor, "shotcut", "1")
set_property(tractor, "shotcut:projectAudioChannels", "2")
set_property(tractor, "shotcut:projectFolder", "0")
set_property(tractor, "shotcut:processingMode", "Native8Cpu")
set_property(tractor, "shotcut:skipConvert", "0")
_set_parent(tractor, root)
multitrack = etree.SubElement(tractor, "multitrack")
bg_track = etree.SubElement(multitrack, "track")
bg_track = ET.SubElement(tractor, "track")
bg_track.set("producer", "background")
_set_parent(bg_track, tractor)
return root
def add_track_to_tractor(root: etree._Element, tractor: etree._Element,
def _add_system_transitions(tractor: ET.Element, track_index: int,
root: ET.Element = None,
track_type: str = "video") -> None:
"""Add standard mix and qtblend transitions for a track.
Audio tracks only get a mix transition. Video tracks get both mix
and qtblend, matching Shotcut's actual output.
"""
# Audio mix transition (always added)
mix_trans = ET.SubElement(tractor, "transition")
mix_trans.set("id", new_id("transition"))
set_property(mix_trans, "a_track", "0")
set_property(mix_trans, "b_track", str(track_index))
set_property(mix_trans, "mlt_service", "mix")
set_property(mix_trans, "always_active", "1")
set_property(mix_trans, "sum", "1")
_set_parent(mix_trans, tractor)
if track_type == "audio":
return
# Video composite transition
prev_video_track = 0
is_first_video = True
if root is not None:
all_tracks = get_tractor_tracks(tractor)
for i in range(1, track_index):
if i < len(all_tracks):
pl = find_element_by_id(root, all_tracks[i].get("producer", ""))
if pl is not None and get_property(pl, "shotcut:video"):
prev_video_track = i
is_first_video = False
comp_trans = ET.SubElement(tractor, "transition")
comp_trans.set("id", new_id("transition"))
set_property(comp_trans, "a_track", str(prev_video_track))
set_property(comp_trans, "b_track", str(track_index))
set_property(comp_trans, "compositing", "0")
set_property(comp_trans, "distort", "0")
set_property(comp_trans, "rotate_center", "0")
set_property(comp_trans, "mlt_service", "qtblend")
set_property(comp_trans, "threads", "0")
set_property(comp_trans, "disable", "1" if is_first_video else "0")
_set_parent(comp_trans, tractor)
def add_track_to_tractor(root: ET.Element, tractor: ET.Element,
track_type: str = "video",
name: str = "") -> tuple[str, str]:
name: str = "") -> tuple[str, int]:
"""Add a new track (playlist) to a tractor.
Args:
root: The MLT document root
tractor: The tractor element to add the track to
track_type: "video" or "audio"
name: Optional track name
Returns:
Tuple of (playlist_id, track_index_in_multitrack)
Tuple of (playlist_id, track_index)
"""
playlist_id = new_id("playlist")
# Create the playlist element before the tractor
playlist = etree.Element("playlist")
# Create the playlist element
playlist = ET.Element("playlist")
playlist.set("id", playlist_id)
if name:
set_property(playlist, "shotcut:name", name)
@@ -199,134 +322,157 @@ def add_track_to_tractor(root: etree._Element, tractor: etree._Element,
set_property(playlist, "shotcut:video", "1")
else:
set_property(playlist, "shotcut:audio", "1")
_set_parent(playlist, None) # Will be set when inserted
# Insert playlist before the tractor in the document
tractor_parent = tractor.getparent()
if tractor_parent is None:
tractor_parent = root
tractor_idx = list(tractor_parent).index(tractor)
tractor_parent.insert(tractor_idx, playlist)
# Insert playlist before the first tractor in the document
insert_idx = _find_insert_index_for_playlist(root)
root.insert(insert_idx, playlist)
_set_parent(playlist, root)
# Add track reference in multitrack
# Add track reference — preserve multitrack if already present
multitrack = tractor.find("multitrack")
if multitrack is None:
multitrack = etree.SubElement(tractor, "multitrack")
if multitrack is not None:
existing_tracks = multitrack.findall("track")
track_elem = ET.SubElement(multitrack, "track")
_set_parent(track_elem, multitrack)
else:
existing_tracks = tractor.findall("track")
track_elem = ET.SubElement(tractor, "track")
_set_parent(track_elem, tractor)
track_elem = etree.SubElement(multitrack, "track")
track_elem.set("producer", playlist_id)
if track_type == "audio":
track_elem.set("hide", "video")
elif track_type == "video":
track_elem.set("hide", "")
track_index = len(multitrack.findall("track")) - 1
track_index = len(existing_tracks)
# Add standard transitions for compositing
if track_type == "video" and track_index > 0:
# Audio mix transition
mix_trans = etree.SubElement(tractor, "transition")
mix_trans.set("id", new_id("transition"))
set_property(mix_trans, "a_track", "0")
set_property(mix_trans, "b_track", str(track_index))
set_property(mix_trans, "mlt_service", "mix")
set_property(mix_trans, "always_active", "1")
set_property(mix_trans, "sum", "1")
# Video composite transition
comp_trans = etree.SubElement(tractor, "transition")
comp_trans.set("id", new_id("transition"))
set_property(comp_trans, "a_track", "0")
set_property(comp_trans, "b_track", str(track_index))
set_property(comp_trans, "mlt_service", "frei0r.cairoblend")
set_property(comp_trans, "disable", "0")
if track_type == "audio" and track_index > 0:
mix_trans = etree.SubElement(tractor, "transition")
mix_trans.set("id", new_id("transition"))
set_property(mix_trans, "a_track", "0")
set_property(mix_trans, "b_track", str(track_index))
set_property(mix_trans, "mlt_service", "mix")
set_property(mix_trans, "always_active", "1")
set_property(mix_trans, "sum", "1")
if track_index > 0:
_add_system_transitions(tractor, track_index, root, track_type)
return playlist_id, track_index
def create_producer(root: etree._Element, resource: str,
def _create_media_element(tag: str, elem_id: str, resource: str,
in_point: str, out_point: Optional[str],
caption: Optional[str], service: str,
extra_props: Optional[dict] = None,
length: Optional[str] = None) -> ET.Element:
"""Create a chain or producer element for media."""
elem = ET.Element(tag)
elem.set("id", elem_id)
elem.set("in", in_point)
if out_point:
elem.set("out", out_point)
set_property(elem, "length", length or out_point or "")
set_property(elem, "eof", "pause")
set_property(elem, "resource", resource)
set_property(elem, "mlt_service", service)
set_property(elem, "seekable", "1")
set_property(elem, "shotcut:skipConvert", "0")
set_property(elem, "ignore_points", "0")
if caption:
set_property(elem, "shotcut:caption", caption)
else:
import os
set_property(elem, "shotcut:caption", os.path.basename(resource))
if extra_props:
for key, val in extra_props.items():
set_property(elem, key, str(val))
return elem
def create_chain(root: ET.Element, resource: str,
in_point: str = "00:00:00.000",
out_point: Optional[str] = None,
caption: Optional[str] = None,
service: str = "avformat-novalidate",
extra_props: Optional[dict] = None,
insert_idx: Optional[int] = None,
length: Optional[str] = None,
id_override: Optional[str] = None) -> ET.Element:
chain_id = id_override or new_id("chain")
chain = _create_media_element(
"chain", chain_id, resource, in_point, out_point, caption, service, extra_props,
length=length,
)
if insert_idx is not None:
root.insert(insert_idx, chain)
else:
root.append(chain)
_set_parent(chain, root)
return chain
def create_producer(root: ET.Element, resource: str,
in_point: str = "00:00:00.000",
out_point: Optional[str] = None,
caption: Optional[str] = None,
service: str = "avformat") -> etree._Element:
"""Create a new producer element for a media file.
Args:
root: The MLT document root (producer is appended here)
resource: Path to the media file
in_point: In timecode
out_point: Out timecode (None = full duration)
caption: Display name
service: MLT service type (avformat, color, etc.)
Returns:
The new producer element
"""
service: str = "avformat") -> ET.Element:
"""Create a new <producer> element (for internal services like color)."""
prod_id = new_id("producer")
producer = etree.Element("producer")
producer.set("id", prod_id)
producer.set("in", in_point)
if out_point:
producer.set("out", out_point)
producer = _create_media_element(
"producer", prod_id, resource, in_point, out_point, caption, service
)
set_property(producer, "resource", resource)
set_property(producer, "mlt_service", service)
if caption:
set_property(producer, "shotcut:caption", caption)
else:
# Use filename as caption
import os
set_property(producer, "shotcut:caption", os.path.basename(resource))
# Generate a UUID for clip tracking
set_property(producer, "shotcut:uuid", str(uuid.uuid4()))
# Insert before the first tractor
tractors = root.findall("tractor")
if tractors:
tractor_idx = list(root).index(tractors[0])
root.insert(tractor_idx, producer)
else:
root.append(producer)
insert_idx = find_insert_index_for_timeline_chain(root)
root.insert(insert_idx, producer)
_set_parent(producer, root)
return producer
def add_entry_to_playlist(playlist: etree._Element, producer_id: str,
in_point: Optional[str] = None,
out_point: Optional[str] = None,
position: Optional[int] = None) -> etree._Element:
"""Add a clip entry to a playlist (track).
def add_chain_to_bin(root: ET.Element, chain: ET.Element) -> ET.Element:
"""Add an entry for a chain to the main_bin playlist.
Args:
playlist: The playlist element
producer_id: ID of the producer to reference
in_point: In point (trim start), or None for producer's in
out_point: Out point (trim end), or None for producer's out
position: Insert position (index among entries/blanks), or None for append
root: The MLT document root
chain: The chain element to reference
Returns:
The new entry element
"""
entry = etree.Element("entry")
main_bin = find_element_by_id(root, "main_bin")
if main_bin is None:
raise RuntimeError("main_bin playlist not found")
entry = ET.SubElement(main_bin, "entry")
entry.set("producer", chain.get("id"))
entry.set("in", chain.get("in", "00:00:00.000"))
entry.set("out", chain.get("out", "00:00:00.000"))
_set_parent(entry, main_bin)
return entry
def add_entry_to_playlist(playlist: ET.Element, producer_id: str,
in_point: Optional[str] = None,
out_point: Optional[str] = None,
position: Optional[int] = None,
insert_before: Optional[int] = None) -> ET.Element:
"""Add a clip entry to a playlist (track).
Args:
insert_before: If provided, insert before the playlist child at this
raw index. Overrides position.
"""
entry = ET.Element("entry")
entry.set("producer", producer_id)
if in_point:
entry.set("in", in_point)
if out_point:
entry.set("out", out_point)
if position is not None:
if insert_before is not None:
playlist.insert(insert_before, entry)
elif position is not None:
children = list(playlist)
# Skip property elements
non_prop = [c for c in children if c.tag != "property"]
if position < len(non_prop):
playlist.insert(list(playlist).index(non_prop[position]), entry)
@@ -335,59 +481,59 @@ def add_entry_to_playlist(playlist: etree._Element, producer_id: str,
else:
playlist.append(entry)
_set_parent(entry, playlist)
return entry
def add_blank_to_playlist(playlist: etree._Element, length: str) -> etree._Element:
def add_blank_to_playlist(playlist: ET.Element, length: str) -> ET.Element:
"""Add a blank (gap) to a playlist."""
blank = etree.SubElement(playlist, "blank")
blank = ET.SubElement(playlist, "blank")
blank.set("length", length)
_set_parent(blank, playlist)
return blank
def add_filter_to_element(element: etree._Element, service: str,
properties: Optional[dict] = None) -> etree._Element:
"""Add a filter to any MLT element (producer, playlist, tractor).
def add_filter_to_element(element: ET.Element, service: str,
shotcut_filter: Optional[str] = None,
properties: Optional[dict] = None) -> ET.Element:
"""Add a filter to any MLT element (producer, chain, playlist, tractor).
Args:
element: The element to attach the filter to
service: MLT service name (e.g., "brightness", "volume")
shotcut_filter: Shotcut UI identifier (e.g., "brightness", "volume")
properties: Dict of property name → value
Returns:
The new filter element
"""
filt = etree.SubElement(element, "filter")
filt = ET.SubElement(element, "filter")
filt.set("id", new_id("filter"))
set_property(filt, "mlt_service", service)
if shotcut_filter:
set_property(filt, "shotcut:filter", shotcut_filter)
if properties:
for key, val in properties.items():
set_property(filt, key, str(val))
_set_parent(filt, element)
return filt
def remove_element(element: etree._Element) -> bool:
def remove_element(element: ET.Element) -> bool:
"""Remove an element from its parent. Returns True if successful."""
parent = element.getparent()
parent = get_parent(element)
if parent is not None:
parent.remove(element)
_remove_parent(element)
return True
return False
def get_playlist_entries(playlist: etree._Element) -> list[dict]:
"""Get all entries and blanks from a playlist as structured data.
Returns list of dicts with keys:
- type: "entry" or "blank"
- producer: producer ID (entries only)
- in: in point (entries only)
- out: out point (entries only)
- length: blank duration (blanks only)
- index: position in the playlist
"""
def get_playlist_entries(playlist: ET.Element) -> list[dict]:
"""Get all entries and blanks from a playlist as structured data."""
results = []
idx = 0
for child in playlist:
@@ -410,6 +556,13 @@ def get_playlist_entries(playlist: etree._Element) -> list[dict]:
return results
def deep_copy_element(element: etree._Element) -> etree._Element:
def deep_copy_element(element: ET.Element) -> ET.Element:
"""Create a deep copy of an XML element."""
return copy.deepcopy(element)
def set_tractor_out(root: ET.Element, out_timecode: str) -> None:
"""Set the main tractor's out point."""
tractor = get_main_tractor(root)
if tractor is not None:
tractor.set("out", out_timecode)
-1
View File
@@ -36,7 +36,6 @@ setup(
install_requires=[
"click>=8.0.0",
"prompt-toolkit>=3.0.0",
"lxml>=4.9.0",
],
extras_require={
"dev": [
+16 -26
View File
@@ -48,9 +48,6 @@ cli-anything-shotcut
# Enter commands interactively with tab-completion and history
```
The REPL exposes the same practical helpers as command mode, including
`add-clip ... --at`, `volume-envelope`, and `duck`.
## Command Groups
@@ -79,7 +76,7 @@ Timeline operations: tracks, clips, trimming.
| `tracks` | List all tracks |
| `add-track` | Add a new track to the timeline |
| `remove-track` | Remove a track by index |
| `add-clip` | Add a media clip to a track; supports `--at` for absolute timeline placement |
| `add-clip` | Add an imported clip to a track by clip_id; supports `--at` for absolute placement |
| `remove-clip` | Remove a clip from a track |
| `move-clip` | Move a clip between tracks or positions |
| `trim` | Trim a clip's in/out points |
@@ -102,9 +99,9 @@ Filter operations: add, remove, configure effects.
| `add` | Add a filter to a clip, track, or globally |
| `remove` | Remove a filter by index |
| `set` | Set a parameter on a filter |
| `list` | List active filters on a target |
| `volume-envelope` | Create or replace a keyframed volume envelope on a track or clip |
| `duck` | Build a practical ducking envelope over one or more time windows |
| `list` | List active filters on a target |
### Media
@@ -113,6 +110,7 @@ Media operations: probe, list, check files.
| Command | Description |
|---------|-------------|
| `import` | Import a media file into the project bin |
| `probe` | Analyze a media file's properties |
| `list` | List all media clips in the current project |
| `check` | Check all media files for existence |
@@ -206,49 +204,41 @@ Export the project to a final output format.
cli-anything-shotcut --project myproject.json export render output.mp4 --overwrite
```
### Deterministic Timeline Reconstruction
For rebuilds, prefer absolute placement over append-only clip insertion:
```bash
cli-anything-shotcut --project myproject.json -s timeline add-clip intro.mp4 \
cli-anything-shotcut --project myproject.mlt media import intro.mp4
cli-anything-shotcut --project myproject.mlt timeline add-clip clip0 \
--track 1 --in 00:00:00.000 --out 00:00:04.000 --at 00:00:00.000
cli-anything-shotcut --project myproject.json -s timeline add-clip broll.mp4 \
cli-anything-shotcut --project myproject.mlt media import broll.mp4
cli-anything-shotcut --project myproject.mlt timeline add-clip clip1 \
--track 1 --in 00:00:10.000 --out 00:00:16.000 --at 00:00:08.000
```
Notes:
- `--at` inserts blanks automatically when the target time lands in empty space.
- The CLI rejects overlap with an existing clip instead of silently changing the timeline.
- For agent-built timelines, prefer explicit `--in` and `--out` values so later absolute placement remains unambiguous.
- The CLI rejects overlap with an existing clip.
- Prefer explicit `--in` and `--out` values so later absolute placement remains unambiguous.
### Audio Automation
The released CLI now includes higher-level audio automation helpers:
```bash
cli-anything-shotcut --project myproject.json -s filter volume-envelope \
cli-anything-shotcut --project myproject.mlt filter volume-envelope \
--track 2 \
--point 00:00:00.000=1.0 \
--point 00:00:03.000=0.35 \
--point 00:00:05.000=1.0
cli-anything-shotcut --project myproject.json -s filter duck \
cli-anything-shotcut --project myproject.mlt filter duck \
--track 2 \
--window 00:00:06.000:00:00:09.000 \
--window 00:00:15.000:00:00:18.000 \
--normal 1.0 --duck 0.25 \
--attack 00:00:00.150 --release 00:00:00.250
--window 00:00:06.000..00:00:09.000 \
--window 00:00:15.000..00:00:18.000 \
--normal 1.0 --duck 0.25
```
Keyframed `volume` filters now export as ffmpeg `volume=` expressions instead of
collapsing to a simple fade. This is materially better, but you should still
review final renders when automation is editorially important.
## State Management
The CLI maintains session state with:
- **Undo/Redo**: Up to 50 levels of history
@@ -290,4 +280,4 @@ When using this CLI programmatically:
## Version
1.0.0
1.0.0
+12 -23
View File
@@ -1,12 +1,12 @@
---
name: "cli-anything-videocaptioner"
description: >-
AI-powered video captioning — transcribe speech, optimize/translate subtitles, burn into video with beautiful customizable styles (ASS outline or rounded background). Free ASR and translation included.
AI-powered video captioning — transcribe speech, optimize/translate subtitles, and burn them into video via the stable VideoCaptioner backend. Free ASR and translation included.
---
# cli-anything-videocaptioner
AI-powered video captioning tool. Transcribe speech → optimize subtitles → translate → burn into video with beautiful styles.
AI-powered video captioning tool. Transcribe speech → optimize subtitles → translate → burn into video.
## Installation
@@ -15,7 +15,7 @@ pip install cli-anything-videocaptioner
```
**Prerequisites:**
- Python 3.10-3.12 (`videocaptioner` 1.4.1 requires `>=3.10,<3.13`; prefer 3.12)
- Python 3.10-3.12 (`videocaptioner` 1.4.1 requires `>=3.10,<3.13`)
- `videocaptioner` must be installed (`pip install videocaptioner`)
- FFmpeg required for video synthesis
@@ -42,14 +42,12 @@ cli-anything-videocaptioner process video.mp4 --asr bijian --translator bing --t
# Review subtitle/script consistency before a final hard-burn
cli-anything-videocaptioner synthesize video.mp4 -s subtitles.srt \
--subtitle-mode hard \
--review-script approved_script.txt \
--max-script-diff-ratio 0.12
--review-script approved_script.txt
# Render a one-frame subtitle preview for review
cli-anything-videocaptioner review subtitles.srt \
--script approved_script.txt \
--preview-video video.mp4 \
--preview-at 00:00:05.000 \
--preview-output review_5s.png
# JSON output (for agent consumption)
@@ -87,12 +85,13 @@ subtitle <input.srt> [--translator llm|bing|google] [--target-language CODE] [--
synthesize <video> -s <subtitle> [--subtitle-mode soft|hard] [--quality ultra|high|medium|low] [-o PATH] [--review-script PATH] [--max-script-diff-ratio FLOAT]
```
- Mirrors the stable backend synthesize surface
- Subtitle look is controlled by the subtitle asset/backend version, not extra harness flags
- `--review-script` checks subtitle/script drift before the final export
- Prefer reviewed subtitle assets over synthesize-time style tweaking
- Lower `--max-script-diff-ratio` when copy accuracy matters more than resilience
### process — Full pipeline
```
process <input> [--asr ...] [--translator ...] [--target-language ...] [--subtitle-mode ...] [--style ...] [--no-optimize] [--no-translate] [--no-synthesize] [-o PATH]
process <input> [--asr ...] [--translator ...] [--target-language ...] [--subtitle-mode ...] [--layout ...] [--no-optimize] [--no-translate] [--no-synthesize] [-o PATH]
```
### review — Consistency check and preview
@@ -106,6 +105,7 @@ review <input.srt|input.ass> [--script PATH] [--max-diff-ratio FLOAT] [--preview
```
styles
```
- Reports whether the installed backend exposes preset styling support
### config — Manage settings
```
@@ -126,22 +126,11 @@ cli-anything-videocaptioner --json transcribe video.mp4 --asr bijian
# {"output_path": "/path/to/output.srt"}
```
## Style Presets
## Backend Notes
Style support depends on the installed backend version. Use `styles` to see what
the backend actually exposes before relying on style-specific workflows.
| Name | Mode | Description |
|------|------|-------------|
| `default` | ASS | White text, black outline — clean and universal |
| `anime` | ASS | Warm white, orange outline — anime/cartoon style |
| `vertical` | ASS | High bottom margin — for portrait/vertical videos |
| `rounded` | Rounded | Dark text on semi-transparent rounded background |
Compatibility flags such as `layout`, `render_mode`, `style`, `style_override`,
and `font_file` are backend-version dependent. Do not assume the stable harness
will forward them reliably during `synthesize`; prefer checked subtitle assets
plus `review` before final export.
- The upstream backend currently requires Python `>=3.10,<3.13`; prefer Python `3.12` for matrix runs.
- Advanced synthesize-time style flags are backend-version dependent. Use `styles` to check whether the installed backend exposes them.
- Compatibility flags such as `layout`, `render_mode`, `style`, `style_override`, and `font_file` should be treated as non-stable unless `styles` and the installed backend confirm they are supported.
## Target Languages