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/__init__.py +10 -0
- core/ascii_converter.py +111 -0
- core/ascii_displayer.py +317 -0
- core/ascii_file_encoding.py +258 -0
- core/audio_player.py +150 -0
- core/generate_color_ramp.py +69 -0
- core/utils.py +26 -0
- core/video_ascii_video.py +220 -0
- core/video_extractor.py +128 -0
- pdasc/__init__.py +0 -0
- pdasc/fonts/CascadiaMono.ttf +0 -0
- pdasc/fonts/font8x8.ttf +0 -0
- pdasc/main.py +296 -0
- pdasc-0.1.0.dist-info/METADATA +16 -0
- pdasc-0.1.0.dist-info/RECORD +26 -0
- pdasc-0.1.0.dist-info/WHEEL +5 -0
- pdasc-0.1.0.dist-info/entry_points.txt +2 -0
- pdasc-0.1.0.dist-info/licenses/LICENSE +21 -0
- pdasc-0.1.0.dist-info/top_level.txt +3 -0
- web/__init__.py +4 -0
- web/image_controller/__init__.py +1 -0
- web/image_controller/app.py +99 -0
- web/image_controller/templates/index.html +383 -0
- web/video_player/__init__.py +1 -0
- web/video_player/app.py +47 -0
- web/video_player/templates/index.html +296 -0
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
|
core/video_extractor.py
ADDED
|
@@ -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
|
pdasc/fonts/font8x8.ttf
ADDED
|
Binary file
|