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.
- cut_fx/__init__.py +39 -0
- cut_fx/api.py +189 -0
- cut_fx/beats/__init__.py +15 -0
- cut_fx/beats/detector.py +49 -0
- cut_fx/beats/placer.py +76 -0
- cut_fx/catalog/__init__.py +19 -0
- cut_fx/catalog/loader.py +77 -0
- cut_fx/catalog/mapping.py +41 -0
- cut_fx/cli.py +330 -0
- cut_fx/composite/__init__.py +14 -0
- cut_fx/composite/composer.py +222 -0
- cut_fx/config.py +72 -0
- cut_fx/data/README.md +44 -0
- cut_fx/data/engine_mapping.json +7763 -0
- cut_fx/data/transitions_catalog.json +4568 -0
- cut_fx/engines/__init__.py +14 -0
- cut_fx/engines/base.py +49 -0
- cut_fx/engines/ffmpeg/__init__.py +109 -0
- cut_fx/engines/ffmpeg/filter_builder.py +161 -0
- cut_fx/engines/ffmpeg/grid_card.py +35 -0
- cut_fx/engines/ffmpeg/motion.py +66 -0
- cut_fx/engines/ffmpeg/optical_blur.py +35 -0
- cut_fx/engines/ffmpeg/rotation.py +28 -0
- cut_fx/engines/ffmpeg/runner.py +115 -0
- cut_fx/engines/ffmpeg/xfade.py +73 -0
- cut_fx/engines/overlay/__init__.py +224 -0
- cut_fx/engines/overlay/graphic.py +40 -0
- cut_fx/engines/overlay/light_fx.py +73 -0
- cut_fx/engines/overlay/particle.py +51 -0
- cut_fx/engines/overlay/renderer.py +164 -0
- cut_fx/engines/overlay/shape_mask.py +60 -0
- cut_fx/engines/overlay/templates/burst.html +89 -0
- cut_fx/engines/overlay/templates/flash.html +60 -0
- cut_fx/engines/overlay/templates/particle_sparks.html +140 -0
- cut_fx/engines/overlay/templates/shape_circle.html +74 -0
- cut_fx/engines/overlay/templates/streak.html +127 -0
- cut_fx/engines/procedural/__init__.py +101 -0
- cut_fx/engines/procedural/compositor.py +27 -0
- cut_fx/engines/procedural/film_retro.py +87 -0
- cut_fx/engines/procedural/frame_iterator.py +89 -0
- cut_fx/engines/procedural/glitch.py +116 -0
- cut_fx/engines/shader/__init__.py +95 -0
- cut_fx/engines/shader/context.py +75 -0
- cut_fx/engines/shader/glsl/dissolve.glsl +66 -0
- cut_fx/engines/shader/glsl/lens_flare.glsl +144 -0
- cut_fx/engines/shader/glsl/refraction.glsl +89 -0
- cut_fx/engines/shader/glsl/warp.glsl +90 -0
- cut_fx/engines/shader/runner.py +114 -0
- cut_fx/exceptions.py +43 -0
- cut_fx/hardware/__init__.py +15 -0
- cut_fx/hardware/ffmpeg_caps.py +104 -0
- cut_fx/hardware/gpu_caps.py +37 -0
- cut_fx/resolver.py +172 -0
- cut_fx/schema/__init__.py +15 -0
- cut_fx/schema/generator.py +99 -0
- cut_fx/schema/parser.py +64 -0
- cut_fx-0.1.2.dist-info/METADATA +420 -0
- cut_fx-0.1.2.dist-info/RECORD +61 -0
- cut_fx-0.1.2.dist-info/WHEEL +4 -0
- cut_fx-0.1.2.dist-info/entry_points.txt +2 -0
- 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
|
cut_fx/beats/__init__.py
ADDED
|
@@ -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"]
|
cut_fx/beats/detector.py
ADDED
|
@@ -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"]
|
cut_fx/catalog/loader.py
ADDED
|
@@ -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
|