mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-30 17:34:27 +08:00
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:
@@ -1 +1,13 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
coverage.json
|
||||
htmlcov/
|
||||
*.mlt
|
||||
*.egg
|
||||
.worktrees/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -36,7 +36,6 @@ setup(
|
||||
install_requires=[
|
||||
"click>=8.0.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
"lxml>=4.9.0",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user