1+ import base64
12import io
23import os
34import sys
45import json
56import pyvips
67import numpy as np
7-
8+ import subprocess
9+ import tempfile
10+ import xml .etree .ElementTree as et
811
912def 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-
2223def 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-
3130def 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
50161def 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
77226if __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