Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
mikf
GitHub Repository: mikf/gallery-dl
Path: blob/master/gallery_dl/postprocessor/ugoira.py
8841 views
1
# -*- coding: utf-8 -*-
2
3
# Copyright 2018-2025 Mike Fährmann
4
#
5
# This program is free software; you can redistribute it and/or modify
6
# it under the terms of the GNU General Public License version 2 as
7
# published by the Free Software Foundation.
8
9
"""Convert Pixiv Ugoira to WebM"""
10
11
from .common import PostProcessor
12
from .. import util, output
13
import subprocess
14
import tempfile
15
import zipfile
16
import shutil
17
import os
18
19
try:
20
from math import gcd
21
except ImportError:
22
def gcd(a, b):
23
while b:
24
a, b = b, a % b
25
return a
26
27
28
class UgoiraPP(PostProcessor):
29
30
def __init__(self, job, options):
31
PostProcessor.__init__(self, job)
32
self.args = options.get("ffmpeg-args") or ()
33
self.twopass = options.get("ffmpeg-twopass", False)
34
self.output = options.get("ffmpeg-output", "error")
35
self.delete = not options.get("keep-files", False)
36
self.repeat = options.get("repeat-last-frame", True)
37
self.metadata = options.get("metadata", True)
38
self.mtime = options.get("mtime", True)
39
self.skip = options.get("skip", True)
40
self.uniform = self._convert_zip = self._convert_files = False
41
42
ffmpeg = options.get("ffmpeg-location")
43
self.ffmpeg = util.expand_path(ffmpeg) if ffmpeg else "ffmpeg"
44
45
mkvmerge = options.get("mkvmerge-location")
46
self.mkvmerge = util.expand_path(mkvmerge) if mkvmerge else "mkvmerge"
47
48
ext = options.get("extension")
49
mode = options.get("mode") or options.get("ffmpeg-demuxer")
50
if mode is None or mode == "auto":
51
if ext in (None, "webm", "mkv") and (
52
mkvmerge or shutil.which("mkvmerge")):
53
mode = "mkvmerge"
54
else:
55
mode = "concat"
56
57
if mode == "mkvmerge":
58
self._process = self._process_mkvmerge
59
self._finalize = self._finalize_mkvmerge
60
elif mode == "image2":
61
self._process = self._process_image2
62
self._finalize = None
63
elif mode == "archive":
64
if ext is None:
65
ext = "zip"
66
self._convert_impl = self.convert_to_archive
67
self._tempdir = util.NullContext
68
else:
69
self._process = self._process_concat
70
self._finalize = None
71
self.extension = "webm" if ext is None else ext
72
self.log.debug("using %s demuxer", mode)
73
74
rate = options.get("framerate", "auto")
75
if rate == "uniform":
76
self.uniform = True
77
elif rate != "auto":
78
self.calculate_framerate = lambda _: (None, rate)
79
80
if options.get("libx264-prevent-odd", True):
81
# get last video-codec argument
82
vcodec = None
83
for index, arg in enumerate(self.args):
84
arg, _, stream = arg.partition(":")
85
if arg == "-vcodec" or arg in ("-c", "-codec") and (
86
not stream or stream.partition(":")[0] in ("v", "V")):
87
vcodec = self.args[index + 1]
88
# use filter when using libx264/5
89
self.prevent_odd = (
90
vcodec in ("libx264", "libx265") or
91
not vcodec and self.extension.lower() in ("mp4", "mkv"))
92
else:
93
self.prevent_odd = False
94
95
self.args_pp = args = []
96
if isinstance(self.output, str):
97
args += ("-hide_banner", "-loglevel", self.output)
98
if self.prevent_odd:
99
args += ("-vf", "crop=iw-mod(iw\\,2):ih-mod(ih\\,2)")
100
101
job.register_hooks({
102
"prepare": self.prepare,
103
"file" : self.convert_from_zip,
104
"after" : self.convert_from_files,
105
}, options)
106
107
def prepare(self, pathfmt):
108
self._convert_zip = self._convert_files = False
109
if "_ugoira_frame_data" not in pathfmt.kwdict:
110
self._frames = None
111
return
112
113
self._frames = pathfmt.kwdict["_ugoira_frame_data"]
114
index = pathfmt.kwdict.get("_ugoira_frame_index")
115
if index is None:
116
self._convert_zip = True
117
if self.delete:
118
pathfmt.set_extension(self.extension)
119
pathfmt.build_path()
120
else:
121
pathfmt.build_path()
122
frame = self._frames[index].copy()
123
frame["index"] = index
124
frame["path"] = pathfmt.realpath
125
frame["ext"] = pathfmt.extension
126
127
if not index:
128
self._files = [frame]
129
else:
130
self._files.append(frame)
131
if len(self._files) >= len(self._frames):
132
self._convert_files = True
133
134
def convert_from_zip(self, pathfmt):
135
if not self._convert_zip:
136
return
137
self._zip_source = True
138
self._zip_ext = ext = pathfmt.extension
139
140
with self._tempdir() as tempdir:
141
if tempdir:
142
try:
143
with zipfile.ZipFile(pathfmt.temppath) as zfile:
144
zfile.extractall(tempdir)
145
except FileNotFoundError:
146
pathfmt.realpath = pathfmt.temppath
147
return
148
except Exception as exc:
149
pathfmt.realpath = pathfmt.temppath
150
self.log.error(
151
"%s: Unable to extract frames from %s (%s: %s)",
152
pathfmt.kwdict.get("id"), pathfmt.filename,
153
exc.__class__.__name__, exc)
154
return self.log.traceback(exc)
155
156
if self.convert(pathfmt, tempdir):
157
if self.delete:
158
pathfmt.delete = True
159
elif pathfmt.extension != ext:
160
self.log.info(pathfmt.filename)
161
pathfmt.set_extension(ext)
162
pathfmt.build_path()
163
164
def convert_from_files(self, pathfmt):
165
if not self._convert_files:
166
return
167
self._zip_source = False
168
169
with tempfile.TemporaryDirectory() as tempdir:
170
for frame in self._files:
171
172
# update frame filename extension
173
frame["file"] = name = \
174
f"{frame['file'].partition('.')[0]}.{frame['ext']}"
175
176
if tempdir:
177
# move frame into tempdir
178
try:
179
self._copy_file(frame["path"], tempdir + "/" + name)
180
except OSError as exc:
181
self.log.debug("Unable to copy frame %s (%s: %s)",
182
name, exc.__class__.__name__, exc)
183
return
184
185
pathfmt.kwdict["num"] = 0
186
self._frames = self._files
187
if self.convert(pathfmt, tempdir):
188
self.log.info(pathfmt.filename)
189
if self.delete:
190
self.log.debug("Deleting frames")
191
for frame in self._files:
192
util.remove_file(frame["path"])
193
194
def convert(self, pathfmt, tempdir):
195
pathfmt.set_extension(self.extension)
196
pathfmt.build_path()
197
if self.skip and pathfmt.exists():
198
return True
199
200
return self._convert_impl(pathfmt, tempdir)
201
202
def convert_to_animation(self, pathfmt, tempdir):
203
# process frames and collect command-line arguments
204
args = self._process(pathfmt, tempdir)
205
if self.args_pp:
206
args += self.args_pp
207
if self.args:
208
args += self.args
209
210
# ensure target directory exists
211
os.makedirs(pathfmt.realdirectory, exist_ok=True)
212
213
# invoke ffmpeg
214
try:
215
if self.twopass:
216
if "-f" not in self.args:
217
args += ("-f", self.extension)
218
args += ("-passlogfile", tempdir + "/ffmpeg2pass", "-pass")
219
self._exec(args + ["1", "-y", os.devnull])
220
self._exec(args + ["2", pathfmt.realpath])
221
else:
222
args.append(pathfmt.realpath)
223
self._exec(args)
224
if self._finalize:
225
self._finalize(pathfmt, tempdir)
226
except OSError as exc:
227
output.stderr_write("\n")
228
self.log.error("Unable to invoke FFmpeg (%s: %s)",
229
exc.__class__.__name__, exc)
230
self.log.traceback(exc)
231
pathfmt.realpath = pathfmt.temppath
232
except Exception as exc:
233
output.stderr_write("\n")
234
self.log.error("%s: %s", exc.__class__.__name__, exc)
235
self.log.traceback(exc)
236
pathfmt.realpath = pathfmt.temppath
237
else:
238
if self.mtime:
239
pathfmt.set_mtime()
240
return True
241
242
def convert_to_archive(self, pathfmt, tempdir):
243
frames = self._frames
244
245
if self.metadata:
246
if isinstance(self.metadata, str):
247
metaname = self.metadata
248
else:
249
metaname = "animation.json"
250
framedata = util.json_dumps([
251
{"file": frame["file"], "delay": frame["delay"]}
252
for frame in frames
253
]).encode()
254
255
if self._zip_source:
256
zpath = pathfmt.temppath
257
if self.delete:
258
self.delete = False
259
elif self._zip_ext != self.extension:
260
self._copy_file(zpath, pathfmt.realpath)
261
zpath = pathfmt.realpath
262
263
if self.metadata:
264
with zipfile.ZipFile(zpath, "a") as zfile:
265
zinfo = zipfile.ZipInfo(metaname)
266
if self.mtime:
267
zinfo.date_time = zfile.infolist()[0].date_time
268
with zfile.open(zinfo, "w") as fp:
269
fp.write(framedata)
270
else:
271
if self.mtime:
272
dt = pathfmt.kwdict["date_url"] or pathfmt.kwdict["date"]
273
mtime = (dt.year, dt.month, dt.day,
274
dt.hour, dt.minute, dt.second)
275
with zipfile.ZipFile(pathfmt.realpath, "w") as zfile:
276
for frame in frames:
277
zinfo = zipfile.ZipInfo.from_file(
278
frame["path"], frame["file"])
279
if self.mtime:
280
zinfo.date_time = mtime
281
with open(frame["path"], "rb") as src, \
282
zfile.open(zinfo, "w") as dst:
283
shutil.copyfileobj(src, dst, 1024*8)
284
if self.metadata:
285
zinfo = zipfile.ZipInfo(metaname)
286
if self.mtime:
287
zinfo.date_time = mtime
288
with zfile.open(zinfo, "w") as fp:
289
fp.write(framedata)
290
291
return True
292
293
_convert_impl = convert_to_animation
294
_tempdir = tempfile.TemporaryDirectory
295
296
def _exec(self, args):
297
self.log.debug(args)
298
out = None if self.output else subprocess.DEVNULL
299
if retcode := util.Popen(args, stdout=out, stderr=out).wait():
300
output.stderr_write("\n")
301
self.log.error("Non-zero exit status when running %s (%s)",
302
args, retcode)
303
raise ValueError()
304
return retcode
305
306
def _copy_file(self, src, dst):
307
shutil.copyfile(src, dst)
308
309
def _process_concat(self, pathfmt, tempdir):
310
rate_in, rate_out = self.calculate_framerate(self._frames)
311
args = [self.ffmpeg, "-f", "concat"]
312
if rate_in:
313
args += ("-r", str(rate_in))
314
args += ("-i", self._write_ffmpeg_concat(tempdir))
315
if rate_out:
316
args += ("-r", str(rate_out))
317
return args
318
319
def _process_image2(self, pathfmt, tempdir):
320
tempdir += "/"
321
frames = self._frames
322
323
# add extra frame if necessary
324
if self.repeat and not self._delay_is_uniform(frames):
325
last = frames[-1]
326
delay_gcd = self._delay_gcd(frames)
327
if last["delay"] - delay_gcd > 0:
328
last["delay"] -= delay_gcd
329
330
self.log.debug("non-uniform delays; inserting extra frame")
331
last_copy = last.copy()
332
frames.append(last_copy)
333
name, _, ext = last_copy["file"].rpartition(".")
334
last_copy["file"] = f"{int(name) + 1:>06}.{ext}"
335
shutil.copyfile(tempdir + last["file"],
336
tempdir + last_copy["file"])
337
338
# adjust frame mtime values
339
ts = 0
340
for frame in frames:
341
os.utime(tempdir + frame["file"], ns=(ts, ts))
342
ts += frame["delay"] * 1000000
343
344
return [
345
self.ffmpeg,
346
"-f", "image2",
347
"-ts_from_file", "2",
348
"-pattern_type", "sequence",
349
"-i", (f"{tempdir.replace('%', '%%')}%06d."
350
f"{frame['file'].rpartition('.')[2]}"),
351
]
352
353
def _process_mkvmerge(self, pathfmt, tempdir):
354
self._realpath = pathfmt.realpath
355
pathfmt.realpath = tempdir + "/temp." + self.extension
356
357
return [
358
self.ffmpeg,
359
"-f", "image2",
360
"-pattern_type", "sequence",
361
"-i", (f"{tempdir.replace('%', '%%')}/%06d."
362
f"{self._frames[0]['file'].rpartition('.')[2]}"),
363
]
364
365
def _finalize_mkvmerge(self, pathfmt, tempdir):
366
args = [
367
self.mkvmerge,
368
"-o", pathfmt.path, # mkvmerge does not support "raw" paths
369
"--timecodes", "0:" + self._write_mkvmerge_timecodes(tempdir),
370
]
371
if self.extension == "webm":
372
args.append("--webm")
373
args += ("=", pathfmt.realpath)
374
375
pathfmt.realpath = self._realpath
376
self._exec(args)
377
378
def _write_ffmpeg_concat(self, tempdir):
379
content = ["ffconcat version 1.0"]
380
381
for frame in self._frames:
382
content.append(f"file '{frame['file']}'\n"
383
f"duration {frame['delay'] / 1000}")
384
if self.repeat:
385
content.append(f"file '{frame['file']}'")
386
content.append("")
387
388
ffconcat = tempdir + "/ffconcat.txt"
389
with open(ffconcat, "w", encoding="utf-8") as fp:
390
fp.write("\n".join(content))
391
return ffconcat
392
393
def _write_mkvmerge_timecodes(self, tempdir):
394
content = ["# timecode format v2"]
395
396
delay_sum = 0
397
for frame in self._frames:
398
content.append(str(delay_sum))
399
delay_sum += frame["delay"]
400
content.append(str(delay_sum))
401
content.append("")
402
403
timecodes = tempdir + "/timecodes.tc"
404
with open(timecodes, "w", encoding="utf-8") as fp:
405
fp.write("\n".join(content))
406
return timecodes
407
408
def calculate_framerate(self, frames):
409
if self._delay_is_uniform(frames):
410
return (f"1000/{frames[0]['delay']}", None)
411
412
if not self.uniform:
413
gcd = self._delay_gcd(frames)
414
if gcd >= 10:
415
return (None, f"1000/{gcd}")
416
417
return (None, None)
418
419
def _delay_gcd(self, frames):
420
result = frames[0]["delay"]
421
for f in frames:
422
result = gcd(result, f["delay"])
423
return result
424
425
def _delay_is_uniform(self, frames):
426
delay = frames[0]["delay"]
427
for f in frames:
428
if f["delay"] != delay:
429
return False
430
return True
431
432
433
__postprocessor__ = UgoiraPP
434
435