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.
- caramelldansen-1.0.0/LICENSE +21 -0
- caramelldansen-1.0.0/PKG-INFO +151 -0
- caramelldansen-1.0.0/README.md +121 -0
- caramelldansen-1.0.0/cache.py +342 -0
- caramelldansen-1.0.0/caramelldansen.egg-info/PKG-INFO +151 -0
- caramelldansen-1.0.0/caramelldansen.egg-info/SOURCES.txt +28 -0
- caramelldansen-1.0.0/caramelldansen.egg-info/dependency_links.txt +1 -0
- caramelldansen-1.0.0/caramelldansen.egg-info/entry_points.txt +2 -0
- caramelldansen-1.0.0/caramelldansen.egg-info/requires.txt +3 -0
- caramelldansen-1.0.0/caramelldansen.egg-info/top_level.txt +6 -0
- caramelldansen-1.0.0/download.py +208 -0
- caramelldansen-1.0.0/main.py +294 -0
- caramelldansen-1.0.0/platforms/__init__.py +39 -0
- caramelldansen-1.0.0/platforms/base.py +136 -0
- caramelldansen-1.0.0/platforms/linux/__init__.py +34 -0
- caramelldansen-1.0.0/platforms/linux/audio.py +309 -0
- caramelldansen-1.0.0/platforms/linux/input.py +94 -0
- caramelldansen-1.0.0/platforms/linux/terminal.py +72 -0
- caramelldansen-1.0.0/platforms/macos/__init__.py +34 -0
- caramelldansen-1.0.0/platforms/macos/audio.py +307 -0
- caramelldansen-1.0.0/platforms/macos/input.py +95 -0
- caramelldansen-1.0.0/platforms/macos/terminal.py +72 -0
- caramelldansen-1.0.0/platforms/windows/__init__.py +34 -0
- caramelldansen-1.0.0/platforms/windows/audio.py +255 -0
- caramelldansen-1.0.0/platforms/windows/input.py +64 -0
- caramelldansen-1.0.0/platforms/windows/terminal.py +79 -0
- caramelldansen-1.0.0/pyproject.toml +46 -0
- caramelldansen-1.0.0/renderer.py +359 -0
- caramelldansen-1.0.0/setup.cfg +4 -0
- 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
|
+
[](https://pypi.org/project/caramelldansen/)
|
|
34
|
+
[](https://pypi.org/project/caramelldansen/)
|
|
35
|
+
[](https://opensource.org/licenses/MIT)
|
|
36
|
+
[](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
|
+
[](https://pypi.org/project/caramelldansen/)
|
|
4
|
+
[](https://pypi.org/project/caramelldansen/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](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()
|