Skip to content

Commit 2be79de

Browse files
committed
add video and audio thumbnail and preview generation, add 360 degree support, improve mesh image quality
1 parent 553550d commit 2be79de

1 file changed

Lines changed: 176 additions & 27 deletions

File tree

image_processor.py

Lines changed: 176 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,231 @@
1+
import base64
12
import io
23
import os
34
import sys
45
import json
56
import pyvips
67
import numpy as np
7-
8+
import subprocess
9+
import tempfile
10+
import xml.etree.ElementTree as et
811

912
def resize_image(image, width, height):
1013
# Use the smaller scaling factor to maintain aspect ratio
1114
scale_x = width / image.width
1215
scale_y = height / image.height
1316
scale = min(scale_x, scale_y)
14-
1517
# Resize the image using libvips if needed
1618
if scale < 1:
1719
return image.resize(scale)
1820
else:
1921
return image
2022

21-
2223
def resize_fixed(image, width, height):
2324
# Use the smaller scaling factor to maintain aspect ratio
2425
scale_x = width / image.width
2526
scale_y = height / image.height
26-
2727
# Resize the image using libvips
2828
return image.resize(scale_x, vscale=scale_y, gap=8)
2929

30-
3130
def mesh_gradient(image):
32-
glyphs = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
33-
text = ""
31+
red_levels = np.array([0, 51, 102, 153, 204, 255])
32+
green_levels = np.array([0, 36, 73, 109, 146, 182, 219, 255])
33+
blue_levels = np.array([0, 64, 128, 192, 255])
34+
35+
palette = np.array([(r, g, b) for r in red_levels for g in green_levels for b in blue_levels], dtype=np.uint8)
3436

3537
array = image.write_to_memory()
3638
np_array = np.frombuffer(array, dtype=np.uint8)
37-
3839
np_array = np_array.reshape(image.height, image.width, image.bands)
3940

40-
for y in range(4):
41-
for x in range(4):
42-
r, g, b = np_array[y, x, :3]
43-
color_val = ((r & 0xF0) << 4) | (g & 0xF0) | ((b & 0xF0) >> 4)
44-
text += glyphs[color_val >> 6]
45-
text += glyphs[color_val & 63]
46-
47-
return text
48-
41+
indices = []
42+
for y in range(8):
43+
for x in range(8):
44+
pixel = np_array[y, x, :3]
45+
# Calculate squared Euclidean distance to each palette color
46+
diffs = palette.astype(np.int32) - pixel.astype(np.int32)
47+
dists = np.sum(diffs ** 2, axis=1)
48+
idx = np.argmin(dists)
49+
indices.append(idx)
50+
51+
byte_seq = bytes(indices)
52+
return base64.b64encode(byte_seq).decode('ascii')
53+
54+
def get_duration(input_file):
55+
cmd = [
56+
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
57+
'-of', 'default=noprint_wrappers=1:nokey=1', input_file
58+
]
59+
result = subprocess.run(cmd, capture_output=True, text=True)
60+
if result.returncode != 0:
61+
return 0
62+
try:
63+
return int(float(result.stdout.strip()))
64+
except ValueError:
65+
return 0
66+
67+
def generate_video_frame(input_file, duration, temp_dir):
68+
seek_time = duration * 0.1
69+
output_jxl = os.path.join(temp_dir, 'frame.jxl')
70+
cmd = [
71+
'ffmpeg', '-i', input_file, '-ss', str(seek_time), '-vframes', '1',
72+
'-q:v', '100', output_jxl
73+
]
74+
subprocess.run(cmd, check=True)
75+
return output_jxl
76+
77+
def generate_audio_waveform(input_file, temp_dir):
78+
output_png = os.path.join(temp_dir, 'waveform.png')
79+
cmd = [
80+
'ffmpeg', '-i', input_file, '-f', 'lavfi', '-i', 'color=c=#000000:s=640x120',
81+
'-filter_complex',
82+
'[0:a] aformat=channel_layouts=mono,showwavespic=s=640x120:colors=#808080:filter=peak:scale=sqrt [pk]; '
83+
'[0:a] aformat=channel_layouts=mono,showwavespic=s=640x120:colors=#ffffff:scale=sqrt [rms], '
84+
'[pk] [rms] overlay=format=auto [nobg], [1:v] [nobg] overlay=format=auto',
85+
'-frames:v', '1', '-update', 'true', output_png
86+
]
87+
subprocess.run(cmd, check=True)
88+
return output_png
89+
90+
def generate_video_preview(input_file, output_file):
91+
# Step 1: Get input framerate using ffprobe
92+
ffprobe_cmd = [
93+
'ffprobe', '-v', 'error', '-select_streams', 'v:0',
94+
'-show_entries', 'stream=avg_frame_rate', '-of',
95+
'default=noprint_wrappers=1:nokey=1', input_file
96+
]
97+
result = subprocess.run(ffprobe_cmd, capture_output=True, text=True, check=True)
98+
framerate_str = result.stdout.strip() # e.g., "30/1"
99+
if not framerate_str:
100+
framerate_str = "1/1"
101+
102+
# Parse framerate to float (e.g., "30/1" -> 30.0)
103+
num, den = map(int, framerate_str.split('/'))
104+
input_fps = num / den
105+
106+
# Step 2: Build FFmpeg command
107+
preview_file = output_file + '.mkv'
108+
cmd = [
109+
'ffmpeg', '-i', input_file, '-c:v', 'libsvtav1', '-preset', '5',
110+
'-crf', '56', '-profile:v', 'main', '-level:v', '5.1',
111+
'-c:a', 'libopus', '-b:a', '16k', '-ac', '1', '-vbr', 'on'
112+
]
113+
vf_filters = ['scale=480:-1']
114+
if input_fps > 30:
115+
vf_filters.insert(0, 'fps=fps=source_fps/2')
116+
cmd.extend(['-vf', ','.join(vf_filters)])
117+
cmd.append(preview_file)
118+
119+
# Run the command
120+
subprocess.run(cmd, check=True)
121+
122+
def generate_audio_preview(input_file, output_file):
123+
preview_file = output_file + '.mkv'
124+
cmd = [
125+
'ffmpeg', '-i', input_file, '-c:a', 'libopus', '-b:a', '16k',
126+
'-ac', '1', '-vbr', 'on', '-vn', preview_file
127+
]
128+
subprocess.run(cmd, check=True)
129+
130+
def is_spherical(image):
131+
if image.width / image.height != 2:
132+
return False
133+
xmp_bytes = None
134+
try:
135+
if 'xmp-data' in image.get_fields():
136+
xmp_bytes = image.get('xmp-data')
137+
elif 'xmp' in image.get_fields():
138+
xmp_bytes = image.get('xmp')
139+
except Exception:
140+
return False
141+
if not xmp_bytes:
142+
return False
143+
namespaces = {'x': 'adobe:ns:meta/', 'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 'GPano': 'http://ns.google.com/photos/1.0/panorama/'}
144+
try:
145+
xmp_str = xmp_bytes.decode('utf-8')
146+
root = et.fromstring(xmp_str)
147+
rdf_descr = root.find('.//rdf:Description', namespaces)
148+
if rdf_descr is not None:
149+
proj = rdf_descr.attrib.get('{http://ns.google.com/photos/1.0/panorama/}ProjectionType')
150+
if proj and proj == 'equirectangular':
151+
return True
152+
except Exception:
153+
pass
154+
return False
155+
156+
def generate_cubemap(input_file, output_file, size):
157+
cubemap_file = output_file + '.c.jxl'
158+
cmd = ['kubi', '-l', 'row', '-s', str(size), '--order', '1', '4', '0', '5', '3', '2', input_file, cubemap_file] # Adjust if kubi args differ
159+
subprocess.run(cmd, check=True)
49160

50161
def process_image(image_info):
51-
if image_info['input_file'].lower().endswith((".heif", ".heic")):
52-
image = pyvips.Image.new_from_file(image_info['input_file'], memory=True, unlimited=True)
162+
input_file = image_info['input_file']
163+
output_file = image_info['output_file']
164+
orientation = image_info.get('orientation', 0)
165+
166+
_, ext = os.path.splitext(input_file.lower())
167+
168+
video_exts = [".3gp", ".flv", ".mov", ".qt", ".m2ts", ".mts", ".divx", ".vob", ".webm", ".mkv", ".mka", ".wmv", ".avi", ".mp4", ".mpg", ".mpeg", ".ps", ".ts", ".rm", ".ogv", ".dv"]
169+
audio_exts = [".mp3", ".wav", ".opus", ".aac", ".ogg", ".wma", ".m4a", ".flac", ".alac", ".mka"]
170+
is_video = ext in video_exts
171+
is_audio = ext in audio_exts and not is_video
172+
173+
duration = None
174+
if is_video or is_audio:
175+
duration = get_duration(input_file)
176+
177+
with tempfile.TemporaryDirectory() as temp_dir:
178+
if is_video:
179+
temp_file = generate_video_frame(input_file, duration, temp_dir)
180+
generate_video_preview(input_file, output_file)
181+
else:
182+
temp_file = generate_audio_waveform(input_file, temp_dir)
183+
generate_audio_preview(input_file, output_file)
184+
185+
# Load the generated thumbnail for processing
186+
image = pyvips.Image.new_from_file(temp_file, memory=True)
53187
else:
54-
image = pyvips.Image.new_from_file(image_info['input_file'], memory=True)
188+
if ext in (".heif", ".heic"):
189+
image = pyvips.Image.new_from_file(input_file, memory=True, unlimited=True)
190+
else:
191+
image = pyvips.Image.new_from_file(input_file, memory=True)
55192

56-
if image_info['orientation'] > 1:
193+
if orientation > 1:
57194
image = image.autorot()
58195

59196
true_width = image.width
60197
true_height = image.height
61198

62-
image.jxlsave(image_info['output_file'] + '.o.jxl', Q=75, strip=True, effort=4)
199+
is_sph = False if is_video or is_audio else is_spherical(image)
200+
if is_sph:
201+
generate_cubemap(input_file, output_file, min(true_width / 4, 1024))
202+
203+
image.jxlsave(output_file + '.o.jxl', Q=75, strip=True, effort=4)
204+
63205
image = resize_image(image, 2048, 2048)
64-
image.jxlsave(image_info['output_file'] + '.h.jxl', Q=60, strip=True, effort=5)
206+
image.jxlsave(output_file + '.h.jxl', Q=60, strip=True, effort=5)
207+
65208
image = resize_image(image, 400, 200)
66-
image.jxlsave(image_info['output_file'] + '.s.jxl', Q=20, strip=True, effort=5)
67-
image = resize_fixed(image, 4, 4)
209+
image.jxlsave(output_file + '.s.jxl', Q=20, strip=True, effort=5)
210+
211+
image = resize_fixed(image, 8, 8)
68212
image = image.colourspace("srgb")
69213

70-
return {
214+
result = {
71215
"width": true_width,
72216
"height": true_height,
73217
"color": mesh_gradient(image)
74218
}
219+
if is_sph:
220+
result["pano"] = True
221+
if duration is not None:
222+
result["duration"] = duration
75223

224+
return result
76225

77226
if __name__ == "__main__":
78227
pyvips.voperation.cache_set_max_mem(2048)
79228
stdin_utf8 = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8')
80229
stdin_data = stdin_utf8.read()
81230
data = json.loads(stdin_data)
82-
sys.stdout.write(json.dumps(process_image(data)))
231+
sys.stdout.write(json.dumps(process_image(data)))

0 commit comments

Comments
 (0)