caramelldansen 1.0.0__tar.gz

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 (30) hide show
  1. caramelldansen-1.0.0/LICENSE +21 -0
  2. caramelldansen-1.0.0/PKG-INFO +151 -0
  3. caramelldansen-1.0.0/README.md +121 -0
  4. caramelldansen-1.0.0/cache.py +342 -0
  5. caramelldansen-1.0.0/caramelldansen.egg-info/PKG-INFO +151 -0
  6. caramelldansen-1.0.0/caramelldansen.egg-info/SOURCES.txt +28 -0
  7. caramelldansen-1.0.0/caramelldansen.egg-info/dependency_links.txt +1 -0
  8. caramelldansen-1.0.0/caramelldansen.egg-info/entry_points.txt +2 -0
  9. caramelldansen-1.0.0/caramelldansen.egg-info/requires.txt +3 -0
  10. caramelldansen-1.0.0/caramelldansen.egg-info/top_level.txt +6 -0
  11. caramelldansen-1.0.0/download.py +208 -0
  12. caramelldansen-1.0.0/main.py +294 -0
  13. caramelldansen-1.0.0/platforms/__init__.py +39 -0
  14. caramelldansen-1.0.0/platforms/base.py +136 -0
  15. caramelldansen-1.0.0/platforms/linux/__init__.py +34 -0
  16. caramelldansen-1.0.0/platforms/linux/audio.py +309 -0
  17. caramelldansen-1.0.0/platforms/linux/input.py +94 -0
  18. caramelldansen-1.0.0/platforms/linux/terminal.py +72 -0
  19. caramelldansen-1.0.0/platforms/macos/__init__.py +34 -0
  20. caramelldansen-1.0.0/platforms/macos/audio.py +307 -0
  21. caramelldansen-1.0.0/platforms/macos/input.py +95 -0
  22. caramelldansen-1.0.0/platforms/macos/terminal.py +72 -0
  23. caramelldansen-1.0.0/platforms/windows/__init__.py +34 -0
  24. caramelldansen-1.0.0/platforms/windows/audio.py +255 -0
  25. caramelldansen-1.0.0/platforms/windows/input.py +64 -0
  26. caramelldansen-1.0.0/platforms/windows/terminal.py +79 -0
  27. caramelldansen-1.0.0/pyproject.toml +46 -0
  28. caramelldansen-1.0.0/renderer.py +359 -0
  29. caramelldansen-1.0.0/setup.cfg +4 -0
  30. caramelldansen-1.0.0/ui.py +81 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ohualtex
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: caramelldansen
3
+ Version: 1.0.0
4
+ Summary: Caramell Dansen Terminal Player — High Performance TrueColor & Unicode with Dansen Loop
5
+ Author: Ohualtex
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Ohualtex/caramelldansen
8
+ Project-URL: Repository, https://github.com/Ohualtex/caramelldansen.git
9
+ Project-URL: Bug Tracker, https://github.com/Ohualtex/caramelldansen/issues
10
+ Keywords: caramelldansen,terminal,ascii-art,truecolor,braille,cli
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Multimedia :: Video :: Display
22
+ Classifier: Topic :: Terminals
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: numpy>=1.20
27
+ Requires-Dist: pygame>=2.5.0
28
+ Requires-Dist: colorama>=0.4.6
29
+ Dynamic: license-file
30
+
31
+ # Caramelldansen in Terminal 🍭✨
32
+
33
+ [![PyPI version](https://img.shields.io/pypi/v/caramelldansen.svg)](https://pypi.org/project/caramelldansen/)
34
+ [![Python Version](https://img.shields.io/pypi/pyversions/caramelldansen.svg)](https://pypi.org/project/caramelldansen/)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
36
+ [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey.svg)](https://github.com/Ohualtex/caramelldansen)
37
+
38
+ High-performance, flicker-free, 24-bit TrueColor **Caramelldansen** terminal player featuring **Dansen Loop** mode!
39
+
40
+ Built with a modular **Platform Abstraction Layer (PAL)** and **Audio Master Clock** architecture, providing zero-configuration, cross-platform audio and rendering on **macOS, Windows, and Linux**.
41
+
42
+ ---
43
+
44
+ ## Features
45
+
46
+ - **24-bit TrueColor Rendering:**
47
+ - `halfblock` (Default): Uses half-block characters (`▀`, `▄`) with 24-bit TrueColor for near-square pixels and stunning visual fidelity.
48
+ - `color-ascii`: Classic ASCII character brightness ramp combined with full RGB color.
49
+ - `braille`: High-resolution Unicode Braille (2x4 dot matrix) rendering.
50
+ - `ascii`: Nostalgic monochrome ASCII shading.
51
+ - **Dansen Loop Mode (Infinite Loop):**
52
+ - Starts directly in the iconic Mai & Mii chorus dance sequence and loops seamlessly without stutter or glitches.
53
+ - Toggle between the dance loop and the full music video using the `L` key or CLI flags.
54
+ - **Audio Master Clock Engine:**
55
+ - Video rendering is strictly synchronized to hardware audio playback time for flawless rhythm alignment and zero audio-video drift.
56
+ - **Zero-CPU Binary Cache Engine (CDBP v1):**
57
+ - Streams from a pre-rendered, zlib-compressed RGB binary container (`caramell_dansen.bin`) for <1% CPU consumption and instantaneous seek times.
58
+ - **Automatic Asset Provisioning:**
59
+ - Installs in seconds as a featherlight (<40 KB) PyPI wheel! Video and audio assets are automatically downloaded on first launch with live progress bar and MD5 verification.
60
+ - **Flicker-Free Terminal Experience:**
61
+ - Uses Alternate Screen Buffer (`\033[?1049h`), cursor hiding, Win32 VT100 processing, and non-blocking I/O.
62
+ - **Responsive Sizing & Interactive Scrubber:**
63
+ - Automatically respects 16:9 aspect ratio, centers video in any terminal window, and renders an adaptive live HUD status bar.
64
+
65
+ ---
66
+
67
+ ## Controls
68
+
69
+ | Key | Action |
70
+ | :--- | :--- |
71
+ | **Space** | Pause / Resume |
72
+ | **M** | Cycle Render Mode (`halfblock` → `color-ascii` → `braille` → `ascii`) |
73
+ | **C** | Toggle Color Mode (TrueColor RGB ↔ Monochrome) |
74
+ | **L** | Toggle **Dansen Loop** mode |
75
+ | **Right Arrow ($\rightarrow$)** | Seek forward 5 seconds |
76
+ | **Left Arrow ($\leftarrow$)** | Seek backward 5 seconds |
77
+ | **R** | Restart playback |
78
+ | **Q / Esc** | Quit cleanly |
79
+
80
+ ---
81
+
82
+ ## Installation & Quick Start
83
+
84
+ ### Option 1: Install from PyPI (Recommended)
85
+
86
+ ```bash
87
+ pip install caramelldansen
88
+ ```
89
+
90
+ Run directly from any terminal:
91
+ ```bash
92
+ caramelldansen
93
+ ```
94
+
95
+ ### Option 2: Run from Source
96
+
97
+ ```bash
98
+ # Clone the repository
99
+ git clone https://github.com/Ohualtex/caramelldansen.git
100
+ cd caramelldansen
101
+
102
+ # Install dependencies
103
+ pip install -r requirements.txt
104
+
105
+ # Start playback (starts directly in Dansen Loop mode)
106
+ python main.py
107
+
108
+ # Play full music video from start
109
+ python main.py --full
110
+
111
+ # Select rendering mode
112
+ python main.py --mode color-ascii
113
+ ```
114
+
115
+ ---
116
+
117
+ ## CLI Options
118
+
119
+ ```text
120
+ usage: caramelldansen [-h] [--mode {halfblock,color-ascii,braille,ascii}] [--no-color] [--loop] [--full] [--no-audio] [--build-cache]
121
+
122
+ options:
123
+ -h, --help show this help message and exit
124
+ --mode {halfblock,color-ascii,braille,ascii}
125
+ Rendering mode (default: halfblock)
126
+ --no-color, --mono Disable TrueColor rendering and run in monochrome
127
+ --loop Start in infinite Dansen Loop mode (default: True)
128
+ --full Play full music video from start to finish
129
+ --no-audio Mute audio and run on internal clock
130
+ --build-cache Force rebuild CDBP binary cache
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Terminal Display Tips
136
+
137
+ ### macOS Terminal.app (Gapless Half-Block Display)
138
+ The default macOS `Terminal.app` adds vertical line leading (font spacing) between character rows. For a gapless, seamless display:
139
+ 1. Open **Terminal** → **Settings** (`Cmd + ,`) → **Profiles** → **Text**.
140
+ 2. Click **Change...** under Font.
141
+ 3. Set the **Line Spacing** slider to **`0.80`**.
142
+ 4. Alternatively, press **`M`** in-player to switch to **Braille mode** (which is naturally immune to line spacing), or use modern GPU-accelerated terminals like [Ghostty](https://ghostty.org/), [iTerm2](https://iterm2.com/), or [Kitty](https://sw.kovidgoyal.net/kitty/).
143
+
144
+ ### Windows Terminal
145
+ Runs out of the box in [Windows Terminal](https://github.com/microsoft/terminal), PowerShell, and Command Prompt with 24-bit TrueColor enabled via Win32 Virtual Terminal Processing.
146
+
147
+ ---
148
+
149
+ ## License
150
+
151
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,121 @@
1
+ # Caramelldansen in Terminal 🍭✨
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/caramelldansen.svg)](https://pypi.org/project/caramelldansen/)
4
+ [![Python Version](https://img.shields.io/pypi/pyversions/caramelldansen.svg)](https://pypi.org/project/caramelldansen/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey.svg)](https://github.com/Ohualtex/caramelldansen)
7
+
8
+ High-performance, flicker-free, 24-bit TrueColor **Caramelldansen** terminal player featuring **Dansen Loop** mode!
9
+
10
+ Built with a modular **Platform Abstraction Layer (PAL)** and **Audio Master Clock** architecture, providing zero-configuration, cross-platform audio and rendering on **macOS, Windows, and Linux**.
11
+
12
+ ---
13
+
14
+ ## Features
15
+
16
+ - **24-bit TrueColor Rendering:**
17
+ - `halfblock` (Default): Uses half-block characters (`▀`, `▄`) with 24-bit TrueColor for near-square pixels and stunning visual fidelity.
18
+ - `color-ascii`: Classic ASCII character brightness ramp combined with full RGB color.
19
+ - `braille`: High-resolution Unicode Braille (2x4 dot matrix) rendering.
20
+ - `ascii`: Nostalgic monochrome ASCII shading.
21
+ - **Dansen Loop Mode (Infinite Loop):**
22
+ - Starts directly in the iconic Mai & Mii chorus dance sequence and loops seamlessly without stutter or glitches.
23
+ - Toggle between the dance loop and the full music video using the `L` key or CLI flags.
24
+ - **Audio Master Clock Engine:**
25
+ - Video rendering is strictly synchronized to hardware audio playback time for flawless rhythm alignment and zero audio-video drift.
26
+ - **Zero-CPU Binary Cache Engine (CDBP v1):**
27
+ - Streams from a pre-rendered, zlib-compressed RGB binary container (`caramell_dansen.bin`) for <1% CPU consumption and instantaneous seek times.
28
+ - **Automatic Asset Provisioning:**
29
+ - Installs in seconds as a featherlight (<40 KB) PyPI wheel! Video and audio assets are automatically downloaded on first launch with live progress bar and MD5 verification.
30
+ - **Flicker-Free Terminal Experience:**
31
+ - Uses Alternate Screen Buffer (`\033[?1049h`), cursor hiding, Win32 VT100 processing, and non-blocking I/O.
32
+ - **Responsive Sizing & Interactive Scrubber:**
33
+ - Automatically respects 16:9 aspect ratio, centers video in any terminal window, and renders an adaptive live HUD status bar.
34
+
35
+ ---
36
+
37
+ ## Controls
38
+
39
+ | Key | Action |
40
+ | :--- | :--- |
41
+ | **Space** | Pause / Resume |
42
+ | **M** | Cycle Render Mode (`halfblock` → `color-ascii` → `braille` → `ascii`) |
43
+ | **C** | Toggle Color Mode (TrueColor RGB ↔ Monochrome) |
44
+ | **L** | Toggle **Dansen Loop** mode |
45
+ | **Right Arrow ($\rightarrow$)** | Seek forward 5 seconds |
46
+ | **Left Arrow ($\leftarrow$)** | Seek backward 5 seconds |
47
+ | **R** | Restart playback |
48
+ | **Q / Esc** | Quit cleanly |
49
+
50
+ ---
51
+
52
+ ## Installation & Quick Start
53
+
54
+ ### Option 1: Install from PyPI (Recommended)
55
+
56
+ ```bash
57
+ pip install caramelldansen
58
+ ```
59
+
60
+ Run directly from any terminal:
61
+ ```bash
62
+ caramelldansen
63
+ ```
64
+
65
+ ### Option 2: Run from Source
66
+
67
+ ```bash
68
+ # Clone the repository
69
+ git clone https://github.com/Ohualtex/caramelldansen.git
70
+ cd caramelldansen
71
+
72
+ # Install dependencies
73
+ pip install -r requirements.txt
74
+
75
+ # Start playback (starts directly in Dansen Loop mode)
76
+ python main.py
77
+
78
+ # Play full music video from start
79
+ python main.py --full
80
+
81
+ # Select rendering mode
82
+ python main.py --mode color-ascii
83
+ ```
84
+
85
+ ---
86
+
87
+ ## CLI Options
88
+
89
+ ```text
90
+ usage: caramelldansen [-h] [--mode {halfblock,color-ascii,braille,ascii}] [--no-color] [--loop] [--full] [--no-audio] [--build-cache]
91
+
92
+ options:
93
+ -h, --help show this help message and exit
94
+ --mode {halfblock,color-ascii,braille,ascii}
95
+ Rendering mode (default: halfblock)
96
+ --no-color, --mono Disable TrueColor rendering and run in monochrome
97
+ --loop Start in infinite Dansen Loop mode (default: True)
98
+ --full Play full music video from start to finish
99
+ --no-audio Mute audio and run on internal clock
100
+ --build-cache Force rebuild CDBP binary cache
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Terminal Display Tips
106
+
107
+ ### macOS Terminal.app (Gapless Half-Block Display)
108
+ The default macOS `Terminal.app` adds vertical line leading (font spacing) between character rows. For a gapless, seamless display:
109
+ 1. Open **Terminal** → **Settings** (`Cmd + ,`) → **Profiles** → **Text**.
110
+ 2. Click **Change...** under Font.
111
+ 3. Set the **Line Spacing** slider to **`0.80`**.
112
+ 4. Alternatively, press **`M`** in-player to switch to **Braille mode** (which is naturally immune to line spacing), or use modern GPU-accelerated terminals like [Ghostty](https://ghostty.org/), [iTerm2](https://iterm2.com/), or [Kitty](https://sw.kovidgoyal.net/kitty/).
113
+
114
+ ### Windows Terminal
115
+ Runs out of the box in [Windows Terminal](https://github.com/microsoft/terminal), PowerShell, and Command Prompt with 24-bit TrueColor enabled via Win32 Virtual Terminal Processing.
116
+
117
+ ---
118
+
119
+ ## License
120
+
121
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,342 @@
1
+ """
2
+ CDBP (Caramell Dansen Binary Pre-rendered) cache engine.
3
+
4
+ Provides zero-CPU, instant random-access playback of color Caramelldansen frames
5
+ by storing RGB video frames compressed with zlib alongside an indexed lookup table
6
+ and loop-point markers.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import struct
14
+ import subprocess
15
+ import time
16
+ import zlib
17
+ from typing import Callable, Optional, Tuple
18
+ import numpy as np
19
+
20
+ # Header specification (Total: 32 bytes)
21
+ # <4s : Magic bytes (b"CDBP")
22
+ # H : Version (uint16 = 1)
23
+ # H : Width (uint16)
24
+ # H : Height (uint16)
25
+ # f : FPS (float32)
26
+ # I : Total frames (uint32)
27
+ # B : Color format (1 = RGB24 zlib)
28
+ # I : Loop start frame (uint32)
29
+ # I : Loop end frame (uint32)
30
+ # 3s : Reserved padding
31
+ CDBP_MAGIC = b"CDBP"
32
+ CDBP_VERSION = 1
33
+ COLOR_FORMAT_RGB24 = 1
34
+
35
+ HEADER_STRUCT = struct.Struct("<4sHHHfIBII3s")
36
+ INDEX_ENTRY_STRUCT = struct.Struct("<IH") # offset (uint32), compressed_size (uint16)
37
+
38
+
39
+ class BinaryCacheError(Exception):
40
+ """Exception raised for cache corruption or builder errors."""
41
+ pass
42
+
43
+
44
+ class BinaryCacheBuilder:
45
+ """Encodes MP4 video frames into a high-speed CDBP binary container using ffmpeg."""
46
+
47
+ @staticmethod
48
+ def build_cache(
49
+ video_path: str,
50
+ output_path: str,
51
+ target_width: int = 160,
52
+ target_height: int = 90,
53
+ fps: float = 25.0,
54
+ loop_start_sec: float = 30.5,
55
+ loop_end_sec: float = 59.5,
56
+ progress_callback: Optional[Callable[[int, int, float], None]] = None,
57
+ ) -> None:
58
+ """
59
+ Extracts RGB frames from source video using ffmpeg, compresses each frame
60
+ with zlib, and builds an indexed CDBP binary cache file.
61
+ """
62
+ if not os.path.exists(video_path):
63
+ raise BinaryCacheError(f"Source video not found at '{video_path}'.")
64
+
65
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
66
+ temp_output = output_path + ".tmp"
67
+ if os.path.exists(temp_output):
68
+ try:
69
+ os.remove(temp_output)
70
+ except OSError:
71
+ pass
72
+
73
+ has_ffmpeg = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
74
+
75
+ if has_ffmpeg:
76
+ # Get video duration using ffprobe
77
+ probe_cmd = [
78
+ "ffprobe",
79
+ "-v",
80
+ "error",
81
+ "-show_entries",
82
+ "format=duration",
83
+ "-of",
84
+ "default=noprint_wrappers=1:nokey=1",
85
+ video_path,
86
+ ]
87
+ try:
88
+ dur_str = subprocess.check_output(probe_cmd, text=True).strip()
89
+ duration = float(dur_str)
90
+ except Exception as e:
91
+ raise BinaryCacheError(f"Failed to probe video duration: {e}")
92
+
93
+ total_frames = int(duration * fps)
94
+ loop_start_frame = int(loop_start_sec * fps)
95
+ loop_end_frame = int(loop_end_sec * fps)
96
+
97
+ frame_bytes_len = target_width * target_height * 3
98
+ ffmpeg_cmd = [
99
+ "ffmpeg",
100
+ "-v",
101
+ "error",
102
+ "-i",
103
+ video_path,
104
+ "-vf",
105
+ f"scale={target_width}:{target_height}",
106
+ "-r",
107
+ str(fps),
108
+ "-f",
109
+ "rawvideo",
110
+ "-pix_fmt",
111
+ "rgb24",
112
+ "-",
113
+ ]
114
+
115
+ proc = subprocess.Popen(
116
+ ffmpeg_cmd,
117
+ stdout=subprocess.PIPE,
118
+ stderr=subprocess.PIPE,
119
+ bufsize=frame_bytes_len * 4,
120
+ )
121
+
122
+ index_entries: list[tuple[int, int]] = []
123
+ start_time = time.perf_counter()
124
+
125
+ try:
126
+ with open(temp_output, "wb") as f:
127
+ f.write(b"\x00" * HEADER_STRUCT.size)
128
+ current_frame = 0
129
+ while True:
130
+ raw_frame = proc.stdout.read(frame_bytes_len)
131
+ if not raw_frame or len(raw_frame) < frame_bytes_len:
132
+ break
133
+
134
+ compressed = zlib.compress(raw_frame, level=6)
135
+ offset = f.tell()
136
+ comp_size = len(compressed)
137
+
138
+ if comp_size > 65535:
139
+ raise BinaryCacheError(
140
+ f"Compressed frame {current_frame} exceeded uint16 max size ({comp_size} bytes)."
141
+ )
142
+
143
+ f.write(compressed)
144
+ index_entries.append((offset, comp_size))
145
+ current_frame += 1
146
+
147
+ if progress_callback and (current_frame % 25 == 0 or current_frame == total_frames):
148
+ elapsed = time.perf_counter() - start_time
149
+ progress_callback(current_frame, total_frames, elapsed)
150
+
151
+ actual_total_frames = len(index_entries)
152
+ if actual_total_frames == 0:
153
+ raise BinaryCacheError("No frames were extracted from the video stream.")
154
+
155
+ index_table_offset = f.tell()
156
+ for offset, comp_size in index_entries:
157
+ f.write(INDEX_ENTRY_STRUCT.pack(offset, comp_size))
158
+
159
+ f.seek(0)
160
+ f.write(
161
+ HEADER_STRUCT.pack(
162
+ CDBP_MAGIC,
163
+ CDBP_VERSION,
164
+ target_width,
165
+ target_height,
166
+ fps,
167
+ actual_total_frames,
168
+ COLOR_FORMAT_RGB24,
169
+ loop_start_frame,
170
+ loop_end_frame,
171
+ b"\x00" * 3,
172
+ )
173
+ )
174
+ finally:
175
+ if proc.poll() is None:
176
+ proc.terminate()
177
+ proc.wait()
178
+
179
+ else:
180
+ # Fallback to OpenCV (cv2)
181
+ try:
182
+ import cv2
183
+ except ImportError:
184
+ raise BinaryCacheError(
185
+ "Neither ffmpeg/ffprobe CLI tools nor opencv-python (cv2) is available to process video."
186
+ )
187
+
188
+ cap = cv2.VideoCapture(video_path)
189
+ if not cap.isOpened():
190
+ raise BinaryCacheError(f"OpenCV failed to open video file at '{video_path}'.")
191
+
192
+ src_fps = cap.get(cv2.CAP_PROP_FPS) or fps
193
+ src_frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT)
194
+ duration = src_frame_count / max(1.0, src_fps)
195
+ total_frames = int(duration * fps)
196
+
197
+ loop_start_frame = int(loop_start_sec * fps)
198
+ loop_end_frame = int(loop_end_sec * fps)
199
+
200
+ index_entries = []
201
+ start_time = time.perf_counter()
202
+
203
+ try:
204
+ with open(temp_output, "wb") as f:
205
+ f.write(b"\x00" * HEADER_STRUCT.size)
206
+ current_frame = 0
207
+
208
+ while True:
209
+ ret, frame = cap.read()
210
+ if not ret or frame is None:
211
+ break
212
+
213
+ resized = cv2.resize(frame, (target_width, target_height), interpolation=cv2.INTER_AREA)
214
+ rgb_frame = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
215
+ raw_bytes = rgb_frame.tobytes()
216
+
217
+ compressed = zlib.compress(raw_bytes, level=6)
218
+ offset = f.tell()
219
+ comp_size = len(compressed)
220
+
221
+ if comp_size > 65535:
222
+ raise BinaryCacheError(
223
+ f"Compressed frame {current_frame} exceeded uint16 max size ({comp_size} bytes)."
224
+ )
225
+
226
+ f.write(compressed)
227
+ index_entries.append((offset, comp_size))
228
+ current_frame += 1
229
+
230
+ if progress_callback and (current_frame % 25 == 0 or current_frame == total_frames):
231
+ elapsed = time.perf_counter() - start_time
232
+ progress_callback(current_frame, total_frames, elapsed)
233
+
234
+ actual_total_frames = len(index_entries)
235
+ if actual_total_frames == 0:
236
+ raise BinaryCacheError("No frames were extracted from OpenCV stream.")
237
+
238
+ index_table_offset = f.tell()
239
+ for offset, comp_size in index_entries:
240
+ f.write(INDEX_ENTRY_STRUCT.pack(offset, comp_size))
241
+
242
+ f.seek(0)
243
+ f.write(
244
+ HEADER_STRUCT.pack(
245
+ CDBP_MAGIC,
246
+ CDBP_VERSION,
247
+ target_width,
248
+ target_height,
249
+ fps,
250
+ actual_total_frames,
251
+ COLOR_FORMAT_RGB24,
252
+ loop_start_frame,
253
+ loop_end_frame,
254
+ b"\x00" * 3,
255
+ )
256
+ )
257
+ finally:
258
+ cap.release()
259
+
260
+ # Atomic rename
261
+ if os.path.exists(output_path):
262
+ os.remove(output_path)
263
+ os.rename(temp_output, output_path)
264
+
265
+
266
+ class BinaryCacheReader:
267
+ """Reads CDBP binary cache files with instant seek and low memory overhead."""
268
+
269
+ def __init__(self, cache_path: str):
270
+ self.cache_path = os.path.abspath(cache_path)
271
+ if not os.path.exists(self.cache_path):
272
+ raise BinaryCacheError(f"Cache file not found at '{self.cache_path}'.")
273
+
274
+ self._file = open(self.cache_path, "rb")
275
+ header_data = self._file.read(HEADER_STRUCT.size)
276
+ if len(header_data) < HEADER_STRUCT.size:
277
+ raise BinaryCacheError("Incomplete cache header.")
278
+
279
+ (
280
+ magic,
281
+ version,
282
+ self.width,
283
+ self.height,
284
+ self.fps,
285
+ self.total_frames,
286
+ self.color_format,
287
+ self.loop_start_frame,
288
+ self.loop_end_frame,
289
+ _,
290
+ ) = HEADER_STRUCT.unpack(header_data)
291
+
292
+ if magic != CDBP_MAGIC:
293
+ raise BinaryCacheError(f"Invalid magic bytes '{magic!r}', expected '{CDBP_MAGIC!r}'.")
294
+ if version != CDBP_VERSION:
295
+ raise BinaryCacheError(f"Unsupported CDBP version {version}.")
296
+
297
+ self.duration = float(self.total_frames) / float(self.fps) if self.fps > 0 else 0.0
298
+ self.loop_start_sec = float(self.loop_start_frame) / float(self.fps) if self.fps > 0 else 0.0
299
+ self.loop_end_sec = float(self.loop_end_frame) / float(self.fps) if self.fps > 0 else self.duration
300
+
301
+ # Read index table
302
+ index_table_size = self.total_frames * INDEX_ENTRY_STRUCT.size
303
+ self._file.seek(-index_table_size, os.SEEK_END)
304
+ index_data = self._file.read(index_table_size)
305
+ if len(index_data) < index_table_size:
306
+ raise BinaryCacheError("Incomplete index table.")
307
+
308
+ # Unpack index table: offsets (uint32) and sizes (uint16)
309
+ table = np.frombuffer(index_data, dtype=[("offset", "<u4"), ("size", "<u2")])
310
+ self._offsets = table["offset"]
311
+ self._sizes = table["size"]
312
+
313
+ def get_frame(self, frame_index: int) -> np.ndarray:
314
+ """Retrieves and decompresses an RGB frame as numpy array (H, W, 3)."""
315
+ idx = max(0, min(self.total_frames - 1, frame_index))
316
+ offset = int(self._offsets[idx])
317
+ size = int(self._sizes[idx])
318
+
319
+ self._file.seek(offset)
320
+ compressed = self._file.read(size)
321
+ raw_rgb = zlib.decompress(compressed)
322
+
323
+ frame = np.frombuffer(raw_rgb, dtype=np.uint8).reshape((self.height, self.width, 3))
324
+ return frame
325
+
326
+ def get_frame_for_time(self, seconds: float) -> Tuple[np.ndarray, int]:
327
+ """Returns the frame corresponding to the specified playback time."""
328
+ seconds = max(0.0, seconds)
329
+ idx = int(seconds * self.fps)
330
+ idx = min(self.total_frames - 1, idx)
331
+ return self.get_frame(idx), idx
332
+
333
+ def close(self) -> None:
334
+ """Closes the underlying binary file."""
335
+ if hasattr(self, "_file") and self._file:
336
+ self._file.close()
337
+
338
+ def __enter__(self) -> "BinaryCacheReader":
339
+ return self
340
+
341
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
342
+ self.close()