PDASC 0.1.0__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.
core/audio_player.py ADDED
@@ -0,0 +1,150 @@
1
+ import pyaudio
2
+ import threading
3
+ import numpy as np
4
+ import warnings
5
+ from typing import Generator
6
+
7
+ class AudioPlayer:
8
+ def __init__(self, audio_gen: Generator[np.ndarray, None, None],
9
+ samplerate=44100, channels=2, blocksize=1024,
10
+ enable_audio=True):
11
+ self.audio_gen = audio_gen
12
+ self.samplerate = samplerate
13
+ self.channels = channels
14
+ self.blocksize = blocksize
15
+ self.buffer = np.empty((0, channels), dtype=np.float32)
16
+ self.lock = threading.Lock()
17
+ self.done = False
18
+ self.stream = None
19
+ self.feed_thread = None
20
+ self.stopped = False
21
+ self.enable_audio = enable_audio
22
+ self.p = None
23
+
24
+ def _check_audio_available(self):
25
+ """Quick check if audio is available - full init happens in start()"""
26
+ try:
27
+ # Just do a minimal check - don't enumerate all devices
28
+ p = pyaudio.PyAudio()
29
+ # Quick check: just verify we can get default output device
30
+ try:
31
+ default_output = p.get_default_output_device_info()
32
+ has_output = int(default_output['maxOutputChannels']) > 0
33
+ except Exception:
34
+ has_output = False
35
+
36
+ p.terminate()
37
+
38
+ if not has_output:
39
+ warnings.warn("Audio disabled: no output devices found")
40
+ return False
41
+
42
+ return True
43
+
44
+ except Exception as e:
45
+ warnings.warn(f"Audio disabled: {e}")
46
+ return False
47
+
48
+ def callback(self, in_data, frame_count, time_info, status):
49
+ """PyAudio callback function"""
50
+ with self.lock:
51
+ if len(self.buffer) >= frame_count:
52
+ data = self.buffer[:frame_count]
53
+ self.buffer = self.buffer[frame_count:]
54
+ else:
55
+ # Not enough data, pad with zeros
56
+ data = np.zeros((frame_count, self.channels), dtype=np.float32)
57
+ if len(self.buffer) > 0:
58
+ data[:len(self.buffer)] = self.buffer
59
+ self.buffer = np.empty((0, self.channels), dtype=np.float32)
60
+
61
+ return (data.tobytes(), pyaudio.paContinue)
62
+
63
+ def start(self):
64
+ if not self.enable_audio:
65
+ self._start_silent_consumer()
66
+ return
67
+
68
+ try:
69
+ self.p = pyaudio.PyAudio()
70
+ self.stream = self.p.open(
71
+ format=pyaudio.paFloat32,
72
+ channels=self.channels,
73
+ rate=self.samplerate,
74
+ output=True,
75
+ frames_per_buffer=self.blocksize,
76
+ stream_callback=self.callback
77
+ )
78
+ self.stream.start_stream()
79
+ except Exception as e:
80
+ warnings.warn(f"Could not start audio stream: {e}")
81
+ self.enable_audio = False
82
+ if self.p:
83
+ self.p.terminate()
84
+ self.p = None
85
+ self._start_silent_consumer()
86
+ return
87
+
88
+ def feed():
89
+ try:
90
+ for chunk in self.audio_gen:
91
+ if self.stopped:
92
+ break
93
+ if not isinstance(chunk, np.ndarray):
94
+ chunk = np.array(chunk, dtype=np.float32)
95
+ if chunk.dtype != np.float32:
96
+ chunk = chunk.astype(np.float32) / 32768.0
97
+ if chunk.ndim == 1 and self.channels == 2:
98
+ chunk = np.stack([chunk, chunk], axis=-1)
99
+ elif chunk.ndim == 2 and chunk.shape[1] != self.channels:
100
+ if chunk.shape[1] == 1:
101
+ chunk = np.repeat(chunk, self.channels, axis=1)
102
+ else:
103
+ warnings.warn("Audio chunk channel mismatch; skipping")
104
+ continue
105
+ with self.lock:
106
+ self.buffer = np.concatenate([self.buffer, chunk])
107
+ except Exception as e:
108
+ warnings.warn(f"Audio feed thread exception: {e}")
109
+ finally:
110
+ self.done = True
111
+
112
+ self.feed_thread = threading.Thread(target=feed, daemon=True)
113
+ self.feed_thread.start()
114
+
115
+ def _start_silent_consumer(self):
116
+ """Consume generator without audio output"""
117
+ def feed_silent():
118
+ try:
119
+ for _ in self.audio_gen:
120
+ if self.stopped:
121
+ break
122
+ except Exception as e:
123
+ warnings.warn(f"Audio generator exception: {e}")
124
+ finally:
125
+ self.done = True
126
+
127
+ self.feed_thread = threading.Thread(target=feed_silent, daemon=True)
128
+ self.feed_thread.start()
129
+
130
+ def stop(self):
131
+ self.stopped = True
132
+ if self.feed_thread and self.feed_thread.is_alive():
133
+ self.feed_thread.join(timeout=0.5)
134
+ if self.stream:
135
+ try:
136
+ import time
137
+ time.sleep(0.1)
138
+ if self.stream.is_active():
139
+ self.stream.stop_stream()
140
+ time.sleep(0.05)
141
+ self.stream.close()
142
+ except Exception:
143
+ pass
144
+ self.stream = None
145
+ if self.p:
146
+ try:
147
+ self.p.terminate()
148
+ except Exception:
149
+ pass
150
+ self.p = None
@@ -0,0 +1,69 @@
1
+ from PIL import Image, ImageDraw, ImageFont
2
+ import numpy as np
3
+ import os
4
+
5
+ def generate_color_ramp(font_size: int = 32, image_size: int = 48, font_path: str = "CascadiaMono.ttf", chars: list[str] = [chr(i) for i in range(32, 127)]):
6
+ if not os.path.exists(font_path):
7
+ raise FileNotFoundError(f"Font not found at {font_path}")
8
+
9
+ font = ImageFont.truetype(font_path, font_size)
10
+ results = []
11
+
12
+ for char in chars:
13
+ img = Image.new("L", (image_size, image_size), 255)
14
+ draw = ImageDraw.Draw(img)
15
+
16
+ draw.text((0, 0), char, fill=0, font=font)
17
+
18
+ img_data = np.asarray(img, dtype=np.float32) / 255.0
19
+ total_luminance = img_data.mean()
20
+
21
+ results.append((char, total_luminance))
22
+
23
+ lums = [lum for _, lum in results]
24
+ min_lum = min(lums)
25
+ max_lum = max(lums)
26
+
27
+ # Normalize 1-0
28
+ results = [
29
+ (char, 1.0 - (lum - min_lum) / (max_lum - min_lum))
30
+ for char, lum in results
31
+ ]
32
+
33
+ return results
34
+
35
+ def get_charmap(color_ramp: list[tuple[str, float]], levels: int = 8):
36
+ if levels < 2:
37
+ raise ValueError("levels must be >= 2")
38
+
39
+ if levels >= len(color_ramp):
40
+ color_ramp.sort(key=lambda x: x[1])
41
+ return "".join([char[0] for char in color_ramp])
42
+
43
+ quantized_values = [i/levels for i in range(levels + 1)]
44
+
45
+ out_ramp = []
46
+ for value in quantized_values:
47
+ best_char = (color_ramp[0][0], abs(color_ramp[0][1] - value))
48
+ for char, lum in color_ramp:
49
+ distance = abs(value - lum)
50
+ if distance < best_char[1] and char not in out_ramp:
51
+ best_char = (char, distance)
52
+ out_ramp.append(best_char[0])
53
+ return "".join(out_ramp)
54
+
55
+ def render_charmap(charmap: str, font_path="font8x8.ttf", font_size=8, padding=0):
56
+ char_width = font_size
57
+ char_height = font_size
58
+ w = len(charmap) * (char_width + padding)
59
+ h = char_height + 2 * padding
60
+
61
+ img = Image.new("L", (w, h), 0) # black background
62
+ draw = ImageDraw.Draw(img)
63
+ font = ImageFont.truetype(font_path, font_size)
64
+
65
+ for i, ch in enumerate(charmap):
66
+ x = i * (char_width + padding) + padding
67
+ y = padding
68
+ draw.text((x, y), ch, fill=255, font=font)
69
+ return img
core/utils.py ADDED
@@ -0,0 +1,26 @@
1
+ import numpy as np
2
+
3
+ def pack_int24(color: tuple[int, int, int]) -> int:
4
+ return (color[0] << 16) | (color[1] << 8) | color[2]
5
+
6
+ def unpack_int24(packed: int) -> tuple[int, int, int]:
7
+ return ((packed >> 16) & 0xFF, (packed >> 8) & 0xFF, packed & 0xFF)
8
+
9
+ def pack_int24_chunk(rgb: np.ndarray) -> np.ndarray:
10
+ r = rgb[..., 0].astype(np.uint32)
11
+ g = rgb[..., 1].astype(np.uint32)
12
+ b = rgb[..., 2].astype(np.uint32)
13
+ return (r << 16) | (g << 8) | b
14
+
15
+ def unpack_int24_array(colors : np.ndarray):
16
+ r = (colors >> 16) & 0xFF
17
+ g = (colors >> 8) & 0xFF
18
+ b = colors & 0xFF
19
+ return r, g, b
20
+
21
+ def format_file_size(size_bytes: float) -> str:
22
+ for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
23
+ if size_bytes < 1024.0:
24
+ return f"{size_bytes:.2f} {unit}"
25
+ size_bytes /= 1024.0
26
+ return f"{size_bytes:.2f} PB"
@@ -0,0 +1,220 @@
1
+ from PIL import Image
2
+ import moderngl
3
+ import numpy as np
4
+ from .generate_color_ramp import generate_color_ramp, get_charmap, render_charmap
5
+ from .video_extractor import extract_video
6
+ import time
7
+ import subprocess
8
+ import os
9
+
10
+ class VideoAsciiConverter:
11
+ def __init__(self, fragment_shader_src: str, ascii_img: Image.Image, colored: bool = True):
12
+ self.colored = colored
13
+
14
+ self.ctx = moderngl.create_standalone_context()
15
+
16
+ vertices = np.array(
17
+ [-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0],
18
+ dtype="f4",
19
+ )
20
+
21
+ vertex_shader = """
22
+ #version 330
23
+ in vec2 in_vert;
24
+ out vec2 uv;
25
+ void main() {
26
+ uv = in_vert * 0.5 + 0.5;
27
+ gl_Position = vec4(in_vert, 0.0, 1.0);
28
+ }
29
+ """
30
+
31
+ self.prog = self.ctx.program(
32
+ vertex_shader=vertex_shader, fragment_shader=fragment_shader_src
33
+ )
34
+
35
+ self.prog["colored"] = self.colored
36
+
37
+ width, height = ascii_img.size
38
+ ascii_img_data = ascii_img.tobytes()
39
+ self.ascii_tex = self.ctx.texture((width, height), 1, ascii_img_data)
40
+ self.ascii_tex.filter = (moderngl.NEAREST, moderngl.NEAREST)
41
+ self.ascii_tex.use(1)
42
+ self.prog["ascii_map"] = 1
43
+
44
+ self.vbo = self.ctx.buffer(vertices.tobytes())
45
+ self.vao = self.ctx.simple_vertex_array(self.prog, self.vbo, "in_vert")
46
+
47
+ # Pre-allocate reusable resources
48
+ self.current_texture = None
49
+ self.current_framebuffer = None
50
+ self.current_size = None
51
+
52
+ def _ensure_resources(self, width, height):
53
+ """Reuse texture and framebuffer if size matches"""
54
+ if self.current_size != (width, height):
55
+ # Release old resources
56
+ if self.current_texture:
57
+ self.current_texture.release()
58
+ if self.current_framebuffer:
59
+ self.current_framebuffer.release()
60
+
61
+ # Create new ones
62
+ self.current_texture = self.ctx.texture((width, height), 4)
63
+ self.current_framebuffer = self.ctx.simple_framebuffer((width, height))
64
+ self.current_size = (width, height)
65
+
66
+ def process_frame(self, image: Image.Image):
67
+ try:
68
+ # Avoid conversion if already RGBA
69
+ if image.mode != "RGBA":
70
+ img = image.convert("RGBA")
71
+ else:
72
+ img = image
73
+
74
+ input_width, input_height = img.size
75
+
76
+ # Reuse texture and framebuffer
77
+ self._ensure_resources(input_width, input_height)
78
+
79
+ if not self.current_texture or not self.current_framebuffer:
80
+ raise ValueError("Failed to create texture and framebuffer")
81
+
82
+ # Write directly to existing texture instead of creating new one
83
+ self.current_texture.write(img.tobytes())
84
+ self.current_texture.use(0)
85
+ self.prog["tex"] = 0
86
+
87
+ self.current_framebuffer.use()
88
+ self.vao.render(moderngl.TRIANGLE_STRIP)
89
+
90
+ # Read once into bytes
91
+ data = self.current_framebuffer.read(components=4)
92
+ result_img = Image.frombytes("RGBA", (input_width, input_height), data)
93
+
94
+ return result_img
95
+
96
+ except Exception as e:
97
+ print(f"\nError processing image:")
98
+ print(e)
99
+ return None
100
+
101
+ def cleanup(self):
102
+ """Call this when done processing all frames"""
103
+ if self.current_texture:
104
+ self.current_texture.release()
105
+ if self.current_framebuffer:
106
+ self.current_framebuffer.release()
107
+
108
+
109
+ def feed_audio(audio_gen, ffmpeg_process):
110
+ """Thread function to feed audio to ffmpeg"""
111
+ try:
112
+ if ffmpeg_process.stdin and audio_gen:
113
+ for audio_chunk in audio_gen:
114
+ ffmpeg_process.stdin.write(audio_chunk.tobytes())
115
+ except (OSError, BrokenPipeError):
116
+ pass
117
+
118
+ def process_video(converter: VideoAsciiConverter, video_path: str, output_path: str = "", audio: bool = True) -> str:
119
+ fps, frame_gen, audio_gen = extract_video(video_path)
120
+
121
+ if output_path == "":
122
+ parts = os.path.splitext(os.path.basename(video_path))
123
+ output_path = f"ascii_{parts[0]}.asc{parts[-1]}"
124
+
125
+ if os.path.dirname(output_path) and not os.path.exists(os.path.dirname(output_path)):
126
+ os.mkdir(os.path.dirname(output_path))
127
+
128
+ # Build ffmpeg command based on audio parameter
129
+ ffmpeg_cmd = [
130
+ 'ffmpeg',
131
+ '-y',
132
+ # Video input from stdin
133
+ '-f', 'rawvideo',
134
+ '-vcodec', 'rawvideo',
135
+ '-s', '1280x720', # Will be updated after first frame
136
+ '-pix_fmt', 'rgba',
137
+ '-r', str(fps),
138
+ '-i', '-',
139
+ ]
140
+
141
+ if audio:
142
+ # Audio input from the original file (force MP4 format)
143
+ ffmpeg_cmd.extend([
144
+ '-f', 'mp4',
145
+ '-i', video_path,
146
+ # Map video from stdin, audio from file
147
+ '-map', '0:v',
148
+ '-map', '1:a?',
149
+ ])
150
+
151
+ # Video and audio encoding settings
152
+ ffmpeg_cmd.extend([
153
+ '-c:v', 'libx264',
154
+ '-pix_fmt', 'yuv420p',
155
+ '-preset', 'medium',
156
+ '-crf', '23',
157
+ ])
158
+
159
+ if audio:
160
+ ffmpeg_cmd.extend([
161
+ '-c:a', 'aac',
162
+ '-b:a', '192k',
163
+ '-shortest',
164
+ ])
165
+
166
+ ffmpeg_cmd.extend([
167
+ '-f', 'mp4', # Force MP4 output format
168
+ output_path
169
+ ])
170
+
171
+ ffmpeg_process = None
172
+ start = time.time()
173
+ frame_count = 0
174
+
175
+ try:
176
+ for frame in frame_gen:
177
+ out_frame = converter.process_frame(frame)
178
+
179
+ if out_frame is None:
180
+ continue
181
+
182
+ # Initialize ffmpeg process after first frame to get correct dimensions
183
+ if ffmpeg_process is None:
184
+ width, height = out_frame.size
185
+ ffmpeg_cmd[7] = f'{width}x{height}'
186
+ ffmpeg_process = subprocess.Popen(
187
+ ffmpeg_cmd,
188
+ stdin=subprocess.PIPE,
189
+ stdout=subprocess.DEVNULL,
190
+ stderr=subprocess.DEVNULL
191
+ )
192
+
193
+ # Write frame to ffmpeg stdin
194
+ try:
195
+ if ffmpeg_process.stdin:
196
+ ffmpeg_process.stdin.write(out_frame.tobytes())
197
+ except (OSError, BrokenPipeError) as e:
198
+ print(f"\nError writing to ffmpeg: {e}")
199
+ break
200
+
201
+ frame_count += 1
202
+
203
+ if frame_count % 30 == 0:
204
+ print(f"Processed {frame_count} frames...", end='\r')
205
+
206
+ finally:
207
+ # Close ffmpeg stdin and wait for process to finish
208
+ if ffmpeg_process:
209
+ if ffmpeg_process.stdin:
210
+ try:
211
+ ffmpeg_process.stdin.close()
212
+ except:
213
+ pass
214
+ ffmpeg_process.wait()
215
+
216
+ converter.cleanup()
217
+ elapsed = time.time() - start
218
+ print(f"\nCompleted: {elapsed:.2f}s for {frame_count} frames ({frame_count/elapsed:.1f} fps)")
219
+ print(f"Output saved to: {output_path}")
220
+ return output_path
@@ -0,0 +1,128 @@
1
+ import subprocess
2
+ import numpy as np
3
+ from PIL import Image
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Generator, Tuple, Dict, Any, Optional
7
+
8
+ def extract_video(video_path: str, audio_rate=44100) -> Tuple[float, Generator[Image.Image, None, None], Optional[Generator[np.ndarray, None, None]]]:
9
+ """
10
+ Extracts video frames and audio from a video file as generators.
11
+
12
+ Args:
13
+ video_path: Path to video
14
+ audio_rate: Sample rate for audio (default 44100 Hz)
15
+
16
+ Returns:
17
+ fps: float
18
+ frame_gen: generator yielding PIL.Image frames
19
+ audio_gen: generator yielding np.ndarray audio chunks (shape: [N, 2]) or None if no audio
20
+ """
21
+
22
+ video_file = Path(video_path)
23
+ if not video_file.is_file():
24
+ raise FileNotFoundError(f"{video_path} does not exist")
25
+
26
+ # Probe video resolution and FPS
27
+ probe_cmd = [
28
+ "ffprobe",
29
+ "-v", "error",
30
+ "-select_streams", "v:0",
31
+ "-show_entries", "stream=width,height,r_frame_rate",
32
+ "-of", "json",
33
+ str(video_file)
34
+ ]
35
+ probe = subprocess.run(probe_cmd, capture_output=True, text=True)
36
+ info: Dict[str, Any] = json.loads(probe.stdout or "{}")
37
+ streams = info.get('streams', [])
38
+ if not streams:
39
+ raise RuntimeError("No video streams found or FFprobe failed")
40
+
41
+ stream: Dict[str, Any] = streams[0]
42
+ orig_width = int(stream['width'])
43
+ orig_height = int(stream['height'])
44
+
45
+ # Parse FPS
46
+ num, den = map(int, stream.get('r_frame_rate', '30/1').split('/'))
47
+ fps = round(num / den)
48
+
49
+ # Downscale to 720p if necessary
50
+ if orig_height > 720:
51
+ height = 720
52
+ width = int(orig_width * (720 / orig_height))
53
+ # Make width even for compatibility
54
+ width = width - (width % 2)
55
+ scale_filter = f"scale={width}:{height}"
56
+ else:
57
+ width = orig_width
58
+ height = orig_height
59
+ scale_filter = None
60
+
61
+ # Video command raw RGB24
62
+ video_cmd = [
63
+ "ffmpeg",
64
+ "-i", str(video_file),
65
+ ]
66
+
67
+ if scale_filter:
68
+ video_cmd.extend(["-vf", scale_filter])
69
+
70
+ video_cmd.extend([
71
+ "-f", "rawvideo",
72
+ "-pix_fmt", "rgb24",
73
+ "-an", # No audio
74
+ "-"
75
+ ])
76
+
77
+ video_proc = subprocess.Popen(video_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
78
+
79
+ # Audio: PCM16 stereo
80
+ audio_cmd = [
81
+ "ffmpeg",
82
+ "-i", str(video_file),
83
+ "-f", "s16le",
84
+ "-acodec", "pcm_s16le",
85
+ "-ac", "2",
86
+ "-ar", str(audio_rate),
87
+ "-vn", # No video
88
+ "-"
89
+ ]
90
+ audio_proc = subprocess.Popen(audio_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
91
+
92
+ frame_size = width * height * 3
93
+ audio_chunk_size = audio_rate * 2 * 2 # 1 second buffer: 2 bytes/sample, 2 channels
94
+
95
+ def frame_generator():
96
+ try:
97
+ if video_proc.stdout is not None:
98
+ while True:
99
+ raw = video_proc.stdout.read(frame_size)
100
+ if not raw:
101
+ break
102
+ if len(raw) < frame_size:
103
+ continue
104
+ img_np = np.frombuffer(raw, dtype=np.uint8).reshape((height, width, 3))
105
+ yield Image.fromarray(img_np, "RGB")
106
+ finally:
107
+ if video_proc.stdout is not None:
108
+ video_proc.stdout.close()
109
+ video_proc.wait()
110
+
111
+ def audio_generator():
112
+ try:
113
+ if audio_proc.stdout is not None:
114
+ while True:
115
+ raw = audio_proc.stdout.read(audio_chunk_size)
116
+ if not raw:
117
+ break
118
+ valid_size = (len(raw) // 4) * 4
119
+ if valid_size == 0:
120
+ continue
121
+ audio_np = np.frombuffer(raw[:valid_size], dtype=np.int16).reshape(-1, 2)
122
+ yield audio_np
123
+ finally:
124
+ if audio_proc.stdout is not None:
125
+ audio_proc.stdout.close()
126
+ audio_proc.wait()
127
+
128
+ return fps, frame_generator(), audio_generator()
pdasc/__init__.py ADDED
File without changes
Binary file
Binary file