cut-fx 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. cut_fx/__init__.py +39 -0
  2. cut_fx/api.py +189 -0
  3. cut_fx/beats/__init__.py +15 -0
  4. cut_fx/beats/detector.py +49 -0
  5. cut_fx/beats/placer.py +76 -0
  6. cut_fx/catalog/__init__.py +19 -0
  7. cut_fx/catalog/loader.py +77 -0
  8. cut_fx/catalog/mapping.py +41 -0
  9. cut_fx/cli.py +330 -0
  10. cut_fx/composite/__init__.py +14 -0
  11. cut_fx/composite/composer.py +222 -0
  12. cut_fx/config.py +72 -0
  13. cut_fx/data/README.md +44 -0
  14. cut_fx/data/engine_mapping.json +7763 -0
  15. cut_fx/data/transitions_catalog.json +4568 -0
  16. cut_fx/engines/__init__.py +14 -0
  17. cut_fx/engines/base.py +49 -0
  18. cut_fx/engines/ffmpeg/__init__.py +109 -0
  19. cut_fx/engines/ffmpeg/filter_builder.py +161 -0
  20. cut_fx/engines/ffmpeg/grid_card.py +35 -0
  21. cut_fx/engines/ffmpeg/motion.py +66 -0
  22. cut_fx/engines/ffmpeg/optical_blur.py +35 -0
  23. cut_fx/engines/ffmpeg/rotation.py +28 -0
  24. cut_fx/engines/ffmpeg/runner.py +115 -0
  25. cut_fx/engines/ffmpeg/xfade.py +73 -0
  26. cut_fx/engines/overlay/__init__.py +224 -0
  27. cut_fx/engines/overlay/graphic.py +40 -0
  28. cut_fx/engines/overlay/light_fx.py +73 -0
  29. cut_fx/engines/overlay/particle.py +51 -0
  30. cut_fx/engines/overlay/renderer.py +164 -0
  31. cut_fx/engines/overlay/shape_mask.py +60 -0
  32. cut_fx/engines/overlay/templates/burst.html +89 -0
  33. cut_fx/engines/overlay/templates/flash.html +60 -0
  34. cut_fx/engines/overlay/templates/particle_sparks.html +140 -0
  35. cut_fx/engines/overlay/templates/shape_circle.html +74 -0
  36. cut_fx/engines/overlay/templates/streak.html +127 -0
  37. cut_fx/engines/procedural/__init__.py +101 -0
  38. cut_fx/engines/procedural/compositor.py +27 -0
  39. cut_fx/engines/procedural/film_retro.py +87 -0
  40. cut_fx/engines/procedural/frame_iterator.py +89 -0
  41. cut_fx/engines/procedural/glitch.py +116 -0
  42. cut_fx/engines/shader/__init__.py +95 -0
  43. cut_fx/engines/shader/context.py +75 -0
  44. cut_fx/engines/shader/glsl/dissolve.glsl +66 -0
  45. cut_fx/engines/shader/glsl/lens_flare.glsl +144 -0
  46. cut_fx/engines/shader/glsl/refraction.glsl +89 -0
  47. cut_fx/engines/shader/glsl/warp.glsl +90 -0
  48. cut_fx/engines/shader/runner.py +114 -0
  49. cut_fx/exceptions.py +43 -0
  50. cut_fx/hardware/__init__.py +15 -0
  51. cut_fx/hardware/ffmpeg_caps.py +104 -0
  52. cut_fx/hardware/gpu_caps.py +37 -0
  53. cut_fx/resolver.py +172 -0
  54. cut_fx/schema/__init__.py +15 -0
  55. cut_fx/schema/generator.py +99 -0
  56. cut_fx/schema/parser.py +64 -0
  57. cut_fx-0.1.2.dist-info/METADATA +420 -0
  58. cut_fx-0.1.2.dist-info/RECORD +61 -0
  59. cut_fx-0.1.2.dist-info/WHEEL +4 -0
  60. cut_fx-0.1.2.dist-info/entry_points.txt +2 -0
  61. cut_fx-0.1.2.dist-info/licenses/LICENSE +21 -0
cut_fx/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ # Filepath: src/cut_fx/__init__.py
2
+ # Condensed Description: Package entry point; re-exports public API and version.
3
+ # Architecture Layer: Package
4
+ # Environment: Local
5
+ # Script Hierarchy: Top
6
+ # Dependencies: Internal: cut_fx.api / External: None
7
+ # Exposes: __version__, apply_transition, apply_sequence, list_transitions, list_categories, get_transition_info, TransitionConfig
8
+ # Configuration: N/A
9
+
10
+ from __future__ import annotations
11
+
12
+ __version__ = "0.1.2"
13
+
14
+ from cut_fx.api import (
15
+ apply_sequence,
16
+ apply_transition,
17
+ get_transition_info,
18
+ list_categories,
19
+ list_transitions,
20
+ )
21
+
22
+ __all__ = [
23
+ "__version__",
24
+ "apply_transition",
25
+ "apply_sequence",
26
+ "list_transitions",
27
+ "list_categories",
28
+ "get_transition_info",
29
+ "TransitionConfig",
30
+ ]
31
+
32
+
33
+ def __getattr__(name: str) -> object:
34
+ """Lazy-load optional names to avoid ImportError at package import."""
35
+ if name == "TransitionConfig":
36
+ from cut_fx.config import TransitionConfig # noqa: PLC0415
37
+
38
+ return TransitionConfig
39
+ raise AttributeError(f"module 'cut_fx' has no attribute {name!r}")
cut_fx/api.py ADDED
@@ -0,0 +1,189 @@
1
+ # Filepath: src/cut_fx/api.py
2
+ # Condensed Description: Expose top-level public functions for applying and listing transitions.
3
+ # Architecture Layer: API
4
+ # Environment: Local
5
+ # Script Hierarchy: High
6
+ # Dependencies: Internal: cut_fx.resolver, cut_fx.config, cut_fx.catalog.loader, cut_fx.catalog.mapping / External: stdlib
7
+ # Exposes: apply_transition, apply_sequence, list_transitions, list_categories, get_transition_info
8
+ # Configuration: N/A
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import subprocess
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ from cut_fx.config import DEFAULT_OVERLAP, TransitionConfig
18
+
19
+ _log = logging.getLogger(__name__)
20
+
21
+
22
+ def apply_transition(
23
+ clip_a: str | Path,
24
+ clip_b: str | Path,
25
+ transition: str,
26
+ output: str | Path,
27
+ overlap_seconds: float = DEFAULT_OVERLAP,
28
+ config: TransitionConfig | None = None,
29
+ ) -> Path:
30
+ """Apply a named transition between two clips and write to output.
31
+
32
+ Args:
33
+ clip_a: Path to the first (outgoing) clip.
34
+ clip_b: Path to the second (incoming) clip.
35
+ transition: Transition slug or display name.
36
+ output: Destination path for the rendered file.
37
+ overlap_seconds: Duration of the transition overlap in seconds.
38
+ config: Optional pre-built TransitionConfig; built from other args when None.
39
+
40
+ Returns:
41
+ Resolved Path to the written output file.
42
+ """
43
+ from cut_fx.resolver import resolve_and_render # noqa: PLC0415
44
+
45
+ if config is None:
46
+ config = TransitionConfig(transition=transition, overlap_seconds=overlap_seconds)
47
+
48
+ result = resolve_and_render(
49
+ transition=transition,
50
+ clip_a=Path(clip_a),
51
+ clip_b=Path(clip_b),
52
+ config=config,
53
+ output=Path(output),
54
+ )
55
+ _log.debug(f"apply_transition: wrote {result.output_path} via {result.engine_used}")
56
+ return result.output_path
57
+
58
+
59
+ def apply_sequence(
60
+ clips: list[str | Path],
61
+ transitions: list[str | TransitionConfig],
62
+ output: str | Path,
63
+ default_overlap: float = DEFAULT_OVERLAP,
64
+ ) -> Path:
65
+ """Apply transitions across N clips (N-1 transitions) and concatenate to output.
66
+
67
+ Args:
68
+ clips: Ordered list of clip paths; must have at least 2 entries.
69
+ transitions: List of transition slugs/names or TransitionConfig objects.
70
+ Length must equal ``len(clips) - 1``.
71
+ output: Destination path for the concatenated result.
72
+ default_overlap: Overlap seconds used when a transition entry is a plain string.
73
+
74
+ Returns:
75
+ Resolved Path to the written output file.
76
+
77
+ Raises:
78
+ ValueError: When the lengths of clips and transitions are inconsistent.
79
+ """
80
+ if len(clips) < 2:
81
+ raise ValueError("apply_sequence requires at least 2 clips")
82
+ if len(transitions) != len(clips) - 1:
83
+ raise ValueError(f"Expected {len(clips) - 1} transitions for {len(clips)} clips, got {len(transitions)}")
84
+
85
+ output = Path(output)
86
+ segment_paths: list[Path] = []
87
+
88
+ with tempfile.TemporaryDirectory(prefix="cut_fx_seq_") as tmpdir:
89
+ tmp = Path(tmpdir)
90
+
91
+ for i, (clip_a, clip_b, trans) in enumerate(zip(clips, clips[1:], transitions)):
92
+ seg_out = tmp / f"seg_{i:04d}.mp4"
93
+
94
+ if isinstance(trans, TransitionConfig):
95
+ cfg = trans
96
+ trans_name = cfg.transition
97
+ else:
98
+ trans_name = trans
99
+ cfg = TransitionConfig(
100
+ transition=trans_name,
101
+ overlap_seconds=default_overlap,
102
+ )
103
+
104
+ apply_transition(
105
+ clip_a=clip_a,
106
+ clip_b=clip_b,
107
+ transition=trans_name,
108
+ output=seg_out,
109
+ config=cfg,
110
+ )
111
+ segment_paths.append(seg_out)
112
+ _log.debug(f"Rendered segment {i}: {seg_out}")
113
+
114
+ concat_list = tmp / "concat.txt"
115
+ concat_list.write_text(
116
+ "\n".join(f"file '{p}'" for p in segment_paths),
117
+ encoding="utf-8",
118
+ )
119
+
120
+ cmd = [
121
+ "ffmpeg",
122
+ "-y",
123
+ "-f",
124
+ "concat",
125
+ "-safe",
126
+ "0",
127
+ "-i",
128
+ str(concat_list),
129
+ "-c",
130
+ "copy",
131
+ str(output),
132
+ ]
133
+ _log.debug(f"Running ffmpeg concat: {' '.join(cmd)}")
134
+ subprocess.run(cmd, check=True, capture_output=True)
135
+
136
+ return output
137
+
138
+
139
+ def list_transitions(category: str | None = None) -> list[str]:
140
+ """Return display names of all transitions; optionally filter by category.
141
+
142
+ Args:
143
+ category: Category slug to filter by, or None for all 559 transitions.
144
+
145
+ Returns:
146
+ List of transition display names.
147
+ """
148
+ from cut_fx.catalog.loader import list_transitions as _list # noqa: PLC0415
149
+
150
+ return _list(category)
151
+
152
+
153
+ def list_categories() -> list[str]:
154
+ """Return the 11 category names in catalog order.
155
+
156
+ Returns:
157
+ List of category slug strings.
158
+ """
159
+ from cut_fx.catalog.loader import list_categories as _list # noqa: PLC0415
160
+
161
+ return _list()
162
+
163
+
164
+ def get_transition_info(transition: str) -> dict:
165
+ """Return info dict for a transition, merging catalog and engine-mapping data.
166
+
167
+ Args:
168
+ transition: Transition slug or display name (case-insensitive).
169
+
170
+ Returns:
171
+ Dict with catalog fields plus engine, base, params, and duration_default
172
+ where available.
173
+
174
+ Raises:
175
+ TransitionNotFound: When the transition cannot be resolved.
176
+ """
177
+ from cut_fx.catalog.loader import get_transition_info as _get # noqa: PLC0415
178
+ from cut_fx.catalog.mapping import get_render_plan # noqa: PLC0415
179
+
180
+ info = _get(transition)
181
+
182
+ try:
183
+ plan = get_render_plan(info["slug"])
184
+ # Merge plan keys that are not already in info (avoid overwriting name/slug/categories)
185
+ info = {**info, **{k: v for k, v in plan.items() if k not in info}}
186
+ except Exception:
187
+ pass
188
+
189
+ return info
@@ -0,0 +1,15 @@
1
+ # Filepath: src/cut_fx/beats/__init__.py
2
+ # Condensed Description: Re-export beat detection and beat-aligned placement utilities.
3
+ # Architecture Layer: Beats
4
+ # Environment: Local
5
+ # Script Hierarchy: Mid
6
+ # Dependencies: Internal: cut_fx.beats.detector, cut_fx.beats.placer / External: None
7
+ # Exposes: detect_beats, transitions_on_beats
8
+ # Configuration: N/A
9
+
10
+ from __future__ import annotations
11
+
12
+ from cut_fx.beats.detector import detect_beats
13
+ from cut_fx.beats.placer import transitions_on_beats
14
+
15
+ __all__ = ["detect_beats", "transitions_on_beats"]
@@ -0,0 +1,49 @@
1
+ # Filepath: src/cut_fx/beats/detector.py
2
+ # Condensed Description: Detect beat timestamps in an audio file using librosa.
3
+ # Architecture Layer: Beats
4
+ # Environment: Local
5
+ # Script Hierarchy: Mid
6
+ # Dependencies: Internal: cut_fx.exceptions / External: librosa (optional)
7
+ # Exposes: detect_beats
8
+ # Configuration: N/A
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from pathlib import Path
14
+
15
+ from cut_fx.exceptions import EngineUnavailable
16
+
17
+ _log = logging.getLogger(__name__)
18
+
19
+
20
+ def detect_beats(audio_path: str | Path) -> list[float]:
21
+ """Detect beat timestamps in seconds from an audio file.
22
+
23
+ Args:
24
+ audio_path: Path to any audio file supported by librosa/soundfile.
25
+
26
+ Returns:
27
+ Sorted list of beat timestamps in seconds.
28
+
29
+ Raises:
30
+ EngineUnavailable: When librosa is not installed.
31
+ """
32
+ try:
33
+ import librosa # noqa: PLC0415
34
+ except ImportError as exc:
35
+ raise EngineUnavailable(
36
+ "beats",
37
+ "librosa is not installed",
38
+ "pip install cut-fx[beats]",
39
+ ) from exc
40
+
41
+ audio_path = Path(audio_path)
42
+ _log.debug(f"Loading audio for beat detection: {audio_path}")
43
+
44
+ y, sr = librosa.load(str(audio_path), sr=None)
45
+ _tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
46
+ beat_times: list[float] = sorted(float(t) for t in librosa.frames_to_time(beat_frames, sr=sr))
47
+
48
+ _log.debug(f"Detected {len(beat_times)} beats, tempo={float(_tempo):.1f} BPM")
49
+ return beat_times
cut_fx/beats/placer.py ADDED
@@ -0,0 +1,76 @@
1
+ # Filepath: src/cut_fx/beats/placer.py
2
+ # Condensed Description: Place transitions at beat timestamps and concatenate clips to output.
3
+ # Architecture Layer: Beats
4
+ # Environment: Local
5
+ # Script Hierarchy: Mid
6
+ # Dependencies: Internal: cut_fx.beats.detector, cut_fx.api / External: None
7
+ # Exposes: transitions_on_beats
8
+ # Configuration: N/A
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import statistics
14
+ from pathlib import Path
15
+
16
+ _log = logging.getLogger(__name__)
17
+
18
+
19
+ def transitions_on_beats(
20
+ clips: list[str | Path],
21
+ audio: str | Path,
22
+ transitions: list[str],
23
+ output: str | Path,
24
+ beats: list[float] | None = None,
25
+ ) -> Path:
26
+ """Place transitions at beat timestamps and concatenate clips to output.
27
+
28
+ Detects beats automatically when ``beats`` is omitted. Cycles through the
29
+ ``transitions`` list if it is shorter than the number of required cuts
30
+ (``len(clips) - 1``).
31
+
32
+ Args:
33
+ clips: Ordered list of video clip paths.
34
+ audio: Path to the audio file used for beat detection.
35
+ transitions: One or more transition names (slugs or display names).
36
+ output: Destination path for the concatenated result.
37
+ beats: Pre-computed beat timestamps in seconds. Detected from ``audio``
38
+ when None.
39
+
40
+ Returns:
41
+ Resolved Path to the written output file.
42
+
43
+ Raises:
44
+ ValueError: When fewer than 2 clips are provided.
45
+ EngineUnavailable: When librosa is missing and beats must be detected.
46
+ """
47
+ if len(clips) < 2:
48
+ raise ValueError("transitions_on_beats requires at least 2 clips")
49
+
50
+ if beats is None:
51
+ from cut_fx.beats.detector import detect_beats # noqa: PLC0415
52
+
53
+ beats = detect_beats(audio)
54
+ _log.debug(f"Auto-detected {len(beats)} beats from {audio}")
55
+
56
+ n_transitions = len(clips) - 1
57
+
58
+ # Use median beat interval as the overlap duration for all transitions
59
+ overlap_seconds: float = 0.5
60
+ if len(beats) >= 2:
61
+ intervals = [beats[i + 1] - beats[i] for i in range(len(beats) - 1)]
62
+ overlap_seconds = round(min(statistics.median(intervals) * 0.5, 1.0), 3)
63
+ _log.debug(f"Median beat interval={statistics.median(intervals):.3f}s, overlap={overlap_seconds}s")
64
+
65
+ # Cycle transitions list to cover all required cuts
66
+ resolved_transitions = [transitions[i % len(transitions)] for i in range(n_transitions)]
67
+
68
+ # Lazy import to avoid circular dependency at module load time
69
+ from cut_fx.api import apply_sequence # noqa: PLC0415
70
+
71
+ return apply_sequence(
72
+ clips=list(clips),
73
+ transitions=resolved_transitions,
74
+ output=output,
75
+ default_overlap=overlap_seconds,
76
+ )
@@ -0,0 +1,19 @@
1
+ # Filepath: src/cut_fx/catalog/__init__.py
2
+ # Condensed Description: Re-export catalog query functions from loader.
3
+ # Architecture Layer: Catalog
4
+ # Environment: Local
5
+ # Script Hierarchy: Mid
6
+ # Dependencies: Internal: cut_fx.catalog.loader / External: None
7
+ # Exposes: load_catalog, list_transitions, list_categories, get_transition_info
8
+ # Configuration: N/A
9
+
10
+ from __future__ import annotations
11
+
12
+ from cut_fx.catalog.loader import (
13
+ get_transition_info,
14
+ list_categories,
15
+ list_transitions,
16
+ load_catalog,
17
+ )
18
+
19
+ __all__ = ["load_catalog", "list_transitions", "list_categories", "get_transition_info"]
@@ -0,0 +1,77 @@
1
+ # Filepath: src/cut_fx/catalog/loader.py
2
+ # Condensed Description: Load, cache, and query the transitions catalog JSON.
3
+ # Architecture Layer: Catalog
4
+ # Environment: Local
5
+ # Script Hierarchy: Leaf
6
+ # Dependencies: Internal: cut_fx.exceptions / External: stdlib (json, difflib, pathlib)
7
+ # Exposes: load_catalog, list_transitions, list_categories, get_transition_info
8
+ # Configuration: N/A
9
+
10
+ from __future__ import annotations
11
+
12
+ import difflib
13
+ import json
14
+ import logging
15
+ from pathlib import Path
16
+
17
+ from cut_fx.exceptions import TransitionNotFound
18
+
19
+ _log = logging.getLogger(__name__)
20
+
21
+ _DATA_PATH = Path(__file__).parent.parent / "data" / "transitions_catalog.json"
22
+
23
+ _CATALOG: dict | None = None
24
+
25
+
26
+ def load_catalog() -> dict:
27
+ """Load and cache transitions_catalog.json."""
28
+ global _CATALOG
29
+ if _CATALOG is None:
30
+ with _DATA_PATH.open("r", encoding="utf-8") as f:
31
+ _CATALOG = json.load(f)
32
+ _log.debug(f"Loaded catalog with {len(_CATALOG['transitions'])} entries from {_DATA_PATH}")
33
+ return _CATALOG
34
+
35
+
36
+ def list_transitions(category: str | None = None) -> list[str]:
37
+ """Return display names of all transitions; optionally filter by category slug."""
38
+ catalog = load_catalog()
39
+ transitions = catalog["transitions"]
40
+ if category is not None:
41
+ transitions = [t for t in transitions if category in t.get("categories", [])]
42
+ return [t["name"] for t in transitions]
43
+
44
+
45
+ def list_categories() -> list[str]:
46
+ """Return the 11 category names from catalog['categories']."""
47
+ return load_catalog()["categories"]
48
+
49
+
50
+ def get_transition_info(transition: str) -> dict:
51
+ """Look up transition by slug or display name (case-insensitive). Raises TransitionNotFound."""
52
+ entry = _resolve_name(transition)
53
+ if entry is not None:
54
+ return entry
55
+
56
+ all_slugs = [t["slug"] for t in load_catalog()["transitions"]]
57
+ matches = difflib.get_close_matches(transition.lower(), all_slugs, n=1, cutoff=0.5)
58
+ suggestion = matches[0] if matches else None
59
+ raise TransitionNotFound(transition, suggestion=suggestion)
60
+
61
+
62
+ def _resolve_name(name: str) -> dict | None:
63
+ """Try exact slug, case-folded slug, exact name, case-folded name, slug-from-name."""
64
+ transitions = load_catalog()["transitions"]
65
+ folded = name.lower()
66
+ slug_form = folded.replace(" ", "_").replace("-", "_")
67
+
68
+ for entry in transitions:
69
+ if (
70
+ entry["slug"] == name
71
+ or entry["slug"] == folded
72
+ or entry["name"] == name
73
+ or entry["name"].lower() == folded
74
+ or entry["slug"] == slug_form
75
+ ):
76
+ return entry
77
+ return None
@@ -0,0 +1,41 @@
1
+ # Filepath: src/cut_fx/catalog/mapping.py
2
+ # Condensed Description: Load engine_mapping.json and serve per-slug render plans.
3
+ # Architecture Layer: Catalog
4
+ # Environment: Local
5
+ # Script Hierarchy: Leaf
6
+ # Dependencies: Internal: cut_fx.exceptions / External: stdlib (json, pathlib)
7
+ # Exposes: load_engine_mapping, get_render_plan
8
+ # Configuration: N/A
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ from pathlib import Path
15
+
16
+ from cut_fx.exceptions import TransitionNotFound
17
+
18
+ _log = logging.getLogger(__name__)
19
+
20
+ _DATA_PATH = Path(__file__).parent.parent / "data" / "engine_mapping.json"
21
+
22
+ _MAPPING: dict | None = None
23
+
24
+
25
+ def load_engine_mapping() -> dict:
26
+ """Load and cache engine_mapping.json."""
27
+ global _MAPPING
28
+ if _MAPPING is None:
29
+ with _DATA_PATH.open("r", encoding="utf-8") as f:
30
+ _MAPPING = json.load(f)
31
+ _log.debug(f"Loaded engine mapping with {len(_MAPPING)} entries from {_DATA_PATH}")
32
+ return _MAPPING
33
+
34
+
35
+ def get_render_plan(slug: str) -> dict:
36
+ """Return render plan for slug. Raises TransitionNotFound if missing."""
37
+ mapping = load_engine_mapping()
38
+ plan = mapping.get(slug)
39
+ if plan is None:
40
+ raise TransitionNotFound(slug)
41
+ return plan