From 01f669b61d77dfaaf84377a691bb2a24fe282edd Mon Sep 17 00:00:00 2001 From: j Date: Tue, 19 Mar 2024 11:48:36 +0100 Subject: [PATCH 01/48] better way to calculate remove --- render.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/render.py b/render.py index 0be09fd..21cf212 100644 --- a/render.py +++ b/render.py @@ -308,6 +308,16 @@ def get_scene_duration(scene): duration += clip['duration'] return duration +def get_offset_duration(prefix): + duration = 0 + for root, folders, files in os.walk(prefix): + for f in files: + if f == 'scene.json': + path = os.path.join(root, f) + scene = json.load(open(path)) + duration += get_scene_duration(scene) + return duration + def render(root, scene, prefix=''): fps = 24 files = [] @@ -656,8 +666,8 @@ def render_infinity(options): if f.isdigit() and os.path.isdir(render_prefix + f) and state["offset"] > int(f) >= 100 ] if len(current) > state["max-items"]: - current = list(reversed(ox.sorted_strings(current))) - remove = list(reversed(current[-state["max-items"]:])) + current = ox.sorted_strings(current) + remove = current[:-state["max-items"]] update_m3u(render_prefix, exclude=remove) for folder in remove: folder = render_prefix + folder From 80db2f0255692dbc3819cce8ee5374123bbc9131 Mon Sep 17 00:00:00 2001 From: j Date: Fri, 22 Mar 2024 10:56:50 +0100 Subject: [PATCH 02/48] import/export subtitles --- management/commands/export_subtitles.py | 7 +- management/commands/generate_clips.py | 3 +- management/commands/import_subtitles.py | 106 ++++++++++++++++++++++++ management/commands/update_subtitles.py | 1 + render.py | 4 +- 5 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 management/commands/import_subtitles.py diff --git a/management/commands/export_subtitles.py b/management/commands/export_subtitles.py index 1807fbb..8782c77 100644 --- a/management/commands/export_subtitles.py +++ b/management/commands/export_subtitles.py @@ -10,15 +10,16 @@ class Command(BaseCommand): help = 'export all subtitles for translations' def add_arguments(self, parser): - pass + parser.add_argument('--lang', action='store', dest='lang', default="", help='subtitle language') def handle(self, **options): - + lang = options["lang"] import annotation.models import item.models for i in item.models.Item.objects.filter(data__type__contains='Voice Over').order_by('sort__title'): print("## %s %s" % (i.get("title"), i.public_id)) - for sub in i.annotations.all().filter(layer='subtitles').exclude(value='').order_by("start"): + + for sub in i.annotations.all().filter(layer='subtitles').exclude(value='').filter(languages=lang).order_by("start"): if not sub.languages: print(sub.value.strip() + "\n") print("\n\n\n") diff --git a/management/commands/generate_clips.py b/management/commands/generate_clips.py index 9fd1c13..d7ae9f8 100644 --- a/management/commands/generate_clips.py +++ b/management/commands/generate_clips.py @@ -28,6 +28,7 @@ class Command(BaseCommand): help = 'generate symlinks to clips and clips.json' def add_arguments(self, parser): + parser.add_argument('--lang', action='store', dest='lang', default=None, help='subtitle language') parser.add_argument('--prefix', action='store', dest='prefix', default="/srv/t_for_time", help='prefix to build clips in') def handle(self, **options): @@ -102,7 +103,7 @@ class Command(BaseCommand): os.unlink(target) os.symlink(src, target) subs = [] - for sub in vo.annotations.filter(layer="subtitles").exclude(value="").order_by("start"): + for sub in vo.annotations.filter(layer="subtitles", languages=options["lang"]).exclude(value="").order_by("start"): sdata = get_srt(sub) subs.append(sdata) voice_over[fragment_id][batch] = { diff --git a/management/commands/import_subtitles.py b/management/commands/import_subtitles.py new file mode 100644 index 0000000..c812325 --- /dev/null +++ b/management/commands/import_subtitles.py @@ -0,0 +1,106 @@ +import json +import os +import subprocess + +import ox + +from django.core.management.base import BaseCommand +from django.conf import settings + +from item.models import Item +from annotation.models import Annotation + + +class Command(BaseCommand): + help = 'export all subtitles for translations' + + def add_arguments(self, parser): + parser.add_argument('--lang', action='store', dest='lang', default=None, help='subtitle language') + parser.add_argument('--test', action='store_true', dest='test', default=False, help='test run') + parser.add_argument('args', metavar='args', type=str, nargs='*', help='file or url') + + def handle(self, filename, **options): + if not options["lang"]: + print("--lang is required") + return + lang = options["lang"] + + if filename.startswith("http"): + data = ox.net.read_url(filename).decode() + else: + with open(filename) as fd: + data = fd.read() + + data = data.strip().split('\n## ')[1:] + + invalid = [] + valid = [] + for block in data: + title, block = block.split('\n', 1) + block = block.strip() + title = title.strip() + item_id = title.split(' ')[-1] + item = Item.objects.get(public_id=item_id) + + subtitles_en = item.annotations.filter(layer="subtitles", languages=None).exclude(value='') + lines = block.split('\n\n') + if len(lines) != subtitles_en.count(): + print('%s: number of subtitles does not match, en: %s vs %s: %s' % (title, subtitles_en.count(), lang, len(lines))) + continue + + if options["test"]: + print('%s: valid %s subtitles' % (title, len(lines))) + else: + n = 0 + item.annotations.filter(layer="subtitles", languages=lang).delete() + for sub_en in subtitles_en.order_by('start'): + sub = Annotation() + sub.item = sub_en.item + sub.user = sub_en.user + sub.layer = sub_en.layer + sub.start = sub_en.start + sub.end = sub_en.end + sub.value = '%s' % (lang, lines[n]) + sub.save() + n += 1 + + ''' + srt = 'vocals_txt/%s/%s' % (title[0], title.replace('.wav', '.srt')) + filename = 'vocals_txt/%s/%s' % (title[0], title.replace('.wav', '.' + lang + '.srt')) + + folder = os.path.dirname(filename) + if not os.path.exists(folder): + os.makedirs(folder) + data = json.load(open(srt + '.json')) + subs = block.replace('\n\n', '\n').split('\n') + if len(data) != len(subs): + print('invalid', title, 'expected', len(data), 'got', len(subs)) + invalid.append('## %s\n\n%s' % (title, block)) + valid.append('## %s\n\n%s' % (title, '\n\n'.join([d['value'] for d in data]))) + continue + + for i, sub in enumerate(data): + sub['value'] = subs[i] + kodata = ox.srt.encode(data) + current = None + if os.path.exists(filename): + with open(filename, 'rb') as fd: + current = fd.read() + if current != kodata: + print('update', title, filename) + with open(filename, 'wb') as fd: + fd.write(kodata) + with open(filename + '.json', 'w') as fd: + ko = [{ + 'in': s['in'], + 'out': s['out'], + 'value': s['value'], + } for s in data] + json.dump(ko, fd, ensure_ascii=False, indent=4) + + if invalid: + with open('invalid_%s_subtitles.txt' % lang, 'w') as fd: + fd.write('\n\n\n\n'.join(invalid)) + with open('invalid_%s_subtitles_en.txt' % lang, 'w') as fd: + fd.write('\n\n\n\n'.join(valid)) + ''' diff --git a/management/commands/update_subtitles.py b/management/commands/update_subtitles.py index 2ca2511..9bfd644 100644 --- a/management/commands/update_subtitles.py +++ b/management/commands/update_subtitles.py @@ -15,6 +15,7 @@ class Command(BaseCommand): parser.add_argument('--prefix', action='store', dest='prefix', default="/srv/t_for_time", help='prefix to build clips in') parser.add_argument('--duration', action='store', dest='duration', default="3600", help='target duration of all fragments in seconds') parser.add_argument('--offset', action='store', dest='offset', default="1024", help='inital offset in pi') + parser.add_argument('--lang', action='store', dest='lang', default=None, help='subtitle language') def handle(self, **options): update_subtitles(options) diff --git a/render.py b/render.py index 21cf212..72d3cf9 100644 --- a/render.py +++ b/render.py @@ -353,6 +353,7 @@ def render(root, scene, prefix=''): files.append(path) return files + def get_fragments(clips, voice_over, prefix): import itemlist.models import item.models @@ -590,6 +591,7 @@ def update_subtitles(options): prefix = Path(options['prefix']) duration = int(options['duration']) base = int(options['offset']) + lang = options["lang"] _cache = os.path.join(prefix, "cache.json") if os.path.exists(_cache): @@ -609,7 +611,7 @@ def update_subtitles(options): vo = item.models.Item.objects.filter(data__batch__icontains=batch, data__title__startswith=fragment_id + '_').first() if vo: #print("%s => %s %s" % (clip['src'], vo, vo.get('batch'))) - for sub in vo.annotations.filter(layer="subtitles").exclude(value="").order_by("start"): + for sub in vo.annotations.filter(layer="subtitles").filter(languages=lang).exclude(value="").order_by("start"): sdata = get_srt(sub, offset) subs.append(sdata) else: From 3782ca672106840fe5b52963d4e6c9514a36259e Mon Sep 17 00:00:00 2001 From: j Date: Fri, 22 Mar 2024 11:33:39 +0100 Subject: [PATCH 03/48] multiple languages --- management/commands/export_subtitles.py | 28 ++++++++++++++++++---- management/commands/generate_clips.py | 13 ++++++++-- render.py | 32 ++++++++++++++++++++++--- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/management/commands/export_subtitles.py b/management/commands/export_subtitles.py index 8782c77..c954e90 100644 --- a/management/commands/export_subtitles.py +++ b/management/commands/export_subtitles.py @@ -2,25 +2,43 @@ import json import os import subprocess +import ox + from django.core.management.base import BaseCommand from django.conf import settings +from ...render import add_translations + class Command(BaseCommand): help = 'export all subtitles for translations' def add_arguments(self, parser): - parser.add_argument('--lang', action='store', dest='lang', default="", help='subtitle language') + parser.add_argument('--lang', action='store', dest='lang', default=None, help='subtitle language') def handle(self, **options): - lang = options["lang"] import annotation.models import item.models + lang = options["lang"] + if lang: + lang = lang.split(',') + tlang = lang[1:] + lang = lang[0] + else: + tlang = None + if lang == "en": + lang = None + for i in item.models.Item.objects.filter(data__type__contains='Voice Over').order_by('sort__title'): print("## %s %s" % (i.get("title"), i.public_id)) - for sub in i.annotations.all().filter(layer='subtitles').exclude(value='').filter(languages=lang).order_by("start"): - if not sub.languages: - print(sub.value.strip() + "\n") + if tlang: + value = add_translations(sub, tlang) + value = ox.strip_tags(value) + else: + value = sub.value.replace('
', '
').replace('
\n', '\n').replace('
', '\n').strip() + if sub.languages: + value = ox.strip_tags(value) + print(value.strip() + "\n") print("\n\n\n") diff --git a/management/commands/generate_clips.py b/management/commands/generate_clips.py index d7ae9f8..23bc61c 100644 --- a/management/commands/generate_clips.py +++ b/management/commands/generate_clips.py @@ -33,6 +33,15 @@ class Command(BaseCommand): def handle(self, **options): prefix = options['prefix'] + lang = options["lang"] + if lang: + lang = lang.split(',') + tlang = lang[1:] + lang = lang[0] + else: + tlang = None + if lang == "en": + lang = None clips = [] for i in item.models.Item.objects.filter(sort__type='original'): qs = item.models.Item.objects.filter(data__title=i.data['title']).exclude(id=i.id) @@ -103,8 +112,8 @@ class Command(BaseCommand): os.unlink(target) os.symlink(src, target) subs = [] - for sub in vo.annotations.filter(layer="subtitles", languages=options["lang"]).exclude(value="").order_by("start"): - sdata = get_srt(sub) + for sub in vo.annotations.filter(layer="subtitles", languages=lang).exclude(value="").order_by("start"): + sdata = get_srt(sub, lang=tlang) subs.append(sdata) voice_over[fragment_id][batch] = { "src": target, diff --git a/render.py b/render.py index 72d3cf9..f974547 100644 --- a/render.py +++ b/render.py @@ -566,9 +566,26 @@ def render_all(options): json.dump(_CACHE, fd) -def get_srt(sub, offset=0): +def add_translations(sub, lang): + value = sub.value.replace('
', '
').replace('
\n', '\n').replace('
', '\n').strip() + if sub.languages: + value = ox.strip_tags(value) + if lang: + for slang in lang: + if slang == "en": + slang = None + for tsub in sub.item.annotations.filter(layer="subtitles", start=sub.start, end=sub.end, languages=slang): + tvalue = tsub.value.replace('
', '
').replace('
\n', '\n').replace('
', '\n').strip() + if tsub.languages: + tvalue = ox.strip_tags(tvalue) + value += '\n' + tvalue + return value + +def get_srt(sub, offset=0, lang=None): sdata = sub.json(keys=['in', 'out', 'value']) - sdata['value'] = sdata['value'].replace('
', '
').replace('
\n', '\n').replace('
', '\n') + sdata['value'] = sdata['value'].replace('
', '
').replace('
\n', '\n').replace('
', '\n').strip() + if lang: + sdata['value'] = add_translations(sub, lang) if offset: sdata["in"] += offset sdata["out"] += offset @@ -592,6 +609,8 @@ def update_subtitles(options): duration = int(options['duration']) base = int(options['offset']) lang = options["lang"] + if lang and "," in lang: + lang = lang.split(',') _cache = os.path.join(prefix, "cache.json") if os.path.exists(_cache): @@ -611,8 +630,15 @@ def update_subtitles(options): vo = item.models.Item.objects.filter(data__batch__icontains=batch, data__title__startswith=fragment_id + '_').first() if vo: #print("%s => %s %s" % (clip['src'], vo, vo.get('batch'))) + if isinstance(lang, list): + tlang = lang[1:] + lang = lang[0] + else: + tlang = None + if lang == "en": + lang = None for sub in vo.annotations.filter(layer="subtitles").filter(languages=lang).exclude(value="").order_by("start"): - sdata = get_srt(sub, offset) + sdata = get_srt(sub, offset, tlang) subs.append(sdata) else: print("could not find vo for %s" % clip['src']) From 438108a8f98598c972c81b93813b329126aa8f52 Mon Sep 17 00:00:00 2001 From: j Date: Fri, 22 Mar 2024 12:29:11 +0100 Subject: [PATCH 04/48] fix update_subtitles --- render.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/render.py b/render.py index f974547..3f85ed8 100644 --- a/render.py +++ b/render.py @@ -611,6 +611,13 @@ def update_subtitles(options): lang = options["lang"] if lang and "," in lang: lang = lang.split(',') + if isinstance(lang, list): + tlang = lang[1:] + lang = lang[0] + else: + tlang = None + if lang == "en": + lang = None _cache = os.path.join(prefix, "cache.json") if os.path.exists(_cache): @@ -630,13 +637,6 @@ def update_subtitles(options): vo = item.models.Item.objects.filter(data__batch__icontains=batch, data__title__startswith=fragment_id + '_').first() if vo: #print("%s => %s %s" % (clip['src'], vo, vo.get('batch'))) - if isinstance(lang, list): - tlang = lang[1:] - lang = lang[0] - else: - tlang = None - if lang == "en": - lang = None for sub in vo.annotations.filter(layer="subtitles").filter(languages=lang).exclude(value="").order_by("start"): sdata = get_srt(sub, offset, tlang) subs.append(sdata) From ed03c7026a372ef350813cb7f6aa82afa7c5ad7a Mon Sep 17 00:00:00 2001 From: j Date: Fri, 22 Mar 2024 14:10:07 +0100 Subject: [PATCH 05/48] sync to hour, play sax inline, add s/p keybindings --- player/player.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++- title.png | Bin 0 -> 8278 bytes 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 title.png diff --git a/player/player.py b/player/player.py index 74800e9..62a6579 100755 --- a/player/player.py +++ b/player/player.py @@ -8,6 +8,7 @@ import time from threading import Thread from datetime import datetime +import ox import mpv @@ -53,11 +54,22 @@ class Sync(Thread): def __init__(self, *args, **kwargs): self.is_main = kwargs.get('mode', 'main') == 'main' + self.start_at_hour = kwargs.get("hour", False) self.sock = self.init_socket() self.main = Main() if self.is_main: self.socket_enable_broadcast() + if kwargs.get("sax"): + self.sax = mpv.MPV( + log_handler=mpv_log, input_default_bindings=True, + input_vo_keyboard=True, + ) + self.sax.loop_file = True + self.sax.play("/srv/t_for_time/render/Saxophone-5.1.mp4") + else: + self.sax = None + if mpv.MPV_VERSION >= (2, 2): self.mpv = mpv.MPV( log_handler=mpv_log, input_default_bindings=True, @@ -79,6 +91,8 @@ class Sync(Thread): self.mpv.loop_file = False self.mpv.loop_playlist = True self.mpv.register_key_binding('q', self.q_binding) + self.mpv.register_key_binding('s', self.s_binding) + self.mpv.register_key_binding('p', self.p_binding) self.playlist = kwargs['playlist'] self.playlist_mtime = os.stat(self.playlist).st_mtime self.mpv.loadlist(self.playlist) @@ -90,6 +104,30 @@ class Sync(Thread): time.sleep(0.1) self.mpv.pause = True self.sync_to_main() + elif self.start_at_hour: + self.mpv.pause = True + fmt = '%Y-%m-%d %H' + now = datetime.now() + offset = (now - datetime.strptime(now.strftime(fmt), fmt)).total_seconds() + if self.sax: + self.sax.seek(offset, 'absolute', 'exact') + self.sax.pause = True + position = 0 + for idx, item in enumerate(self.mpv.playlist): + duration = ox.avinfo(item['filename'])['duration'] + if position + duration > offset: + pos = offset - position + self.mpv.playlist_play_index(idx) + self.mpv.pause = False + self.mpv.wait_until_playing() + self.mpv.seek(pos, 'absolute', 'exact') + time.sleep(0.1) + break + else: + position += duration + if self.sax: + self.sax.pause = False + self.ready = True Thread.__init__(self) self.start() @@ -116,6 +154,16 @@ class Sync(Thread): self.stop() self.mpv.stop() + def s_binding(self, *args): + self.mpv.pause = True + if self.sax: + self.sax.pause = True + + def p_binding(self, *args): + self.mpv.pause = False + if self.sax: + self.sax.pause = False + def stop(self, *args): self.active = False if self.sock: @@ -302,6 +350,8 @@ def main(): parser.add_argument('--prefix', help='video location', default=prefix) parser.add_argument('--window', action='store_true', help='run in window', default=False) parser.add_argument('--debug', action='store_true', help='debug', default=False) + parser.add_argument('--hour', action='store_true', help='hour', default=False) + parser.add_argument('--sax', action='store_true', help='hour', default=False) args = parser.parse_args() DEBUG = args.debug @@ -311,7 +361,7 @@ def main(): base = os.path.dirname(os.path.abspath(__file__)) #os.chdir(base) - player = Sync(mode=args.mode, playlist=args.playlist, fullscreen=not args.window) + player = Sync(mode=args.mode, playlist=args.playlist, fullscreen=not args.window, hour=args.hour, sax=args.sax) while player.active: try: player.mpv.wait_for_playback() diff --git a/title.png b/title.png new file mode 100644 index 0000000000000000000000000000000000000000..c92b10e1bdfed63f87b11db517cd3f2d176428eb GIT binary patch literal 8278 zcmeH}dpMNa9>^O5ivwALz1KtgWB7rCij{ecS4;?B%9nNwe4ck zG^j~(C%H{B24l#*W(#xQ41`#I;o^Y8g<*7Lm2`#kTv-u3(aKHs&zzxS4tqmBHA zZ5sdp@^-dX&HzZo03c~3Ee@afmb)s$n@o`H*-!vg(wVB?0N5dIXJz4XEr-p} z_d}jm2SMXSX&NV;ZWYFR^e6>k=A7c}TT+cRUfHWW)KS=Xupp(}DP>abdrzU|MN3(O z#V(~2a@+fzDb`;1vlMENil4aM7hhQ0^g!vk0n@9BW7eA5ai4uMhZ#uV%nXGKOc?XD z(dZIkGNK_OAR-_lAR-_lAR-_lAR-_lAR_QT3E1qGj$2vcWVDS}S65FS^YQgfQ%JGQ z%+0;K7Gakp0p7QLB|nw`z-TpYM^)s?Vt2=KJBs-LQ(TlwLXt?N(a}*mn_!Ich6L2m zwNYuU!T$cN%uJ&?88H|#2AkS8o`sjh|B}(RC-R)&02<9S^fDqzQ0N&hh8#i0~F`5{kjwv2#>Fw>cJ1Pml{014}%l(Fi2zJ!!0u9d@I<|K! z00mEN%_V>;SzBAzEmEoXS)oe^1Y$cnYVb?k$jC@x zVd0B&B`$8B%-Th6dU|@jf-p@$aIx$jdR%`?TRP6KFX4#B^i8ac{JN~qwDq9CcU5Z| zPPul=qbf+FpD{S-@J~e;Tg48>h9gL)CkD>Kud`4Lcv9qmUg~%JS0QUGr*{i$y8@2-@R+# zNecY^_dQVo`WbC^;ka{Weqq5N$IZjT1B=be%)EK%6!&AdbRgr+mI~Q~y3fx}>v_CU zu6~Clq6za;weeC6E$i^!*cx2?RQ$z#(s2WoftNFfcG;O$(SgFYwY#^QTPl`4LiWYH3BQ(%w0qhHJRj?z*a{bTecV zj@+JHendhdkq;d-J3<~hOe`^@1Ci0qdUC?WbJnX(t~h>_*9HYEHMy=V^vB?No8M(QDjD0+{w+&^^98H zla66j1@Cg(xhubugK|B6nnt5_=9z}C@)l-enN`6>3uBi(Gb@J(-*WLox3DRllPDDG zc(B11VG%}WnvsraTNpDu{xUblvO+ZGKtY6=6lZ{MclDn@uX zLlZPwz<1%qB$KT8vGPQ#`p`?d8JigCIqg10PqU(vogmS^mAB>P!NcG1g0v?#1$M?TC+rmqML9XS8;pumgiGXp zV$Iq%)9-ZO{3~yHP?+*FtZPP$KE2*So-L`oyxec;({c1=ld;~?b0#Jx-b)kh+2vm( zK*1%YE&z5v*FgS&@5yTWQ79EQwwgdD6@OdcF(`Nwr2RSQ4-2&uvd|&w7H4N?V?QM) zCnvH$fBx)TqO6sfv{5~gt$n(z;UD6_d}()q7_jXd2&_?KuPqJ)UQSZJ-AJh3ew7)) zZSVLaYJTd3uf!oG0iWCHopkVl?^!H1`OvSt=BBQ$E^pd8P;h{_M-3K7e8ZjWw4vzB zp2y*EH~bsIj2U$5JDVly>xku$S1Uu|oJO74$VC?250%}%w^|$oiT@pWc-X5i8S(Wp zd-~JU?#So4(+61@QlOyuzrOQeScu%!wsWC%!wu|B7ryWPt|~~CH)gJ4hoHX`e{0|Xq%b!WM~}mX5zi$J|z0%onl~zg3y|j zdS5&>@WBKJ1^tTSuvn_W4)cFO-}FPJweH>`13<9Qf>U19tnsw_SBK*__A9jdkRl@^ zy_Z_kv~1K7rv3ziaewtywwrS*0E$VH(ClSaMv~MYF*c(4FW4z_b8~+9>(`fWUU-zW zZ{NNIc53RTY^WIF;m@hs>6q;7Y;U}m*N%)fa+O(Ht9wn9DPauCY5JsZ%3|a8!_osV z-pP|EleX!4%Y&f%KdBEva_a1;~y(`}k-& zC_MQzT~bmKVOCR70e1_@Wv{{E!>Y$jIV)eE5i!M7yJmvhSjg+l1?@a^bky)1rZ{Bo zOXDv2y??lsF1BjrTX|-}G~f#1OEad|zyAh{7kEWS3zoT)*`7!M9PbFhnHHYWdE#8+ zhtW^R$ip)!C->H`Wo&G$p`k&h!Fr?dY=RxsJ&Z>!wr_1*2i^-##7V$*eL@F;=1mpB zG!{QVH#EV|w3yX=`B&o?3g;c=)hp%bmWhc8xXfIFBrumS5$aSoIOtB*2z%sKYP}0K zUk}GKd((BXZ!_D*o0^(n8_sH5C@U+|)zic05U^*@PVcY7b_J=ZF0@MH8|e)QVeS_FvM zd{tA}mxmMJe@W0tT@=Nw^uuwGSq!C(-3e)e_Yc@PZ|0TBTa0TBTa0TBTa0TBTa h0TF@!6M>w^0`Vt^P(}6yl#4K=-BCxYvLjw`{{|x-$SeQ= literal 0 HcmV?d00001 From d72bf343e31d1ee2c6ffc38448656adc3777ec7f Mon Sep 17 00:00:00 2001 From: j Date: Fri, 22 Mar 2024 14:13:16 +0100 Subject: [PATCH 06/48] adjust levels - Melody (Ban's vocals) (+ 1 db) - Ashley (Ban's vocals) (- 0.75 db) --- render.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/render.py b/render.py index 3f85ed8..eeaef1b 100644 --- a/render.py +++ b/render.py @@ -140,9 +140,9 @@ def compose(clips, target=150, base=1024, voice_over=None): elif 'Free' in voc['src']: a, b = '5.2', '-3.8' elif 'Ashley' in voc['src']: - a, b = '3.75', '-5.25' + a, b = '4.50', '-4.50' elif 'Melody' in voc['src']: - a, b = '4.25', '-4.75' + a, b = '5.25', '-3.75' voc['filter'] = {'volume': a} scene['audio-center']['A1'].append(voc) vo_low = vo.copy() From 19b54d57cbfc2f0ae7a64b167eaf3734f076907b Mon Sep 17 00:00:00 2001 From: j Date: Fri, 22 Mar 2024 14:25:13 +0100 Subject: [PATCH 07/48] include sound adjustments Front L & R: +3.0 Centre: -14.0 Rear: L & R +3.0 Wall (Back): L & R -8.0 (These is the stereo pair attached to the "original" clips) --- render.py | 16 ++++++++-------- sax.py | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/render.py b/render.py index eeaef1b..1cdd132 100644 --- a/render.py +++ b/render.py @@ -132,17 +132,17 @@ def compose(clips, target=150, base=1024, voice_over=None): subs = [] for vo in voice_overs: voc = vo.copy() - a, b = '3', '-6' + a, b = '-11', '-3' if 'Whispered' in voc['src']: - a, b = '6', '-3' + a, b = '-8', '0' elif 'Read' in voc['src']: - a, b = '6.25', '-2.75' + a, b = '-7.75', '0.25' elif 'Free' in voc['src']: - a, b = '5.2', '-3.8' + a, b = '-8.8', '-0.8' elif 'Ashley' in voc['src']: - a, b = '4.50', '-4.50' + a, b = '-9.5', '-1.50' elif 'Melody' in voc['src']: - a, b = '5.25', '-3.75' + a, b = '-5.75', '-0.75' voc['filter'] = {'volume': a} scene['audio-center']['A1'].append(voc) vo_low = vo.copy() @@ -282,10 +282,10 @@ def compose(clips, target=150, base=1024, voice_over=None): scene['audio-back']['A1'].append({ 'duration': clip['duration'], 'src': clip['original'], - 'filter': {'volume': '+0.2'}, + 'filter': {'volume': '-8.2'}, }) # TBD: Foley - cf_volume = '-5.5' + cf_volume = '-2.5' scene['audio-front']['A2'].append({ 'duration': clip['duration'], 'src': foley, diff --git a/sax.py b/sax.py index 3009ea6..c1e916b 100644 --- a/sax.py +++ b/sax.py @@ -31,7 +31,7 @@ reverb = { "src": reverb_wav, "duration": 3600.0, "filter": { - "volume": "0.5" + "volume": "3.5" }, } @@ -39,14 +39,14 @@ long = { "src": long_wav, "duration": 3600.0, "filter": { - "volume": "-4" + "volume": "-1" }, } noise = { "src": nois_wav, "duration": 3600.0, "filter": { - "volume": "4.75" + "volume": "7.75" }, } From a6479d17460e832b19a74bbe1cf7218cf855b258 Mon Sep 17 00:00:00 2001 From: j Date: Fri, 22 Mar 2024 14:51:08 +0100 Subject: [PATCH 08/48] fix seek in sax --- player/player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/player/player.py b/player/player.py index 62a6579..f437540 100755 --- a/player/player.py +++ b/player/player.py @@ -110,6 +110,7 @@ class Sync(Thread): now = datetime.now() offset = (now - datetime.strptime(now.strftime(fmt), fmt)).total_seconds() if self.sax: + self.sax.wait_until_playing() self.sax.seek(offset, 'absolute', 'exact') self.sax.pause = True position = 0 From 6cdbf4f1b92163d650f730269ed4b5d4624b4820 Mon Sep 17 00:00:00 2001 From: j Date: Sat, 23 Mar 2024 10:00:47 +0100 Subject: [PATCH 09/48] forward pause/play to peers --- player/player.py | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/player/player.py b/player/player.py index f437540..c3e5cb1 100755 --- a/player/player.py +++ b/player/player.py @@ -93,6 +93,7 @@ class Sync(Thread): self.mpv.register_key_binding('q', self.q_binding) self.mpv.register_key_binding('s', self.s_binding) self.mpv.register_key_binding('p', self.p_binding) + self.mpv.register_key_binding('SPACE', self.space_binding) self.playlist = kwargs['playlist'] self.playlist_mtime = os.stat(self.playlist).st_mtime self.mpv.loadlist(self.playlist) @@ -152,18 +153,34 @@ class Sync(Thread): self.mpv.stop() def q_binding(self, *args): + if args[0] != 'd-': + return self.stop() self.mpv.stop() + def space_binding(self, *args): + if args[0] != 'd-': + return + if self.mpv.pause: + self.p_binding(*args) + else: + self.s_binding(*args) + def s_binding(self, *args): + if args[0] != 'd-': + return self.mpv.pause = True if self.sax: self.sax.pause = True + self.send_playback_state() def p_binding(self, *args): + if args[0] != 'd-': + return self.mpv.pause = False if self.sax: self.sax.pause = False + self.send_playback_state() def stop(self, *args): self.active = False @@ -251,21 +268,37 @@ class Sync(Thread): except socket.error as e: logger.error("send failed: %s", e) + def send_playback_state(self): + state = 'pause' if self.mpv.pause else 'play' + msg = ("%s -1" % state).encode() + try: + self.sock.send(msg) + except socket.error as e: + logger.error("send failed: %s", e) + # # follower specific # + _last_ping = None def read_position_main(self): self.sock.settimeout(5) try: data = self.sock.recvfrom(1024)[0].decode().split(" ", 1) except socket.timeout: - logger.error("failed to receive data from main") + if self._last_ping != "pause": + logger.error("failed to receive data from main") except OSError: logger.error("socket closed") else: - self.main.time_pos = float(data[0]) - self.main.playlist_current_pos = int(data[1]) + self._last_ping = data[0] + if data[0] == "pause": + self.mpv.pause = True + elif data[0] == "play": + self.mpv.pause = False + else: + self.main.time_pos = float(data[0]) + self.main.playlist_current_pos = int(data[1]) def adjust_position(self): if self.mpv.time_pos is not None: From 8268166b7744ad08f30c2b709ce6a28d80f1e5dd Mon Sep 17 00:00:00 2001 From: j Date: Mon, 1 Apr 2024 12:08:21 +0200 Subject: [PATCH 10/48] load player config from file --- player/player.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/player/player.py b/player/player.py index c3e5cb1..4b56fc8 100755 --- a/player/player.py +++ b/player/player.py @@ -20,10 +20,15 @@ SYNC_GRACE_TIME = 5 SYNC_JUMP_AHEAD = 1 PORT = 9067 DEBUG = False -FONT = 'Menlo' -FONT_SIZE = 30 -FONT_BORDER = 4 -SUB_MARGIN = 2 * 36 + 6 + +CONFIG = { + "font": "Menlo", + "font_size": 30, + "font_border": 4, + "sub_margin": 2 * 36 + 6, + "sub_spacing": 0, + "vf": None +} def hide_gnome_overview(): @@ -74,18 +79,22 @@ class Sync(Thread): self.mpv = mpv.MPV( log_handler=mpv_log, input_default_bindings=True, input_vo_keyboard=True, - sub_font_size=FONT_SIZE, sub_font=FONT, - sub_border_size=FONT_BORDER, - sub_margin_y=SUB_MARGIN, + sub_font_size=CONFIG["font_size"], sub_font=CONFIG["font"], + sub_border_size=CONFIG["font_border"], + sub_margin_y=CONFIG["sub_margin"], + sub_ass_line_spacing=CONFIG["sub_spacing"], ) else: self.mpv = mpv.MPV( log_handler=mpv_log, input_default_bindings=True, input_vo_keyboard=True, - sub_text_font_size=FONT_SIZE, sub_text_font=FONT, - sub_border_size=FONT_BORDER, - sub_margin_y=SUB_MARGIN, + sub_text_font_size=CONFIG["font_size"], sub_text_font=CONFIG["font"], + sub_border_size=CONFIG["font_border"], + sub_margin_y=CONFIG["sub_margin"], + sub_ass_line_spacing=CONFIG["sub_spacing"], ) + if CONFIG.get("vf"): + self.mpv.vf = CONFIG["vf"] self.mpv.observe_property('time-pos', self.time_pos_cb) self.mpv.fullscreen = kwargs.get('fullscreen', False) self.mpv.loop_file = False @@ -386,12 +395,17 @@ def main(): parser.add_argument('--debug', action='store_true', help='debug', default=False) parser.add_argument('--hour', action='store_true', help='hour', default=False) parser.add_argument('--sax', action='store_true', help='hour', default=False) + parser.add_argument('--config', help='config', default=None) args = parser.parse_args() DEBUG = args.debug if DEBUG: log_format = '%(asctime)s:%(levelname)s:%(name)s:%(message)s' logging.basicConfig(level=logging.DEBUG, format=log_format) + if args.config: + with open(args.config) as fd: + CONFIG.update(json.load(fd)) + base = os.path.dirname(os.path.abspath(__file__)) #os.chdir(base) From f8bb75cd5b58e29d13df26dc9fc95b25dabe2149 Mon Sep 17 00:00:00 2001 From: j Date: Tue, 2 Apr 2024 11:30:46 +0200 Subject: [PATCH 11/48] duration not needed for subtitle updates --- management/commands/update_subtitles.py | 1 - render.py | 1 - 2 files changed, 2 deletions(-) diff --git a/management/commands/update_subtitles.py b/management/commands/update_subtitles.py index 9bfd644..a1482d6 100644 --- a/management/commands/update_subtitles.py +++ b/management/commands/update_subtitles.py @@ -13,7 +13,6 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--prefix', action='store', dest='prefix', default="/srv/t_for_time", help='prefix to build clips in') - parser.add_argument('--duration', action='store', dest='duration', default="3600", help='target duration of all fragments in seconds') parser.add_argument('--offset', action='store', dest='offset', default="1024", help='inital offset in pi') parser.add_argument('--lang', action='store', dest='lang', default=None, help='subtitle language') diff --git a/render.py b/render.py index 1cdd132..2cb0abf 100644 --- a/render.py +++ b/render.py @@ -606,7 +606,6 @@ def update_subtitles(options): import item.models prefix = Path(options['prefix']) - duration = int(options['duration']) base = int(options['offset']) lang = options["lang"] if lang and "," in lang: From 7654fc7d6c1ca029d3560fb8bf33e8cfbbe3af40 Mon Sep 17 00:00:00 2001 From: j Date: Tue, 2 Apr 2024 12:35:01 +0200 Subject: [PATCH 12/48] sub border color --- player/player.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/player/player.py b/player/player.py index 4b56fc8..94aa7ed 100755 --- a/player/player.py +++ b/player/player.py @@ -25,6 +25,7 @@ CONFIG = { "font": "Menlo", "font_size": 30, "font_border": 4, + "sub_border_color": "0.0/0.0/0.0/0.75", "sub_margin": 2 * 36 + 6, "sub_spacing": 0, "vf": None @@ -81,6 +82,7 @@ class Sync(Thread): input_vo_keyboard=True, sub_font_size=CONFIG["font_size"], sub_font=CONFIG["font"], sub_border_size=CONFIG["font_border"], + sub_border_color=CONFIG["sub_border_color"], sub_margin_y=CONFIG["sub_margin"], sub_ass_line_spacing=CONFIG["sub_spacing"], ) @@ -90,6 +92,7 @@ class Sync(Thread): input_vo_keyboard=True, sub_text_font_size=CONFIG["font_size"], sub_text_font=CONFIG["font"], sub_border_size=CONFIG["font_border"], + sub_border_color=CONFIG["sub_border_color"], sub_margin_y=CONFIG["sub_margin"], sub_ass_line_spacing=CONFIG["sub_spacing"], ) From 569c72ee8bcd62fb3b69a6a3a9f4964836a52926 Mon Sep 17 00:00:00 2001 From: j Date: Tue, 2 Apr 2024 12:37:17 +0200 Subject: [PATCH 13/48] skip folders without scene.json --- render.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/render.py b/render.py index 2cb0abf..dec37e9 100644 --- a/render.py +++ b/render.py @@ -626,7 +626,10 @@ def update_subtitles(options): base_prefix = prefix / 'render' / str(base) for folder in os.listdir(base_prefix): folder = base_prefix / folder - with open(folder / "scene.json") as fd: + scene_json = folder / "scene.json" + if not os.path.exists(scene_json): + continue + with open(scene_json) as fd: scene = json.load(fd) offset = 0 subs = [] From 3c9f200fdd983fd65e8d366e4b7d1b0f16d3645d Mon Sep 17 00:00:00 2001 From: j Date: Thu, 4 Apr 2024 23:24:56 +0100 Subject: [PATCH 14/48] don't reset player if its paused --- player/player.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/player/player.py b/player/player.py index 94aa7ed..097b6f0 100755 --- a/player/player.py +++ b/player/player.py @@ -51,6 +51,7 @@ class Main: class Sync(Thread): active = True is_main = True + is_paused = False ready = False destination = "255.255.255.255" reload_check = None @@ -158,7 +159,7 @@ class Sync(Thread): else: self.read_position_main() self.reload_playlist() - if self._tick and abs(time.time() - self._tick) > 60: + if not self.is_paused and self._tick and abs(time.time() - self._tick) > 60: logger.error("player is stuck") self._tick = 0 self.stop() @@ -181,6 +182,7 @@ class Sync(Thread): def s_binding(self, *args): if args[0] != 'd-': return + self.is_paused = True self.mpv.pause = True if self.sax: self.sax.pause = True @@ -189,6 +191,7 @@ class Sync(Thread): def p_binding(self, *args): if args[0] != 'd-': return + self.is_paused = False self.mpv.pause = False if self.sax: self.sax.pause = False @@ -305,8 +308,10 @@ class Sync(Thread): else: self._last_ping = data[0] if data[0] == "pause": + self.is_paused = True self.mpv.pause = True elif data[0] == "play": + self.is_paused = False self.mpv.pause = False else: self.main.time_pos = float(data[0]) From 1ac5574bfcbbc9cfb41cdeaa322a465a332b3992 Mon Sep 17 00:00:00 2001 From: j Date: Thu, 4 Apr 2024 23:37:29 +0100 Subject: [PATCH 15/48] reset tick --- player/player.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/player/player.py b/player/player.py index 097b6f0..769d8b2 100755 --- a/player/player.py +++ b/player/player.py @@ -192,6 +192,7 @@ class Sync(Thread): if args[0] != 'd-': return self.is_paused = False + self._tick = 0 self.mpv.pause = False if self.sax: self.sax.pause = False @@ -312,6 +313,7 @@ class Sync(Thread): self.mpv.pause = True elif data[0] == "play": self.is_paused = False + self._tick = 0 self.mpv.pause = False else: self.main.time_pos = float(data[0]) From 4f5723099630b66a9d7f629716f635a784e2142e Mon Sep 17 00:00:00 2001 From: j Date: Sun, 14 Apr 2024 10:36:13 +0100 Subject: [PATCH 16/48] increase the melody version of Bani's singing by 0.5 db --- render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render.py b/render.py index dec37e9..38e7a16 100644 --- a/render.py +++ b/render.py @@ -142,7 +142,7 @@ def compose(clips, target=150, base=1024, voice_over=None): elif 'Ashley' in voc['src']: a, b = '-9.5', '-1.50' elif 'Melody' in voc['src']: - a, b = '-5.75', '-0.75' + a, b = '-5.25', '-0.25' voc['filter'] = {'volume': a} scene['audio-center']['A1'].append(voc) vo_low = vo.copy() From 793da444ade53a6b973a34e1f6899f12564b9e26 Mon Sep 17 00:00:00 2001 From: j Date: Tue, 30 Apr 2024 16:44:35 +0100 Subject: [PATCH 17/48] fix subtitle import --- management/commands/import_subtitles.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/management/commands/import_subtitles.py b/management/commands/import_subtitles.py index c812325..6dcfe4a 100644 --- a/management/commands/import_subtitles.py +++ b/management/commands/import_subtitles.py @@ -31,7 +31,7 @@ class Command(BaseCommand): with open(filename) as fd: data = fd.read() - data = data.strip().split('\n## ')[1:] + data = ('\n' + data.strip()).split('\n## ')[1:] invalid = [] valid = [] @@ -46,6 +46,9 @@ class Command(BaseCommand): lines = block.split('\n\n') if len(lines) != subtitles_en.count(): print('%s: number of subtitles does not match, en: %s vs %s: %s' % (title, subtitles_en.count(), lang, len(lines))) + if options["test"]: + print(json.dumps(lines, indent=2, ensure_ascii=False)) + print(json.dumps([s.value for s in subtitles_en.order_by('start')], indent=2, ensure_ascii=False)) continue if options["test"]: From 44bd62897ca66df0c0a4025e58696b5bb03b2c01 Mon Sep 17 00:00:00 2001 From: j Date: Thu, 29 Aug 2024 17:36:18 +0200 Subject: [PATCH 18/48] avoid double --- render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render.py b/render.py index 38e7a16..4cb2c4a 100644 --- a/render.py +++ b/render.py @@ -552,7 +552,7 @@ def render_all(options): shutil.move(fragment_prefix / "front-5.1.mp4", fragment_prefix / "front.mp4") for fn in ( "audio-5.1.mp4", - "audio-center.wav", "audio-rear.wav", "audio-center.wav", + "audio-center.wav", "audio-rear.wav", "audio-front.wav", "audio-back.wav", "back-audio.mp4", "fl.wav", "fr.wav", "fc.wav", "lfe.wav", "bl.wav", "br.wav", ): From 9021131e8d6b2596650b581c34479fde1b121e48 Mon Sep 17 00:00:00 2001 From: j Date: Thu, 29 Aug 2024 17:36:30 +0200 Subject: [PATCH 19/48] newline --- render_kdenlive.py | 1 - 1 file changed, 1 deletion(-) diff --git a/render_kdenlive.py b/render_kdenlive.py index 147fbef..2431500 100644 --- a/render_kdenlive.py +++ b/render_kdenlive.py @@ -554,7 +554,6 @@ class KDEnliveProject: ] + value) ] - def properties(self, *props): return [ self.get_element("property", attrib={"name": name}, text=str(value) if value is not None else value) From e221626191b9b45b9778d7932c8164b31614fb36 Mon Sep 17 00:00:00 2001 From: j Date: Tue, 10 Sep 2024 12:42:16 +0100 Subject: [PATCH 20/48] sync group --- player/player.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/player/player.py b/player/player.py index 769d8b2..51077d8 100755 --- a/player/player.py +++ b/player/player.py @@ -28,7 +28,8 @@ CONFIG = { "sub_border_color": "0.0/0.0/0.0/0.75", "sub_margin": 2 * 36 + 6, "sub_spacing": 0, - "vf": None + "vf": None, + "sync_group": None, } @@ -277,6 +278,8 @@ class Sync(Thread): "%0.4f %s" % (self.mpv.time_pos, self.mpv.playlist_current_pos) ).encode() + if CONFIG.get("sync_group"): + msg = (b"%s " % CONFIG["sync_group"]) + msg except: return try: @@ -307,6 +310,11 @@ class Sync(Thread): except OSError: logger.error("socket closed") else: + if CONFIG.get("sync_group"): + if data[0] != str(CONFIG["sync_group"]): + return self.read_position_main() + else: + data = data[1:] self._last_ping = data[0] if data[0] == "pause": self.is_paused = True From 45e2acbbb8463e85e6ea3f8db2ba777b4a630fd5 Mon Sep 17 00:00:00 2001 From: j Date: Tue, 10 Sep 2024 12:46:47 +0100 Subject: [PATCH 21/48] avoid recursion --- player/player.py | 46 +++++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/player/player.py b/player/player.py index 51077d8..216165a 100755 --- a/player/player.py +++ b/player/player.py @@ -302,30 +302,34 @@ class Sync(Thread): def read_position_main(self): self.sock.settimeout(5) - try: - data = self.sock.recvfrom(1024)[0].decode().split(" ", 1) - except socket.timeout: - if self._last_ping != "pause": - logger.error("failed to receive data from main") - except OSError: - logger.error("socket closed") - else: + while True: + try: + data = self.sock.recvfrom(1024)[0].decode().split(" ", 1) + except socket.timeout: + if self._last_ping != "pause": + logger.error("failed to receive data from main") + return + except OSError: + logger.error("socket closed") + return if CONFIG.get("sync_group"): - if data[0] != str(CONFIG["sync_group"]): - return self.read_position_main() - else: + if data[0] == str(CONFIG["sync_group"]): data = data[1:] - self._last_ping = data[0] - if data[0] == "pause": - self.is_paused = True - self.mpv.pause = True - elif data[0] == "play": - self.is_paused = False - self._tick = 0 - self.mpv.pause = False + break else: - self.main.time_pos = float(data[0]) - self.main.playlist_current_pos = int(data[1]) + break + + self._last_ping = data[0] + if data[0] == "pause": + self.is_paused = True + self.mpv.pause = True + elif data[0] == "play": + self.is_paused = False + self._tick = 0 + self.mpv.pause = False + else: + self.main.time_pos = float(data[0]) + self.main.playlist_current_pos = int(data[1]) def adjust_position(self): if self.mpv.time_pos is not None: From c8991438bbd5a003ce8dbefa6d1138b427ec1e1b Mon Sep 17 00:00:00 2001 From: j Date: Tue, 10 Sep 2024 13:03:49 +0100 Subject: [PATCH 22/48] group fixes --- player/player.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/player/player.py b/player/player.py index 216165a..74938ef 100755 --- a/player/player.py +++ b/player/player.py @@ -279,7 +279,7 @@ class Sync(Thread): % (self.mpv.time_pos, self.mpv.playlist_current_pos) ).encode() if CONFIG.get("sync_group"): - msg = (b"%s " % CONFIG["sync_group"]) + msg + msg = ("%s " % CONFIG["sync_group"]).encode() + msg except: return try: @@ -312,9 +312,10 @@ class Sync(Thread): except OSError: logger.error("socket closed") return + if CONFIG.get("sync_group"): if data[0] == str(CONFIG["sync_group"]): - data = data[1:] + data = data[1].split(" ", 1) break else: break From 2a2516bff91e3be63f9ef0f59751fc0dda20f6c6 Mon Sep 17 00:00:00 2001 From: j Date: Tue, 3 Dec 2024 19:35:37 +0000 Subject: [PATCH 23/48] pad audio tracks to scene duration --- render.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/render.py b/render.py index 4cb2c4a..97d412a 100644 --- a/render.py +++ b/render.py @@ -66,6 +66,7 @@ def write_if_new(path, data, mode=''): def compose(clips, target=150, base=1024, voice_over=None): + fps = 24 length = 0 scene = { 'front': { @@ -100,6 +101,7 @@ def compose(clips, target=150, base=1024, voice_over=None): used = [] voice_overs = [] + sub_offset = 0 if voice_over: vo_keys = list(sorted(voice_over)) if chance(seq, 0.5): @@ -118,7 +120,7 @@ def compose(clips, target=150, base=1024, voice_over=None): if vo_min > target: target = vo_min elif vo_min < target: - offset = (target - vo_min) / 2 + offset = int(((target - vo_min) / 2) * fps) / fps scene['audio-center']['A1'].append({ 'blank': True, 'duration': offset @@ -298,6 +300,16 @@ def compose(clips, target=150, base=1024, voice_over=None): }) used.append(clip) print("scene duration %0.3f (target: %0.3f, vo_min: %0.3f)" % (length, target, vo_min)) + if sub_offset < length: + delta = length - sub_offset + scene['audio-center']['A1'].append({ + 'blank': True, + 'duration': delta + }) + scene['audio-rear']['A1'].append({ + 'blank': True, + 'duration': delta + }) return scene, used def get_scene_duration(scene): @@ -321,7 +333,7 @@ def get_offset_duration(prefix): def render(root, scene, prefix=''): fps = 24 files = [] - scene_duration = int(get_scene_duration(scene) * 24) + scene_duration = int(get_scene_duration(scene) * fps) for timeline, data in scene.items(): if timeline == "subtitles": path = os.path.join(root, prefix + "front.srt") @@ -338,14 +350,14 @@ def render(root, scene, prefix=''): #print(track) for clip in clips: project.append_clip(track, clip) - track_durations[track] = int(sum([c['duration'] for c in clips]) * 24) + track_durations[track] = int(sum([c['duration'] for c in clips]) * fps) if timeline.startswith('audio-'): track_duration = project.get_duration() delta = scene_duration - track_duration if delta > 0: for track in track_durations: if track_durations[track] == track_duration: - project.append_clip(track, {'blank': True, "duration": delta/24}) + project.append_clip(track, {'blank': True, "duration": delta/fps}) break path = os.path.join(root, prefix + "%s.kdenlive" % timeline) project_xml = project.to_xml() From 95a41fc2e2a1ab7c1ec1ba4659a97124d4af5307 Mon Sep 17 00:00:00 2001 From: j Date: Tue, 3 Dec 2024 20:12:15 +0000 Subject: [PATCH 24/48] single video render --- management/commands/render.py | 3 + render.py | 133 ++++++++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/management/commands/render.py b/management/commands/render.py index ace0d61..54e66c2 100644 --- a/management/commands/render.py +++ b/management/commands/render.py @@ -16,6 +16,9 @@ class Command(BaseCommand): parser.add_argument('--duration', action='store', dest='duration', default="3600", help='target duration of all fragments in seconds') parser.add_argument('--offset', action='store', dest='offset', default="1024", help='inital offset in pi') parser.add_argument('--no-video', action='store_true', dest='no_video', default=False, help='don\'t render video') + parser.add_argument('--single-file', action='store_true', dest='single_file', default=False, help='render to single video') + parser.add_argument('--keep-audio', action='store_true', dest='keep_audio', default=False, help='keep independent audio tracks') + parser.add_argument('--debug', action='store_true', dest='debug', default=False, help='output more info') def handle(self, **options): render_all(options) diff --git a/render.py b/render.py index 97d412a..32d43a7 100644 --- a/render.py +++ b/render.py @@ -313,6 +313,9 @@ def compose(clips, target=150, base=1024, voice_over=None): return scene, used def get_scene_duration(scene): + if isinstance(scene, str): + with open(scene) as fd: + scene = json.load(fd) duration = 0 for key, value in scene.items(): for name, clips in value.items(): @@ -325,8 +328,6 @@ def get_offset_duration(prefix): for root, folders, files in os.walk(prefix): for f in files: if f == 'scene.json': - path = os.path.join(root, f) - scene = json.load(open(path)) duration += get_scene_duration(scene) return duration @@ -414,6 +415,8 @@ def get_fragments(clips, voice_over, prefix): return fragments +def render_timeline(options): + def render_all(options): prefix = options['prefix'] duration = int(options['duration']) @@ -472,7 +475,7 @@ def render_all(options): scene_json = json.dumps(scene, indent=2, ensure_ascii=False) write_if_new(os.path.join(fragment_prefix, 'scene.json'), scene_json) - if not options['no_video']: + if not options['no_video'] and not options["single_file"]: for timeline in timelines: print(timeline) ext = '.mp4' @@ -502,8 +505,8 @@ def render_all(options): subprocess.call(cmd) os.unlink(timeline.replace('.kdenlive', ext)) - fragment_prefix = Path(fragment_prefix) cmds = [] + fragment_prefix = Path(fragment_prefix) for src, out1, out2 in ( ("audio-front.wav", "fl.wav", "fr.wav"), ("audio-center.wav", "fc.wav", "lfe.wav"), @@ -547,7 +550,8 @@ def render_all(options): fragment_prefix / "back-audio.mp4", ]) for cmd in cmds: - #print(" ".join([str(x) for x in cmd])) + if options["debug"]: + print(" ".join([str(x) for x in cmd])) subprocess.call(cmd) for a, b in ( @@ -562,6 +566,10 @@ def render_all(options): sys.exit(-1) shutil.move(fragment_prefix / "back-audio.mp4", fragment_prefix / "back.mp4") shutil.move(fragment_prefix / "front-5.1.mp4", fragment_prefix / "front.mp4") + if options["keep_audio"]: + shutil.move(fragment_prefix / "audio-center.wav", fragment_prefix / "vocals.wav") + shutil.move(fragment_prefix / "audio-front.wav", fragment_prefix / "foley.wav") + shutil.move(fragment_prefix / "audio-back.wav", fragment_prefix / "original.wav") for fn in ( "audio-5.1.mp4", "audio-center.wav", "audio-rear.wav", @@ -572,6 +580,109 @@ def render_all(options): if os.path.exists(fn): os.unlink(fn) + if options["single_file"]: + cmds = [] + base_prefix = Path(base_prefix) + for timeline in ( + "front", + "back", + "audio-back", + "audio-center", + "audio-front", + "audio-rear", + ): + timelines = list(sorted(glob('%s/*/%s.kdenlive' % (base_prefix, timeline)))) + ext = '.mp4' + if '/audio' in timelines[0]: + ext = '.wav' + out = base_prefix / (timeline + ext) + cmd = [ + 'xvfb-run', '-a', + 'melt' + ] + timelines + [ + '-quiet', + '-consumer', 'avformat:%s' % out, + ] + if ext == '.wav': + cmd += ['vn=1'] + else: + cmd += ['an=1'] + cmd += ['vcodec=libx264', 'x264opts=keyint=1', 'crf=15'] + cmds.append(cmd) + for src, out1, out2 in ( + ("audio-front.wav", "fl.wav", "fr.wav"), + ("audio-center.wav", "fc.wav", "lfe.wav"), + ("audio-rear.wav", "bl.wav", "br.wav"), + ): + cmds.append([ + "ffmpeg", "-y", + "-nostats", "-loglevel", "error", + "-i", base_prefix / src, + "-filter_complex", + "[0:0]pan=1|c0=c0[left]; [0:0]pan=1|c0=c1[right]", + "-map", "[left]", base_prefix / out1, + "-map", "[right]", base_prefix / out2, + ]) + cmds.append([ + "ffmpeg", "-y", + "-nostats", "-loglevel", "error", + "-i", base_prefix / "fl.wav", + "-i", base_prefix / "fr.wav", + "-i", base_prefix / "fc.wav", + "-i", base_prefix / "lfe.wav", + "-i", base_prefix / "bl.wav", + "-i", base_prefix / "br.wav", + "-filter_complex", "[0:a][1:a][2:a][3:a][4:a][5:a]amerge=inputs=6[a]", + "-map", "[a]", "-c:a", "aac", base_prefix / "audio-5.1.mp4" + ]) + cmds.append([ + "ffmpeg", "-y", + "-nostats", "-loglevel", "error", + "-i", base_prefix / "front.mp4", + "-i", base_prefix / "audio-5.1.mp4", + "-c", "copy", + base_prefix / "front-5.1.mp4", + ]) + cmds.append([ + "ffmpeg", "-y", + "-nostats", "-loglevel", "error", + "-i", base_prefix / "back.mp4", + "-i", base_prefix / "audio-back.wav", + "-c:v", "copy", + base_prefix / "back-audio.mp4", + ]) + for cmd in cmds: + if options["debug"]: + print(" ".join([str(x) for x in cmd])) + subprocess.call(cmd) + + for a, b in ( + ("back-audio.mp4", "back.mp4"), + ("front-5.1.mp4", "back.mp4"), + ): + duration_a = ox.avinfo(str(base_prefix / a))['duration'] + duration_b = ox.avinfo(str(base_prefix / b))['duration'] + if duration_a != duration_b: + print('!!', duration_a, base_prefix / a) + print('!!', duration_b, base_prefix / b) + sys.exit(-1) + shutil.move(base_prefix / "back-audio.mp4", base_prefix / "back.mp4") + shutil.move(base_prefix / "front-5.1.mp4", base_prefix / "front.mp4") + if options["keep_audio"]: + shutil.move(base_prefix / "audio-center.wav", base_prefix / "vocals.wav") + shutil.move(base_prefix / "audio-front.wav", base_prefix / "foley.wav") + shutil.move(base_prefix / "audio-back.wav", base_prefix / "original.wav") + for fn in ( + "audio-5.1.mp4", + "audio-center.wav", "audio-rear.wav", + "audio-front.wav", "audio-back.wav", "back-audio.mp4", + "fl.wav", "fr.wav", "fc.wav", "lfe.wav", "bl.wav", "br.wav", + ): + fn = base_prefix / fn + if os.path.exists(fn): + os.unlink(fn) + join_subtitles(base_prefix) + print("Duration - Target: %s Actual: %s" % (target_position, position)) print(json.dumps(dict(stats), sort_keys=True, indent=2)) with open(_cache, "w") as fd: @@ -727,3 +838,15 @@ def render_infinity(options): with open(state_f + "~", "w") as fd: json.dump(state, fd, indent=2) shutil.move(state_f + "~", state_f) + + +def join_subtitles(base_prefix): + subtitles = list(sorted(glob('%s/*/front.srt' % base_prefix))) + data = [] + position = 0 + for srt in subtitles: + scene = srt.replace('front.srt', 'scene.json') + data += ox.srt.load(srt, offset=position) + position += get_scene_duration(scene) + with open(base_prefix / 'front.srt', 'wb') as fd: + fd.write(ox.srt.encode(data)) From b2552d6059ed1fd51953e3cf3407371d9b5f47d5 Mon Sep 17 00:00:00 2001 From: j Date: Wed, 4 Dec 2024 09:16:24 +0000 Subject: [PATCH 25/48] make sure all tracks are exactly the same length --- management/commands/generate_clips.py | 9 ++- render.py | 79 ++++++++++++++++++++------- render_kdenlive.py | 12 +++- 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/management/commands/generate_clips.py b/management/commands/generate_clips.py index 23bc61c..e2ecca9 100644 --- a/management/commands/generate_clips.py +++ b/management/commands/generate_clips.py @@ -23,6 +23,9 @@ def resolve_roman(s): return s.replace(extra, new) return s +def format_duration(duration, fps): + return float('%0.5f' % (round(duration * fps) / fps)) + class Command(BaseCommand): help = 'generate symlinks to clips and clips.json' @@ -68,6 +71,10 @@ class Command(BaseCommand): if not clip["duration"]: print('!!', durations, clip) continue + cd = format_duration(clip["duration"], 24) + #if cd != clip["duration"]: + # print(clip["duration"], '->', cd, durations, clip) + clip["duration"] = cd clip['tags'] = i.data.get('tags', []) clip['editingtags'] = i.data.get('editingtags', []) name = os.path.basename(clip['original']) @@ -117,7 +124,7 @@ class Command(BaseCommand): subs.append(sdata) voice_over[fragment_id][batch] = { "src": target, - "duration": source.duration, + "duration": format_duration(source.duration, 24), "subs": subs } with open(os.path.join(prefix, 'voice_over.json'), 'w') as fd: diff --git a/render.py b/render.py index 32d43a7..36518bf 100644 --- a/render.py +++ b/render.py @@ -11,8 +11,10 @@ import time from pathlib import Path import ox +import lxml.etree + from .pi import random -from .render_kdenlive import KDEnliveProject, _CACHE +from .render_kdenlive import KDEnliveProject, _CACHE, melt_xml, get_melt def random_int(seq, length): @@ -64,6 +66,8 @@ def write_if_new(path, data, mode=''): with open(path, write_mode) as fd: fd.write(data) +def format_duration(duration, fps): + return float('%0.5f' % (round(duration * fps) / fps)) def compose(clips, target=150, base=1024, voice_over=None): fps = 24 @@ -120,7 +124,7 @@ def compose(clips, target=150, base=1024, voice_over=None): if vo_min > target: target = vo_min elif vo_min < target: - offset = int(((target - vo_min) / 2) * fps) / fps + offset = format_duration((target - vo_min) / 2, fps) scene['audio-center']['A1'].append({ 'blank': True, 'duration': offset @@ -188,7 +192,7 @@ def compose(clips, target=150, base=1024, voice_over=None): if length + clip['duration'] > target and length >= vo_min: break print('%06.3f %06.3f' % (length, clip['duration']), os.path.basename(clip['original'])) - length += clip['duration'] + length += int(clip['duration'] * fps) / fps if "foreground" not in clip and "animation" in clip: fg = clip['animation'] @@ -300,8 +304,11 @@ def compose(clips, target=150, base=1024, voice_over=None): }) used.append(clip) print("scene duration %0.3f (target: %0.3f, vo_min: %0.3f)" % (length, target, vo_min)) - if sub_offset < length: - delta = length - sub_offset + scene_duration = int(get_scene_duration(scene) * fps) + sub_offset = int(sub_offset * fps) + if sub_offset < scene_duration: + delta = format_duration((scene_duration - sub_offset) / fps, fps) + print(">> add %0.3f of silence.. %0.3f (scene_duration)" % (delta, scene_duration / fps)) scene['audio-center']['A1'].append({ 'blank': True, 'duration': delta @@ -310,8 +317,24 @@ def compose(clips, target=150, base=1024, voice_over=None): 'blank': True, 'duration': delta }) + elif sub_offset > scene_duration: + delta = format_duration((scene_duration - sub_offset) / fps, fps) + scene['audio-center']['A1'][-1]["duration"] += delta + scene['audio-rear']['A1'][-1]["duration"] += delta + print("WTF, needed to cut %s new duration: %s" % (delta, scene['audio-center']['A1'][-1]["duration"])) + print(scene['audio-center']['A1'][-1]) return scene, used +def get_track_duration(scene, k, n): + duration = 0 + for key, value in scene.items(): + if key == k: + for name, clips in value.items(): + if name == n: + for clip in clips: + duration += int(clip['duration'] * 24) + return duration / 24 + def get_scene_duration(scene): if isinstance(scene, str): with open(scene) as fd: @@ -320,8 +343,8 @@ def get_scene_duration(scene): for key, value in scene.items(): for name, clips in value.items(): for clip in clips: - duration += clip['duration'] - return duration + duration += int(clip['duration'] * 24) + return duration / 24 def get_offset_duration(prefix): duration = 0 @@ -331,7 +354,8 @@ def get_offset_duration(prefix): duration += get_scene_duration(scene) return duration -def render(root, scene, prefix=''): +def render(root, scene, prefix='', options=None): + if options is None: options = {} fps = 24 files = [] scene_duration = int(get_scene_duration(scene) * fps) @@ -351,7 +375,7 @@ def render(root, scene, prefix=''): #print(track) for clip in clips: project.append_clip(track, clip) - track_durations[track] = int(sum([c['duration'] for c in clips]) * fps) + track_durations[track] = sum([int(c['duration'] * fps) for c in clips]) if timeline.startswith('audio-'): track_duration = project.get_duration() delta = scene_duration - track_duration @@ -359,13 +383,34 @@ def render(root, scene, prefix=''): for track in track_durations: if track_durations[track] == track_duration: project.append_clip(track, {'blank': True, "duration": delta/fps}) - break + path = os.path.join(root, prefix + "%s.kdenlive" % timeline) project_xml = project.to_xml() write_if_new(path, project_xml) + + if options["debug"]: + # check duration + out_duration = get_project_duration(path) + p_duration = project.get_duration() + print(path, 'out: %s, project: %s, scene: %s' %(out_duration, p_duration, scene_duration)) + if p_duration != scene_duration: + print(path, 'FAIL project: %s, scene: %s' %(p_duration, scene_duration)) + _cache = os.path.join(root, "cache.json") + with open(_cache, "w") as fd: + json.dump(_CACHE, fd) + sys.exit(1) + if out_duration != p_duration: + print(path, 'fail got: %s expected: %s' %(out_duration, p_duration)) + sys.exit(1) + files.append(path) return files +def get_project_duration(file): + out = melt_xml(file) + chain = lxml.etree.fromstring(out).xpath('producer')[0] + duration = int(chain.attrib['out']) + 1 + return duration def get_fragments(clips, voice_over, prefix): import itemlist.models @@ -415,8 +460,6 @@ def get_fragments(clips, voice_over, prefix): return fragments -def render_timeline(options): - def render_all(options): prefix = options['prefix'] duration = int(options['duration']) @@ -470,7 +513,7 @@ def render_all(options): elif position < target_position: target = target + 0.1 * fragment_target - timelines = render(prefix, scene, fragment_prefix[len(prefix) + 1:] + '/') + timelines = render(prefix, scene, fragment_prefix[len(prefix) + 1:] + '/', options) scene_json = json.dumps(scene, indent=2, ensure_ascii=False) write_if_new(os.path.join(fragment_prefix, 'scene.json'), scene_json) @@ -481,9 +524,8 @@ def render_all(options): ext = '.mp4' if '/audio' in timeline: ext = '.wav' - cmd = [ - 'xvfb-run', '-a', - 'melt', timeline, + cmd = get_melt() + [ + timeline, '-quiet', '-consumer', 'avformat:%s' % timeline.replace('.kdenlive', ext), ] @@ -596,10 +638,7 @@ def render_all(options): if '/audio' in timelines[0]: ext = '.wav' out = base_prefix / (timeline + ext) - cmd = [ - 'xvfb-run', '-a', - 'melt' - ] + timelines + [ + cmd = get_melt() + timelines + [ '-quiet', '-consumer', 'avformat:%s' % out, ] diff --git a/render_kdenlive.py b/render_kdenlive.py index 2431500..cdf755b 100644 --- a/render_kdenlive.py +++ b/render_kdenlive.py @@ -4,6 +4,7 @@ import subprocess import lxml.etree import uuid import os +import sys _CACHE = {} _IDS = defaultdict(int) @@ -12,6 +13,14 @@ def get_propery(element, name): return element.xpath('property[@name="%s"]' % name)[0].text +def get_melt(): + cmd = ['melt'] + if 'XDG_RUNTIME_DIR' not in os.environ: + os.environ['XDG_RUNTIME_DIR'] = '/tmp/runtime-pandora' + if 'DISPLAY' not in os.environ: + cmd = ['xvfb-run', '-a'] + cmd + return cmd + def melt_xml(file): out = None real_path = os.path.realpath(file) @@ -20,7 +29,8 @@ def melt_xml(file): if os.stat(real_path).st_mtime != ts: out = None if not out: - out = subprocess.check_output(['melt', file, '-consumer', 'xml']).decode() + cmd = get_melt() + [file, '-consumer', 'xml'] + out = subprocess.check_output(cmd).decode() _CACHE[file] = [os.stat(real_path).st_mtime, out] return out From f7dfa963895d372d1f24a0e1dbe9140a29b820d3 Mon Sep 17 00:00:00 2001 From: j Date: Wed, 12 Feb 2025 17:50:53 +0100 Subject: [PATCH 26/48] fix infinity --- management/commands/infinity.py | 3 +++ render.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/management/commands/infinity.py b/management/commands/infinity.py index 1dc85e3..e642092 100644 --- a/management/commands/infinity.py +++ b/management/commands/infinity.py @@ -14,6 +14,9 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--prefix', action='store', dest='prefix', default="/srv/t_for_time", help='prefix to build clips in') parser.add_argument('--duration', action='store', dest='duration', default="3600", help='target duration of all fragments in seconds') + parser.add_argument('--single-file', action='store_true', dest='single_file', default=False, help='render to single video') + parser.add_argument('--keep-audio', action='store_true', dest='keep_audio', default=False, help='keep independent audio tracks') + parser.add_argument('--debug', action='store_true', dest='debug', default=False, help='output more info') def handle(self, **options): render_infinity(options) diff --git a/render.py b/render.py index 36518bf..c1b9af5 100644 --- a/render.py +++ b/render.py @@ -848,7 +848,7 @@ def render_infinity(options): "max-items": 30, "no_video": False, } - for key in ("prefix", "duration"): + for key in ("prefix", "duration", "debug", "single_file", "keep_audio"): state[key] = options[key] while True: From 0d5a6cb6ba8059cb00eb1315f2a0b3f9da556ea2 Mon Sep 17 00:00:00 2001 From: j Date: Tue, 22 Apr 2025 12:44:06 +0200 Subject: [PATCH 27/48] add default --- config.jsonc | 1 + 1 file changed, 1 insertion(+) diff --git a/config.jsonc b/config.jsonc index e598d2e..58cc505 100644 --- a/config.jsonc +++ b/config.jsonc @@ -63,6 +63,7 @@ examples (config.SITENAME.jsonc) that are part of this pan.do/ra distribution. "canExportAnnotations": {"member": true, "staff": true, "admin": true}, "canImportAnnotations": {"member": true, "staff": true, "admin": true}, "canImportItems": {"member": true, "staff": true, "admin": true}, + "canTranscribeAudio": {}, "canManageDocuments": {"member": true, "staff": true, "admin": true}, "canManageEntities": {"member": true, "staff": true, "admin": true}, "canManageHome": {"staff": true, "admin": true}, From 824170afb450185601c375606ba73fc52154344b Mon Sep 17 00:00:00 2001 From: j Date: Thu, 8 May 2025 08:09:25 +0100 Subject: [PATCH 28/48] check front --- render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render.py b/render.py index c1b9af5..547b4b0 100644 --- a/render.py +++ b/render.py @@ -598,7 +598,7 @@ def render_all(options): for a, b in ( ("back-audio.mp4", "back.mp4"), - ("front-5.1.mp4", "back.mp4"), + ("front-5.1.mp4", "front.mp4"), ): duration_a = ox.avinfo(str(fragment_prefix / a))['duration'] duration_b = ox.avinfo(str(fragment_prefix / b))['duration'] From bb22ffbaaee36e6840adcb9f926bcd0cb405fe2a Mon Sep 17 00:00:00 2001 From: j Date: Sat, 10 May 2025 10:04:25 +0100 Subject: [PATCH 29/48] add stereo downmix --- management/commands/infinity.py | 1 + management/commands/render.py | 1 + render.py | 39 ++++++++++++++++++++++++--------- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/management/commands/infinity.py b/management/commands/infinity.py index e642092..73e91dc 100644 --- a/management/commands/infinity.py +++ b/management/commands/infinity.py @@ -16,6 +16,7 @@ class Command(BaseCommand): parser.add_argument('--duration', action='store', dest='duration', default="3600", help='target duration of all fragments in seconds') parser.add_argument('--single-file', action='store_true', dest='single_file', default=False, help='render to single video') parser.add_argument('--keep-audio', action='store_true', dest='keep_audio', default=False, help='keep independent audio tracks') + parser.add_argument('--stereo-downmix', action='store_true', dest='stereo_downmix', default=False, help='stereo downmix') parser.add_argument('--debug', action='store_true', dest='debug', default=False, help='output more info') def handle(self, **options): diff --git a/management/commands/render.py b/management/commands/render.py index 54e66c2..91e8fa5 100644 --- a/management/commands/render.py +++ b/management/commands/render.py @@ -18,6 +18,7 @@ class Command(BaseCommand): parser.add_argument('--no-video', action='store_true', dest='no_video', default=False, help='don\'t render video') parser.add_argument('--single-file', action='store_true', dest='single_file', default=False, help='render to single video') parser.add_argument('--keep-audio', action='store_true', dest='keep_audio', default=False, help='keep independent audio tracks') + parser.add_argument('--stereo-downmix', action='store_true', dest='stereo_downmix', default=False, help='stereo downmix') parser.add_argument('--debug', action='store_true', dest='debug', default=False, help='output more info') def handle(self, **options): diff --git a/render.py b/render.py index 547b4b0..b70fee8 100644 --- a/render.py +++ b/render.py @@ -575,19 +575,37 @@ def render_all(options): "-filter_complex", "[0:a][1:a][2:a][3:a][4:a][5:a]amerge=inputs=6[a]", "-map", "[a]", "-c:a", "aac", fragment_prefix / "audio-5.1.mp4" ]) + audio_front = "audio-5.1.mp4" + audio_back = "audio-back.wav" + copy = '-c' + if options["stereo_downmix"]: + cmds.append([ + "ffmpeg", "-y", + "-nostats", "-loglevel", "error", + "-i", fragment_prefix / "audio-front.wav", + "-i", fragment_prefix / "audio-center.wav", + "-i", fragment_prefix / "audio-rear.wav", + "-i", fragment_prefix / audio_back, + "-filter_complex", "[0:a][1:a][2:a][3:a]amerge=inputs=2[a]", + "-map", "[a]", '-ac', '2', fragment_prefix / "audio-stereo.wav" + ]) + audio_front = "audio-stereo.wav" + audio_back = "audio-stereo.wav" + copy = '-c:v' + cmds.append([ "ffmpeg", "-y", "-nostats", "-loglevel", "error", "-i", fragment_prefix / "front.mp4", - "-i", fragment_prefix / "audio-5.1.mp4", - "-c", "copy", - fragment_prefix / "front-5.1.mp4", + "-i", fragment_prefix / audio_front, + copy, "copy", + fragment_prefix / "front-mixed.mp4", ]) cmds.append([ "ffmpeg", "-y", "-nostats", "-loglevel", "error", "-i", fragment_prefix / "back.mp4", - "-i", fragment_prefix / "audio-back.wav", + "-i", fragment_prefix / audio_back, "-c:v", "copy", fragment_prefix / "back-audio.mp4", ]) @@ -598,7 +616,7 @@ def render_all(options): for a, b in ( ("back-audio.mp4", "back.mp4"), - ("front-5.1.mp4", "front.mp4"), + ("front-mixed.mp4", "front.mp4"), ): duration_a = ox.avinfo(str(fragment_prefix / a))['duration'] duration_b = ox.avinfo(str(fragment_prefix / b))['duration'] @@ -607,7 +625,7 @@ def render_all(options): print('!!', duration_b, fragment_prefix / b) sys.exit(-1) shutil.move(fragment_prefix / "back-audio.mp4", fragment_prefix / "back.mp4") - shutil.move(fragment_prefix / "front-5.1.mp4", fragment_prefix / "front.mp4") + shutil.move(fragment_prefix / "front-mixed.mp4", fragment_prefix / "front.mp4") if options["keep_audio"]: shutil.move(fragment_prefix / "audio-center.wav", fragment_prefix / "vocals.wav") shutil.move(fragment_prefix / "audio-front.wav", fragment_prefix / "foley.wav") @@ -617,6 +635,7 @@ def render_all(options): "audio-center.wav", "audio-rear.wav", "audio-front.wav", "audio-back.wav", "back-audio.mp4", "fl.wav", "fr.wav", "fc.wav", "lfe.wav", "bl.wav", "br.wav", + "audio-stereo.wav", ): fn = fragment_prefix / fn if os.path.exists(fn): @@ -680,7 +699,7 @@ def render_all(options): "-i", base_prefix / "front.mp4", "-i", base_prefix / "audio-5.1.mp4", "-c", "copy", - base_prefix / "front-5.1.mp4", + base_prefix / "front-mixed.mp4", ]) cmds.append([ "ffmpeg", "-y", @@ -697,7 +716,7 @@ def render_all(options): for a, b in ( ("back-audio.mp4", "back.mp4"), - ("front-5.1.mp4", "back.mp4"), + ("front-mixed.mp4", "back.mp4"), ): duration_a = ox.avinfo(str(base_prefix / a))['duration'] duration_b = ox.avinfo(str(base_prefix / b))['duration'] @@ -706,7 +725,7 @@ def render_all(options): print('!!', duration_b, base_prefix / b) sys.exit(-1) shutil.move(base_prefix / "back-audio.mp4", base_prefix / "back.mp4") - shutil.move(base_prefix / "front-5.1.mp4", base_prefix / "front.mp4") + shutil.move(base_prefix / "front-mixed.mp4", base_prefix / "front.mp4") if options["keep_audio"]: shutil.move(base_prefix / "audio-center.wav", base_prefix / "vocals.wav") shutil.move(base_prefix / "audio-front.wav", base_prefix / "foley.wav") @@ -848,7 +867,7 @@ def render_infinity(options): "max-items": 30, "no_video": False, } - for key in ("prefix", "duration", "debug", "single_file", "keep_audio"): + for key in ("prefix", "duration", "debug", "single_file", "keep_audio", "stereo_downmix"): state[key] = options[key] while True: From 188daf94f4cd0eb189da1dc8e9e0b68f2abade1e Mon Sep 17 00:00:00 2001 From: j Date: Tue, 20 May 2025 12:02:36 +0100 Subject: [PATCH 30/48] fix stereo downmix --- render.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/render.py b/render.py index b70fee8..361f8ea 100644 --- a/render.py +++ b/render.py @@ -586,8 +586,9 @@ def render_all(options): "-i", fragment_prefix / "audio-center.wav", "-i", fragment_prefix / "audio-rear.wav", "-i", fragment_prefix / audio_back, - "-filter_complex", "[0:a][1:a][2:a][3:a]amerge=inputs=2[a]", - "-map", "[a]", '-ac', '2', fragment_prefix / "audio-stereo.wav" + "-filter_complex", + "amix=inputs=4:duration=longest:dropout_transition=0", + '-ac', '2', fragment_prefix / "audio-stereo.wav" ]) audio_front = "audio-stereo.wav" audio_back = "audio-stereo.wav" From e12936ec0f023a37eb29a7d1dabc03f806abab03 Mon Sep 17 00:00:00 2001 From: j Date: Tue, 20 May 2025 12:14:14 +0100 Subject: [PATCH 31/48] load config from file --- etc/systemd/system/render-infinity.service | 2 +- management/commands/infinity.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/etc/systemd/system/render-infinity.service b/etc/systemd/system/render-infinity.service index 9afaad8..a016046 100644 --- a/etc/systemd/system/render-infinity.service +++ b/etc/systemd/system/render-infinity.service @@ -9,7 +9,7 @@ User=pandora Group=pandora Nice=19 WorkingDirectory=/srv/pandora/pandora -ExecStart=/srv/pandora/pandora/manage.py infinity +ExecStart=/srv/pandora/pandora/manage.py infinity --config /etc/infinity.json [Install] WantedBy=multi-user.target diff --git a/management/commands/infinity.py b/management/commands/infinity.py index 73e91dc..0ca70fa 100644 --- a/management/commands/infinity.py +++ b/management/commands/infinity.py @@ -13,6 +13,7 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--prefix', action='store', dest='prefix', default="/srv/t_for_time", help='prefix to build clips in') + parser.add_argument('--config', action='store', dest='config', default=None, help='config') parser.add_argument('--duration', action='store', dest='duration', default="3600", help='target duration of all fragments in seconds') parser.add_argument('--single-file', action='store_true', dest='single_file', default=False, help='render to single video') parser.add_argument('--keep-audio', action='store_true', dest='keep_audio', default=False, help='keep independent audio tracks') @@ -20,4 +21,11 @@ class Command(BaseCommand): parser.add_argument('--debug', action='store_true', dest='debug', default=False, help='output more info') def handle(self, **options): + if options.get("config"): + if os.path.exists(options["config"]): + with open(options["config"]) as fd: + config = json.load(fd) + options.update(config) + else: + print("unable to load config %s" % options["config"]) render_infinity(options) From 34b111c343f0a16179dcfcc4904d9c320ba5b244 Mon Sep 17 00:00:00 2001 From: j Date: Wed, 21 May 2025 13:17:50 +0100 Subject: [PATCH 32/48] adjust volume for stereo_downmix --- render.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/render.py b/render.py index 361f8ea..4cd50e3 100644 --- a/render.py +++ b/render.py @@ -69,7 +69,9 @@ def write_if_new(path, data, mode=''): def format_duration(duration, fps): return float('%0.5f' % (round(duration * fps) / fps)) -def compose(clips, target=150, base=1024, voice_over=None): +def compose(clips, target=150, base=1024, voice_over=None, options=None): + if options is None: + options = {} fps = 24 length = 0 scene = { @@ -149,6 +151,18 @@ def compose(clips, target=150, base=1024, voice_over=None): a, b = '-9.5', '-1.50' elif 'Melody' in voc['src']: a, b = '-5.25', '-0.25' + if options.get('stereo_downmix'): + a, b = '-9', '-1' + if 'Whispered' in voc['src']: + a, b = '-6', '2' + elif 'Read' in voc['src']: + a, b = '-5.75', '2.25' + elif 'Free' in voc['src']: + a, b = '-6.8', '3.2' + elif 'Ashley' in voc['src']: + a, b = '-7.5', '0.50' + elif 'Melody' in voc['src']: + a, b = '-3.25', '1.75' voc['filter'] = {'volume': a} scene['audio-center']['A1'].append(voc) vo_low = vo.copy() @@ -285,10 +299,13 @@ def compose(clips, target=150, base=1024, voice_over=None): blur = seq() * 3 if blur: scene['back']['V1'][-1]['filter']['blur'] = blur + volume_back = '-8.2' + if options.get('stereo_downmix'): + volume_back = '-6.2' scene['audio-back']['A1'].append({ 'duration': clip['duration'], 'src': clip['original'], - 'filter': {'volume': '-8.2'}, + 'filter': {'volume': volume_back}, }) # TBD: Foley cf_volume = '-2.5' @@ -498,7 +515,13 @@ def render_all(options): fragment_clips = fragment['clips'] unused_fragment_clips = [c for c in fragment_clips if c not in clips_used] print('fragment clips', len(fragment_clips), 'unused', len(unused_fragment_clips)) - scene, used = compose(unused_fragment_clips, target=target, base=fragment_base, voice_over=fragment['voice_over']) + scene, used = compose( + unused_fragment_clips, + target=target, + base=fragment_base, + voice_over=fragment['voice_over'], + options=options + ) clips_used += used scene_duration = get_scene_duration(scene) print("%s %6.3f -> %6.3f (%6.3f)" % (name, target, scene_duration, fragment_target)) From d966d27dca41eb284f48296fc4a7f122bba0c09b Mon Sep 17 00:00:00 2001 From: j Date: Wed, 21 May 2025 14:22:21 +0100 Subject: [PATCH 33/48] add faststart --- render.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/render.py b/render.py index 4cd50e3..7415654 100644 --- a/render.py +++ b/render.py @@ -623,6 +623,7 @@ def render_all(options): "-i", fragment_prefix / "front.mp4", "-i", fragment_prefix / audio_front, copy, "copy", + '-movflags', '+faststart', fragment_prefix / "front-mixed.mp4", ]) cmds.append([ @@ -631,6 +632,7 @@ def render_all(options): "-i", fragment_prefix / "back.mp4", "-i", fragment_prefix / audio_back, "-c:v", "copy", + '-movflags', '+faststart', fragment_prefix / "back-audio.mp4", ]) for cmd in cmds: From a227d7d258593a1a1079350f13cb1c643db0d189 Mon Sep 17 00:00:00 2001 From: j Date: Wed, 21 May 2025 14:23:23 +0100 Subject: [PATCH 34/48] add faststart --- render.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/render.py b/render.py index 7415654..abcc3f1 100644 --- a/render.py +++ b/render.py @@ -623,7 +623,7 @@ def render_all(options): "-i", fragment_prefix / "front.mp4", "-i", fragment_prefix / audio_front, copy, "copy", - '-movflags', '+faststart', + "-movflags", "+faststart", fragment_prefix / "front-mixed.mp4", ]) cmds.append([ @@ -632,7 +632,7 @@ def render_all(options): "-i", fragment_prefix / "back.mp4", "-i", fragment_prefix / audio_back, "-c:v", "copy", - '-movflags', '+faststart', + "-movflags", "+faststart", fragment_prefix / "back-audio.mp4", ]) for cmd in cmds: @@ -725,6 +725,7 @@ def render_all(options): "-i", base_prefix / "front.mp4", "-i", base_prefix / "audio-5.1.mp4", "-c", "copy", + "-movflags", "+faststart", base_prefix / "front-mixed.mp4", ]) cmds.append([ @@ -733,6 +734,7 @@ def render_all(options): "-i", base_prefix / "back.mp4", "-i", base_prefix / "audio-back.wav", "-c:v", "copy", + "-movflags", "+faststart", base_prefix / "back-audio.mp4", ]) for cmd in cmds: From 38a893aae7a52e6a4c1fef32cb476b756c9d50a4 Mon Sep 17 00:00:00 2001 From: j Date: Wed, 21 May 2025 14:25:01 +0100 Subject: [PATCH 35/48] tweak orig volume --- render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render.py b/render.py index abcc3f1..7c1493d 100644 --- a/render.py +++ b/render.py @@ -301,7 +301,7 @@ def compose(clips, target=150, base=1024, voice_over=None, options=None): scene['back']['V1'][-1]['filter']['blur'] = blur volume_back = '-8.2' if options.get('stereo_downmix'): - volume_back = '-6.2' + volume_back = '-7.2' scene['audio-back']['A1'].append({ 'duration': clip['duration'], 'src': clip['original'], From e31df1790c67447ee645de6c1bb26c032e01338e Mon Sep 17 00:00:00 2001 From: j Date: Wed, 8 Oct 2025 11:35:10 +0100 Subject: [PATCH 36/48] set defaults --- render.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/render.py b/render.py index 7c1493d..56fc94a 100644 --- a/render.py +++ b/render.py @@ -885,19 +885,24 @@ def render_infinity(options): prefix = options['prefix'] duration = int(options['duration']) + defaults = { + "offset": 100, + "max-items": 30, + "no_video": False, + } state_f = os.path.join(prefix, "infinity.json") if os.path.exists(state_f): with open(state_f) as fd: state = json.load(fd) else: - state = { - "offset": 100, - "max-items": 30, - "no_video": False, - } + state = {} for key in ("prefix", "duration", "debug", "single_file", "keep_audio", "stereo_downmix"): state[key] = options[key] + for key in defaults: + if key not in state: + state[key] = defaults[key] + while True: render_prefix = state["prefix"] + "/render/" current = [ From a7816225a5b41754efc9abf20d971ca18bb12a52 Mon Sep 17 00:00:00 2001 From: j Date: Wed, 8 Oct 2025 13:25:22 +0100 Subject: [PATCH 37/48] censored version --- management/commands/generate_clips.py | 28 +++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/management/commands/generate_clips.py b/management/commands/generate_clips.py index e2ecca9..4c96377 100644 --- a/management/commands/generate_clips.py +++ b/management/commands/generate_clips.py @@ -1,6 +1,7 @@ import json import os import re +import subprocess from collections import defaultdict from django.core.management.base import BaseCommand @@ -33,6 +34,7 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--lang', action='store', dest='lang', default=None, help='subtitle language') parser.add_argument('--prefix', action='store', dest='prefix', default="/srv/t_for_time", help='prefix to build clips in') + parser.add_argument('--censored', action='store', dest='censored', default=None, help='censor items from list') def handle(self, **options): prefix = options['prefix'] @@ -45,8 +47,12 @@ class Command(BaseCommand): tlang = None if lang == "en": lang = None + if options['censored']: + censored_list = itemlist.models.List.get(options["censored"]) + censored = list(censored_list.get_items(censored_list.user).all().values_list('public_id', flat=True)) clips = [] for i in item.models.Item.objects.filter(sort__type='original'): + original_target = "" qs = item.models.Item.objects.filter(data__title=i.data['title']).exclude(id=i.id) if qs.count() >= 1: clip = {} @@ -65,6 +71,10 @@ class Command(BaseCommand): if os.path.islink(target): os.unlink(target) os.symlink(source, target) + if type_ == "original": + original_target = target + if options['censored'] and e.public_id in censored: + target = '/srv/t_for_time/censored.mp4' clip[type_] = target durations.append(e.files.filter(selected=True)[0].duration) clip["duration"] = min(durations) @@ -77,8 +87,7 @@ class Command(BaseCommand): clip["duration"] = cd clip['tags'] = i.data.get('tags', []) clip['editingtags'] = i.data.get('editingtags', []) - name = os.path.basename(clip['original']) - + name = os.path.basename(original_target) seqid = re.sub("Hotel Aporia_(\d+)", "S\\1_", name) seqid = re.sub("Night March_(\d+)", "S\\1_", seqid) seqid = re.sub("_(\d+)H_(\d+)", "_S\\1\\2_", seqid) @@ -129,3 +138,18 @@ class Command(BaseCommand): } with open(os.path.join(prefix, 'voice_over.json'), 'w') as fd: json.dump(voice_over, fd, indent=2, ensure_ascii=False) + + if options['censored']: + censored_mp4 = '/srv/t_for_time/censored.mp4' + if not os.path.exists(censored_mp4): + cmd = [ + "ffmpeg", + "-nostats", "-loglevel", "error", + "-f", "lavfi", + "-i", "color=color=white:size=1920x1080:rate=24", + "-t", "3600", + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + censored_mp4 + ] + subprocess.call(cmd) From 454c4eeaa5884ef7e7523b43f78c3f560826cafa Mon Sep 17 00:00:00 2001 From: j Date: Mon, 20 Oct 2025 10:31:42 +0100 Subject: [PATCH 38/48] skip missing config --- player/player.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/player/player.py b/player/player.py index 74938ef..28b41e0 100755 --- a/player/player.py +++ b/player/player.py @@ -426,8 +426,11 @@ def main(): log_format = '%(asctime)s:%(levelname)s:%(name)s:%(message)s' logging.basicConfig(level=logging.DEBUG, format=log_format) if args.config: - with open(args.config) as fd: - CONFIG.update(json.load(fd)) + if os.path.exists(args.config): + with open(args.config) as fd: + CONFIG.update(json.load(fd)) + else: + logger.error("config file %s does not exist, skipping", args.config) base = os.path.dirname(os.path.abspath(__file__)) #os.chdir(base) From bde25f576272edc6610707ebf02fbb7f9ffd255f Mon Sep 17 00:00:00 2001 From: j Date: Mon, 20 Oct 2025 10:33:34 +0100 Subject: [PATCH 39/48] tag regexp strings --- install.py | 2 +- management/commands/generate_clips.py | 8 ++++---- render.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/install.py b/install.py index 1ad6f4d..20a4484 100755 --- a/install.py +++ b/install.py @@ -100,7 +100,7 @@ if os.path.exists('__init__.py'): local_settings += '\nLOCAL_APPS = ["%s"]\n' % name local_settings_changed = True else: - apps = re.compile('(LOCAL_APPS.*?)\]', re.DOTALL).findall(local_settings)[0] + apps = re.compile(r'(LOCAL_APPS.*?)\]', re.DOTALL).findall(local_settings)[0] if name not in apps: new_apps = apps.strip() + ',\n"%s"\n' % name local_settings = local_settings.replace(apps, new_apps) diff --git a/management/commands/generate_clips.py b/management/commands/generate_clips.py index 4c96377..1be54fa 100644 --- a/management/commands/generate_clips.py +++ b/management/commands/generate_clips.py @@ -14,7 +14,7 @@ from ...render import get_srt def resolve_roman(s): - extra = re.compile('^\d+(.*?)$').findall(s) + extra = re.compile(r'^\d+(.*?)$').findall(s) if extra: extra = extra[0].lower() new = { @@ -88,9 +88,9 @@ class Command(BaseCommand): clip['tags'] = i.data.get('tags', []) clip['editingtags'] = i.data.get('editingtags', []) name = os.path.basename(original_target) - seqid = re.sub("Hotel Aporia_(\d+)", "S\\1_", name) - seqid = re.sub("Night March_(\d+)", "S\\1_", seqid) - seqid = re.sub("_(\d+)H_(\d+)", "_S\\1\\2_", seqid) + seqid = re.sub(r"Hotel Aporia_(\d+)", "S\\1_", name) + seqid = re.sub(r"Night March_(\d+)", "S\\1_", seqid) + seqid = re.sub(r"_(\d+)H_(\d+)", "_S\\1\\2_", seqid) seqid = seqid.split('_')[:2] seqid = [b[1:] if b[0] in ('B', 'S') else '0' for b in seqid] seqid[1] = resolve_roman(seqid[1]) diff --git a/render.py b/render.py index 56fc94a..f97a60a 100644 --- a/render.py +++ b/render.py @@ -61,7 +61,7 @@ def write_if_new(path, data, mode=''): old = "" is_new = data != old if path.endswith(".kdenlive"): - is_new = re.sub('\{.{36}\}', '', data) != re.sub('\{.{36}\}', '', old) + is_new = re.sub(r'\{.{36}\}', '', data) != re.sub(r'\{.{36}\}', '', old) if is_new: with open(path, write_mode) as fd: fd.write(data) From 992d39bc22756e523b4191c736dbde2a13917e7b Mon Sep 17 00:00:00 2001 From: j Date: Mon, 20 Oct 2025 10:39:28 +0100 Subject: [PATCH 40/48] try to load config by default --- player/player-back.service | 2 +- player/player-front.service | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/player/player-back.service b/player/player-back.service index 4804318..b0a2f14 100644 --- a/player/player-back.service +++ b/player/player-back.service @@ -7,7 +7,7 @@ Wants=network-online.target Type=simple Restart=on-failure KillSignal=SIGINT -ExecStart=/srv/pandora/t_for_time/player/player.py --mode peer --playlist /srv/t_for_time/render/back.m3u +ExecStart=/srv/pandora/t_for_time/player/player.py --mode peer --playlist /srv/t_for_time/render/back.m3u --config /srv/t_for_time/render/back.json [Install] WantedBy=graphical-session.target diff --git a/player/player-front.service b/player/player-front.service index 7470316..d514da8 100644 --- a/player/player-front.service +++ b/player/player-front.service @@ -6,7 +6,7 @@ After=gnome-session.target network-online.target Type=simple Restart=on-failure KillSignal=SIGINT -ExecStart=/srv/pandora/t_for_time/player/player.py --mode main --playlist /srv/t_for_time/render/front.m3u +ExecStart=/srv/pandora/t_for_time/player/player.py --mode main --playlist /srv/t_for_time/render/front.m3u --config /srv/t_for_time/render/front.json [Install] WantedBy=graphical-session.target From 187d853b3a407996f6bcd96db4edac548246e83e Mon Sep 17 00:00:00 2001 From: j Date: Thu, 13 Nov 2025 09:30:35 +0100 Subject: [PATCH 41/48] fix censored selection --- management/commands/generate_clips.py | 1 + render.py | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/management/commands/generate_clips.py b/management/commands/generate_clips.py index 1be54fa..bf0f1d5 100644 --- a/management/commands/generate_clips.py +++ b/management/commands/generate_clips.py @@ -74,6 +74,7 @@ class Command(BaseCommand): if type_ == "original": original_target = target if options['censored'] and e.public_id in censored: + clip[type_ + "_censored"] = target target = '/srv/t_for_time/censored.mp4' clip[type_] = target durations.append(e.files.filter(selected=True)[0].duration) diff --git a/render.py b/render.py index f97a60a..0d45c0e 100644 --- a/render.py +++ b/render.py @@ -469,7 +469,11 @@ def get_fragments(clips, voice_over, prefix): fragment['clips'] = [] for clip in clips: #if set(clip['tags']) & set(fragment['tags']) and not set(clip['tags']) & set(fragment['anti-tags']): - if clip['original'] in originals: + key = 'original' + original = clip['original'] + if 'original_censored' in clip: + original = clip['original_censored'] + if original in originals: fragment['clips'].append(clip) fragment["voice_over"] = voice_over.get(str(fragment["id"]), {}) fragments.append(fragment) From b1db77de536cc0248e2e0a04e65f6789483f6a0f Mon Sep 17 00:00:00 2001 From: j Date: Fri, 14 Nov 2025 15:23:58 +0100 Subject: [PATCH 42/48] remove demo description --- config.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.jsonc b/config.jsonc index 58cc505..79a4e2a 100644 --- a/config.jsonc +++ b/config.jsonc @@ -1015,7 +1015,7 @@ examples (config.SITENAME.jsonc) that are part of this pan.do/ra distribution. the system (from). */ "site": { - "description": "This is a demo of pan.do/ra - a free, open source media archive. It allows you to manage large, decentralized collections of video, to collaboratively create metadata and time-based annotations, and to serve your archive as a cutting-edge web application.", + "description": "T for Time - pan.do/ra", "email": { // E-mail address in contact form (to) "contact": "system@time.0x2620.org", From 790ae530952460ad37f89fd5fddc9f53a48c7ad0 Mon Sep 17 00:00:00 2001 From: j Date: Fri, 14 Nov 2025 12:17:31 +0100 Subject: [PATCH 43/48] switch to .ass subtitles to adjust per language font/size --- management/commands/generate_clips.py | 145 +----------- render.py | 312 +++++++++++++++++++++++--- 2 files changed, 277 insertions(+), 180 deletions(-) diff --git a/management/commands/generate_clips.py b/management/commands/generate_clips.py index bf0f1d5..dfbb559 100644 --- a/management/commands/generate_clips.py +++ b/management/commands/generate_clips.py @@ -1,31 +1,6 @@ -import json -import os -import re -import subprocess -from collections import defaultdict - from django.core.management.base import BaseCommand -from django.conf import settings -import item.models -import itemlist.models - -from ...render import get_srt - - -def resolve_roman(s): - extra = re.compile(r'^\d+(.*?)$').findall(s) - if extra: - extra = extra[0].lower() - new = { - 'i': '1', 'ii': '2', 'iii': '3', 'iv': '4', 'v': '5', - 'vi': '6', 'vii': 7, 'viii': '8', 'ix': '9', 'x': '10' - }.get(extra, extra) - return s.replace(extra, new) - return s - -def format_duration(duration, fps): - return float('%0.5f' % (round(duration * fps) / fps)) +from ...render import generate_clips class Command(BaseCommand): @@ -37,120 +12,4 @@ class Command(BaseCommand): parser.add_argument('--censored', action='store', dest='censored', default=None, help='censor items from list') def handle(self, **options): - prefix = options['prefix'] - lang = options["lang"] - if lang: - lang = lang.split(',') - tlang = lang[1:] - lang = lang[0] - else: - tlang = None - if lang == "en": - lang = None - if options['censored']: - censored_list = itemlist.models.List.get(options["censored"]) - censored = list(censored_list.get_items(censored_list.user).all().values_list('public_id', flat=True)) - clips = [] - for i in item.models.Item.objects.filter(sort__type='original'): - original_target = "" - qs = item.models.Item.objects.filter(data__title=i.data['title']).exclude(id=i.id) - if qs.count() >= 1: - clip = {} - durations = [] - for e in item.models.Item.objects.filter(data__title=i.data['title']): - if 'type' not in e.data: - print("ignoring invalid video %s (no type)" % e) - continue - if not e.files.filter(selected=True).exists(): - continue - source = e.files.filter(selected=True)[0].data.path - ext = os.path.splitext(source)[1] - type_ = e.data['type'][0].lower() - target = os.path.join(prefix, type_, i.data['title'] + ext) - os.makedirs(os.path.dirname(target), exist_ok=True) - if os.path.islink(target): - os.unlink(target) - os.symlink(source, target) - if type_ == "original": - original_target = target - if options['censored'] and e.public_id in censored: - clip[type_ + "_censored"] = target - target = '/srv/t_for_time/censored.mp4' - clip[type_] = target - durations.append(e.files.filter(selected=True)[0].duration) - clip["duration"] = min(durations) - if not clip["duration"]: - print('!!', durations, clip) - continue - cd = format_duration(clip["duration"], 24) - #if cd != clip["duration"]: - # print(clip["duration"], '->', cd, durations, clip) - clip["duration"] = cd - clip['tags'] = i.data.get('tags', []) - clip['editingtags'] = i.data.get('editingtags', []) - name = os.path.basename(original_target) - seqid = re.sub(r"Hotel Aporia_(\d+)", "S\\1_", name) - seqid = re.sub(r"Night March_(\d+)", "S\\1_", seqid) - seqid = re.sub(r"_(\d+)H_(\d+)", "_S\\1\\2_", seqid) - seqid = seqid.split('_')[:2] - seqid = [b[1:] if b[0] in ('B', 'S') else '0' for b in seqid] - seqid[1] = resolve_roman(seqid[1]) - seqid[1] = ''.join([b for b in seqid[1] if b.isdigit()]) - if not seqid[1]: - seqid[1] = '0' - try: - clip['seqid'] = int(''.join(['%06d' % int(b) for b in seqid])) - except: - print(name, seqid, 'failed') - raise - if "original" in clip and "foreground" in clip and "background" in clip: - clips.append(clip) - elif "original" in clip and "animation" in clip: - clips.append(clip) - else: - print("ignoring incomplete video", i) - - with open(os.path.join(prefix, 'clips.json'), 'w') as fd: - json.dump(clips, fd, indent=2, ensure_ascii=False) - - print("using", len(clips), "clips") - - voice_over = defaultdict(dict) - for vo in item.models.Item.objects.filter( - data__type__contains="Voice Over", - ): - fragment_id = int(vo.get('title').split('_')[0]) - source = vo.files.filter(selected=True)[0] - batch = vo.get('batch')[0].replace('Text-', '') - src = source.data.path - target = os.path.join(prefix, 'voice_over', batch, '%s.wav' % fragment_id) - os.makedirs(os.path.dirname(target), exist_ok=True) - if os.path.islink(target): - os.unlink(target) - os.symlink(src, target) - subs = [] - for sub in vo.annotations.filter(layer="subtitles", languages=lang).exclude(value="").order_by("start"): - sdata = get_srt(sub, lang=tlang) - subs.append(sdata) - voice_over[fragment_id][batch] = { - "src": target, - "duration": format_duration(source.duration, 24), - "subs": subs - } - with open(os.path.join(prefix, 'voice_over.json'), 'w') as fd: - json.dump(voice_over, fd, indent=2, ensure_ascii=False) - - if options['censored']: - censored_mp4 = '/srv/t_for_time/censored.mp4' - if not os.path.exists(censored_mp4): - cmd = [ - "ffmpeg", - "-nostats", "-loglevel", "error", - "-f", "lavfi", - "-i", "color=color=white:size=1920x1080:rate=24", - "-t", "3600", - "-c:v", "libx264", - "-pix_fmt", "yuv420p", - censored_mp4 - ] - subprocess.call(cmd) + return generate_clips(options) diff --git a/render.py b/render.py index 0d45c0e..8bda21e 100644 --- a/render.py +++ b/render.py @@ -371,6 +371,20 @@ def get_offset_duration(prefix): duration += get_scene_duration(scene) return duration +def write_subtitles(data, folder, options): + data = fix_overlaps(data) + path = folder / "front.srt" + ''' + srt = ox.srt.encode(data) + write_if_new(str(path), srt, 'b') + ''' + if os.path.exists(path): + os.unlink(path) + path = folder / "front.ass" + ass = ass_encode(data, options) + write_if_new(str(path), ass, '') + + def render(root, scene, prefix='', options=None): if options is None: options = {} fps = 24 @@ -378,10 +392,8 @@ def render(root, scene, prefix='', options=None): scene_duration = int(get_scene_duration(scene) * fps) for timeline, data in scene.items(): if timeline == "subtitles": - path = os.path.join(root, prefix + "front.srt") - data = fix_overlaps(data) - srt = ox.srt.encode(data) - write_if_new(path, srt, 'b') + folder = Path(root) / prefix + write_subtitles(data, folder, options) continue #print(timeline) project = KDEnliveProject(root) @@ -480,6 +492,18 @@ def get_fragments(clips, voice_over, prefix): fragments.sort(key=lambda f: ox.sort_string(f['name'])) return fragments +def parse_lang(lang): + if lang and "," in lang: + lang = lang.split(',') + if isinstance(lang, list): + tlang = lang[1:] + lang = lang[0] + else: + tlang = None + if lang == "en": + lang = None + return lang, tlang + def render_all(options): prefix = options['prefix'] @@ -771,7 +795,7 @@ def render_all(options): fn = base_prefix / fn if os.path.exists(fn): os.unlink(fn) - join_subtitles(base_prefix) + join_subtitles(base_prefix, options) print("Duration - Target: %s Actual: %s" % (target_position, position)) print(json.dumps(dict(stats), sort_keys=True, indent=2)) @@ -794,11 +818,39 @@ def add_translations(sub, lang): value += '\n' + tvalue return value -def get_srt(sub, offset=0, lang=None): +def add_translations_dict(sub, langs): + values = {} + value = sub.value.replace('
', '
').replace('
\n', '\n').replace('
', '\n').strip() + if sub.languages: + value = ox.strip_tags(value) + values[sub.languages] = value + else: + values["en"] = value + for slang in langs: + slang_value = None if slang == "en" else slang + if sub.languages == slang_value: + continue + + for tsub in sub.item.annotations.filter( + layer="subtitles", start=sub.start, end=sub.end, + languages=slang_value + ): + tvalue = tsub.value.replace('
', '
').replace('
\n', '\n').replace('
', '\n').strip() + if tsub.languages: + tvalue = ox.strip_tags(tvalue) + values[slang] = tvalue + return values + + +def get_srt(sub, offset, lang, tlang): sdata = sub.json(keys=['in', 'out', 'value']) sdata['value'] = sdata['value'].replace('
', '
').replace('
\n', '\n').replace('
', '\n').strip() - if lang: - sdata['value'] = add_translations(sub, lang) + if tlang: + sdata['value'] = add_translations(sub, tlang) + langs = [lang] + if tlang: + langs += tlang + sdata['values'] = add_translations_dict(sub, langs) if offset: sdata["in"] += offset sdata["out"] += offset @@ -815,21 +867,42 @@ def fix_overlaps(data): previous = sub return data +def shift_clips(data, offset): + for clip in data: + clip['in'] += offset + clip['out'] += offset + +def scene_subtitles(scene, options): + import item.models + offset = 0 + subs = [] + lang, tlang = parse_lang(options["lang"]) + for clip in scene['audio-center']['A1']: + if not clip.get("blank"): + batch, fragment_id = clip['src'].replace('.wav', '').split('/')[-2:] + vo = item.models.Item.objects.filter( + data__batch__icontains=batch, data__title__startswith=fragment_id + '_' + ).first() + if vo: + #print("%s => %s %s" % (clip['src'], vo, vo.get('batch'))) + for sub in vo.annotations.filter( + layer="subtitles" + ).filter( + languages=None if lang == "en" else lang + ).exclude(value="").order_by("start"): + sdata = get_srt(sub, offset, lang, tlang) + subs.append(sdata) + else: + print("could not find vo for %s" % clip['src']) + offset += clip['duration'] + return subs + def update_subtitles(options): import item.models prefix = Path(options['prefix']) base = int(options['offset']) - lang = options["lang"] - if lang and "," in lang: - lang = lang.split(',') - if isinstance(lang, list): - tlang = lang[1:] - lang = lang[0] - else: - tlang = None - if lang == "en": - lang = None + lang, tlang = parse_lang(options["lang"]) _cache = os.path.join(prefix, "cache.json") if os.path.exists(_cache): @@ -844,25 +917,51 @@ def update_subtitles(options): continue with open(scene_json) as fd: scene = json.load(fd) - offset = 0 - subs = [] - for clip in scene['audio-center']['A1']: - if not clip.get("blank"): - batch, fragment_id = clip['src'].replace('.wav', '').split('/')[-2:] - vo = item.models.Item.objects.filter(data__batch__icontains=batch, data__title__startswith=fragment_id + '_').first() - if vo: - #print("%s => %s %s" % (clip['src'], vo, vo.get('batch'))) - for sub in vo.annotations.filter(layer="subtitles").filter(languages=lang).exclude(value="").order_by("start"): - sdata = get_srt(sub, offset, tlang) - subs.append(sdata) - else: - print("could not find vo for %s" % clip['src']) - offset += clip['duration'] - path = folder / "front.srt" - data = fix_overlaps(subs) - srt = ox.srt.encode(subs) - write_if_new(str(path), srt, 'b') + subs = scene_subtitles(scene, options) + write_subtitles(subs, folder, options) +def ass_encode(subs, options): + if "lang" in options: + langs = options["lang"].split(',') + else: + langs = list(subs[0]["values"]) + print('ass_encode', langs, options) + print(subs) + + header = '''[Script Info] +ScriptType: v4.00+ +PlayResX: 1920 +PlayResY: 1080 +ScaledBorderAndShadow: yes +YCbCr Matrix: None + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +''' + ass = header + offset = 10 + height = 42 + styles = [] + for lang in reversed(langs): + font = 'SimHei' if lang in ('zh', 'jp') else 'Menlo' + size = 46 if font == 'SimHei' else 42 + styles.append( + f'Style: {lang},{font},{size},&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,1,0,2,10,10,{offset},1' + ) + offset += size + 20 + ass += '\n'.join(reversed(styles)) + '\n' + events = [ + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text' + ] + for sub in subs: + start = ox.format_timecode(sub['in']).rstrip('0') + stop = ox.format_timecode(sub['out']).rstrip('0') + for lang in reversed(langs): + value = sub['values'][lang] + event = f'Dialogue: 0,{start},{stop},{lang},,0,0,0,,{value}' + events.append(event) + ass += '\n\n[Events]\n' + '\n'.join(events) + '\n' + return ass def update_m3u(render_prefix, exclude=[]): files = ox.sorted_strings(glob(render_prefix + "*/*/back.mp4")) @@ -935,7 +1034,8 @@ def render_infinity(options): shutil.move(state_f + "~", state_f) -def join_subtitles(base_prefix): +def join_subtitles(base_prefix, options): + ''' subtitles = list(sorted(glob('%s/*/front.srt' % base_prefix))) data = [] position = 0 @@ -945,3 +1045,141 @@ def join_subtitles(base_prefix): position += get_scene_duration(scene) with open(base_prefix / 'front.srt', 'wb') as fd: fd.write(ox.srt.encode(data)) + ''' + scenes = list(sorted(glob('%s/*/scene.json' % base_prefix))) + data = [] + position = 0 + for scene in scenes: + subs = scene_subtitles(scene, options) + data += shift_clips(subs, position) + position += get_scene_duration(scene) + write_subtitles(data, base_prefix, options) + +def resolve_roman(s): + extra = re.compile(r'^\d+(.*?)$').findall(s) + if extra: + extra = extra[0].lower() + new = { + 'i': '1', 'ii': '2', 'iii': '3', 'iv': '4', 'v': '5', + 'vi': '6', 'vii': 7, 'viii': '8', 'ix': '9', 'x': '10' + }.get(extra, extra) + return s.replace(extra, new) + return s + +def generate_clips(options): + import item.models + import itemlist.models + + prefix = options['prefix'] + lang, tlang = parse_lang(options["lang"]) + if options['censored']: + censored_list = itemlist.models.List.get(options["censored"]) + censored = list(censored_list.get_items( + censored_list.user + ).all().values_list('public_id', flat=True)) + clips = [] + for i in item.models.Item.objects.filter(sort__type='original'): + original_target = "" + qs = item.models.Item.objects.filter(data__title=i.data['title']).exclude(id=i.id) + if qs.count() >= 1: + clip = {} + durations = [] + for e in item.models.Item.objects.filter(data__title=i.data['title']): + if 'type' not in e.data: + print("ignoring invalid video %s (no type)" % e) + continue + if not e.files.filter(selected=True).exists(): + continue + source = e.files.filter(selected=True)[0].data.path + ext = os.path.splitext(source)[1] + type_ = e.data['type'][0].lower() + target = os.path.join(prefix, type_, i.data['title'] + ext) + os.makedirs(os.path.dirname(target), exist_ok=True) + if os.path.islink(target): + os.unlink(target) + os.symlink(source, target) + if type_ == "original": + original_target = target + if options['censored'] and e.public_id in censored: + clip[type_ + "_censored"] = target + target = '/srv/t_for_time/censored.mp4' + clip[type_] = target + durations.append(e.files.filter(selected=True)[0].duration) + clip["duration"] = min(durations) + if not clip["duration"]: + print('!!', durations, clip) + continue + cd = format_duration(clip["duration"], 24) + #if cd != clip["duration"]: + # print(clip["duration"], '->', cd, durations, clip) + clip["duration"] = cd + clip['tags'] = i.data.get('tags', []) + clip['editingtags'] = i.data.get('editingtags', []) + name = os.path.basename(original_target) + seqid = re.sub(r"Hotel Aporia_(\d+)", "S\\1_", name) + seqid = re.sub(r"Night March_(\d+)", "S\\1_", seqid) + seqid = re.sub(r"_(\d+)H_(\d+)", "_S\\1\\2_", seqid) + seqid = seqid.split('_')[:2] + seqid = [b[1:] if b[0] in ('B', 'S') else '0' for b in seqid] + seqid[1] = resolve_roman(seqid[1]) + seqid[1] = ''.join([b for b in seqid[1] if b.isdigit()]) + if not seqid[1]: + seqid[1] = '0' + try: + clip['seqid'] = int(''.join(['%06d' % int(b) for b in seqid])) + except: + print(name, seqid, 'failed') + raise + if "original" in clip and "foreground" in clip and "background" in clip: + clips.append(clip) + elif "original" in clip and "animation" in clip: + clips.append(clip) + else: + print("ignoring incomplete video", i) + + with open(os.path.join(prefix, 'clips.json'), 'w') as fd: + json.dump(clips, fd, indent=2, ensure_ascii=False) + + print("using", len(clips), "clips") + + voice_over = defaultdict(dict) + for vo in item.models.Item.objects.filter( + data__type__contains="Voice Over", + ): + fragment_id = int(vo.get('title').split('_')[0]) + source = vo.files.filter(selected=True)[0] + batch = vo.get('batch')[0].replace('Text-', '') + src = source.data.path + target = os.path.join(prefix, 'voice_over', batch, '%s.wav' % fragment_id) + os.makedirs(os.path.dirname(target), exist_ok=True) + if os.path.islink(target): + os.unlink(target) + os.symlink(src, target) + subs = [] + for sub in vo.annotations.filter( + layer="subtitles", languages=lang + ).exclude(value="").order_by("start"): + sdata = get_srt(sub, 0, lang, tlang) + subs.append(sdata) + voice_over[fragment_id][batch] = { + "src": target, + "duration": format_duration(source.duration, 24), + "subs": subs + } + with open(os.path.join(prefix, 'voice_over.json'), 'w') as fd: + json.dump(voice_over, fd, indent=2, ensure_ascii=False) + + if options['censored']: + censored_mp4 = '/srv/t_for_time/censored.mp4' + if not os.path.exists(censored_mp4): + cmd = [ + "ffmpeg", + "-nostats", "-loglevel", "error", + "-f", "lavfi", + "-i", "color=color=white:size=1920x1080:rate=24", + "-t", "3600", + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + censored_mp4 + ] + subprocess.call(cmd) From ce51e8c2c41f7efb5d470876acc19db0eab1c665 Mon Sep 17 00:00:00 2001 From: j Date: Fri, 21 Nov 2025 11:00:06 +0100 Subject: [PATCH 44/48] optionally render srt subtitles --- render.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/render.py b/render.py index 8bda21e..110e18a 100644 --- a/render.py +++ b/render.py @@ -374,15 +374,15 @@ def get_offset_duration(prefix): def write_subtitles(data, folder, options): data = fix_overlaps(data) path = folder / "front.srt" - ''' - srt = ox.srt.encode(data) - write_if_new(str(path), srt, 'b') - ''' - if os.path.exists(path): - os.unlink(path) - path = folder / "front.ass" - ass = ass_encode(data, options) - write_if_new(str(path), ass, '') + if options.get("subtitle_format") == "srt": + srt = ox.srt.encode(data) + write_if_new(str(path), srt, 'b') + else: + if os.path.exists(path): + os.unlink(path) + path = folder / "front.ass" + ass = ass_encode(data, options) + write_if_new(str(path), ass, '') def render(root, scene, prefix='', options=None): From 2a5d741ccf4f3b87e4f445aeb2a13a16c47dd650 Mon Sep 17 00:00:00 2001 From: j Date: Fri, 21 Nov 2025 11:00:40 +0100 Subject: [PATCH 45/48] use default options instead of passing all over the place --- management/commands/generate_clips.py | 2 -- management/commands/update_subtitles.py | 1 - render.py | 14 ++++++++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/management/commands/generate_clips.py b/management/commands/generate_clips.py index dfbb559..abfd55d 100644 --- a/management/commands/generate_clips.py +++ b/management/commands/generate_clips.py @@ -7,9 +7,7 @@ class Command(BaseCommand): help = 'generate symlinks to clips and clips.json' def add_arguments(self, parser): - parser.add_argument('--lang', action='store', dest='lang', default=None, help='subtitle language') parser.add_argument('--prefix', action='store', dest='prefix', default="/srv/t_for_time", help='prefix to build clips in') - parser.add_argument('--censored', action='store', dest='censored', default=None, help='censor items from list') def handle(self, **options): return generate_clips(options) diff --git a/management/commands/update_subtitles.py b/management/commands/update_subtitles.py index a1482d6..1585ff5 100644 --- a/management/commands/update_subtitles.py +++ b/management/commands/update_subtitles.py @@ -14,7 +14,6 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--prefix', action='store', dest='prefix', default="/srv/t_for_time", help='prefix to build clips in') parser.add_argument('--offset', action='store', dest='offset', default="1024", help='inital offset in pi') - parser.add_argument('--lang', action='store', dest='lang', default=None, help='subtitle language') def handle(self, **options): update_subtitles(options) diff --git a/render.py b/render.py index 110e18a..f06b606 100644 --- a/render.py +++ b/render.py @@ -506,6 +506,7 @@ def parse_lang(lang): def render_all(options): + options = load_defaults(options) prefix = options['prefix'] duration = int(options['duration']) base = int(options['offset']) @@ -897,9 +898,22 @@ def scene_subtitles(scene, options): offset += clip['duration'] return subs + +def load_defaults(options): + path = os.path.join(options["prefix"], "options.json") + if os.path.exists(path): + with open(path) as fd: + defaults = json.loads(fd) + for key in defaults: + if key not in options: + options[key] = defaults[key] + return options + + def update_subtitles(options): import item.models + options = load_defaults(options) prefix = Path(options['prefix']) base = int(options['offset']) lang, tlang = parse_lang(options["lang"]) From 3f9280e0ba46e451d843e27edebc2666f0649bf5 Mon Sep 17 00:00:00 2001 From: j Date: Thu, 27 Nov 2025 17:49:29 +0100 Subject: [PATCH 46/48] make font/spacing configuration option --- render.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/render.py b/render.py index f06b606..aafa8fe 100644 --- a/render.py +++ b/render.py @@ -953,16 +953,24 @@ YCbCr Matrix: None Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding ''' ass = header - offset = 10 + offset = options.get("sub_margin", 10) + spacing = options.get("sub_spacing", 20) height = 42 styles = [] for lang in reversed(langs): - font = 'SimHei' if lang in ('zh', 'jp') else 'Menlo' - size = 46 if font == 'SimHei' else 42 + if isinstance(options.get("font"), list) and lang in options["font"]: + font = options["font"][lang] + else: + font = 'SimHei' if lang in ('zh', 'jp') else 'Menlo' + if isinstance(options.get("font_size"), list) and lang in options["font_size"]: + size = options["font_size"][lang] + else: + size = 46 if font == 'SimHei' else 42 + styles.append( f'Style: {lang},{font},{size},&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,1,0,2,10,10,{offset},1' ) - offset += size + 20 + offset += size + spacing ass += '\n'.join(reversed(styles)) + '\n' events = [ 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text' From 4e767b72682d8086340670109de3606303f6500d Mon Sep 17 00:00:00 2001 From: j Date: Thu, 27 Nov 2025 18:02:00 +0100 Subject: [PATCH 47/48] fix default options --- render.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/render.py b/render.py index aafa8fe..8df5cf6 100644 --- a/render.py +++ b/render.py @@ -377,6 +377,9 @@ def write_subtitles(data, folder, options): if options.get("subtitle_format") == "srt": srt = ox.srt.encode(data) write_if_new(str(path), srt, 'b') + path = folder / "front.ass" + if os.path.exists(path): + os.unlink(path) else: if os.path.exists(path): os.unlink(path) @@ -386,7 +389,8 @@ def write_subtitles(data, folder, options): def render(root, scene, prefix='', options=None): - if options is None: options = {} + if options is None: + options = {} fps = 24 files = [] scene_duration = int(get_scene_duration(scene) * fps) @@ -903,7 +907,7 @@ def load_defaults(options): path = os.path.join(options["prefix"], "options.json") if os.path.exists(path): with open(path) as fd: - defaults = json.loads(fd) + defaults = json.load(fd) for key in defaults: if key not in options: options[key] = defaults[key] @@ -939,8 +943,8 @@ def ass_encode(subs, options): langs = options["lang"].split(',') else: langs = list(subs[0]["values"]) - print('ass_encode', langs, options) - print(subs) + #print('ass_encode', langs, options) + #print(subs) header = '''[Script Info] ScriptType: v4.00+ @@ -1092,6 +1096,7 @@ def generate_clips(options): import item.models import itemlist.models + options = load_defaults(options) prefix = options['prefix'] lang, tlang = parse_lang(options["lang"]) if options['censored']: From a65ab3159586e7c50feaa4c5b00972267aa10fdd Mon Sep 17 00:00:00 2001 From: j Date: Thu, 27 Nov 2025 19:06:11 +0100 Subject: [PATCH 48/48] fix join_subtitles --- render.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/render.py b/render.py index 8df5cf6..e62f513 100644 --- a/render.py +++ b/render.py @@ -876,6 +876,7 @@ def shift_clips(data, offset): for clip in data: clip['in'] += offset clip['out'] += offset + return data def scene_subtitles(scene, options): import item.models @@ -1075,7 +1076,9 @@ def join_subtitles(base_prefix, options): scenes = list(sorted(glob('%s/*/scene.json' % base_prefix))) data = [] position = 0 - for scene in scenes: + for scene_json in scenes: + with open(scene_json) as fd: + scene = json.load(fd) subs = scene_subtitles(scene, options) data += shift_clips(subs, position) position += get_scene_duration(scene)