Repository navigation
Expand file tree
/
Copy pathavi2atari.py
More file actions
1342 lines (1199 loc) · 59.9 KB
/
Copy pathavi2atari.py
File metadata and controls
1342 lines (1199 loc) · 59.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# avi2atari.py - Atari 8-bit AVF Video Converter
# Copyright (C) 2026 HanJammer & Lumen
#
# A modern, all-in-one converter for AVF/BIN format on Atari 8-bit computers.
# Supports both PAL (50Hz) and NTSC (60Hz) output standards.
#
# Key Features:
# - Single file processing
# - Batch processing (directory support)
# - URL support (YouTube and direct video links) via yt-dlp
# - URL List processing via text file (--urllist filename.txt)
# - Downloads are saved to 'downloads/' folder and preserved
# - EBU R128 Audio Loudness Normalization (prevents silence or clipping on 8-bit DACs)
# - Error diffusion dithering (Floyd-Steinberg derivative)
# - Strict file structure integrity checks (prevents sync drift)
# - Auto-generation of silence for video-only inputs
# - Built-in test signal generator
#
# Original Concept: phaeron encvideo/encaudio/mux C++ sources)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import sys
import os
import subprocess
import argparse
import glob
import time
import shutil
import numpy as np
import math
try:
from numba import jit, prange
NUMBA_OK = True
except ImportError:
# Numba is an accelerator, not a requirement: fall back to plain Python
# (an order of magnitude slower, but correct) so mismatched numpy/numba
# installs never block a conversion outright. EXCEPTION: the c80btc
# exhaustive search is ~1000x slower un-jitted (~7.5 s/frame = 6+ hours
# per minute of PAL video) - main() refuses it without --allow-slow.
NUMBA_OK = False
def jit(*_args, **_kwargs):
def _deco(fn):
return fn
return _deco
prange = range
print("WARNING: numba unavailable - encoding runs unaccelerated (slow).")
__version__ = "0.9"
import avf_format as avf
# Try importing yt_dlp for URL support
try:
import yt_dlp
YT_DLP_AVAILABLE = True
except ImportError:
YT_DLP_AVAILABLE = False
# --- AVF FORMAT CONFIGURATION ---
# All container constants now live in avf_format (the shared PC-suite lib);
# these are aliases so the converter body reads the same as before.
HEIGHT = avf.HEIGHT
WIDTH = avf.WIDTH
FRAME_SIZE_BYTES = avf.FRAME_SIZE_APAC # 17 sectors; byte-aligned or it drifts
C80_FRAME_SIZE = avf.FRAME_SIZE_C80 # 7680 video + 512 linear audio
PROFILE_APAC = avf.PROFILE_APAC
PROFILE_C80M3 = avf.PROFILE_C80M3
PROFILE_C80M4 = avf.PROFILE_C80M4
C80_HALFBLOCK = avf.C80_HALFBLOCK
SECONDS_PER_FRAME_PAL = avf.SECONDS_PER_FRAME_PAL
SECONDS_PER_FRAME_NTSC = avf.SECONDS_PER_FRAME_NTSC
# --- C80/BTC ORDERED DITHER -------------------------------------------------
# Standard 8x8 Bayer ranks. A 4x4 tile resonates with the 2x4 BTC cell:
# every cell row sees the same vertical phase and only two horizontal cell
# fragments repeat across the screen. 8x8 gives eight distinct aligned 2x4
# fragments (4 cell phases horizontally x 2 vertically), reducing that grid.
BTC_BAYER8 = np.array([
[ 0, 32, 8, 40, 2, 34, 10, 42],
[48, 16, 56, 24, 50, 18, 58, 26],
[12, 44, 4, 36, 14, 46, 6, 38],
[60, 28, 52, 20, 62, 30, 54, 22],
[ 3, 35, 11, 43, 1, 33, 9, 41],
[51, 19, 59, 27, 49, 17, 57, 25],
[15, 47, 7, 39, 13, 45, 5, 37],
[63, 31, 55, 23, 61, 29, 53, 21],
], dtype=np.float32)
BTC_BAYER8_CENTERED = (BTC_BAYER8 + 0.5) / 64.0 - 0.5
# Variance gate ramp: cells whose internal detail (mean per-channel std over
# the 8 subpixels) is below GATE_LO get the full dither (they are flat -
# gradients, sky - where banding lives); above GATE_HI they get none (the
# masks are busy encoding REAL detail there and dither grain would only
# compete with it); in between the amplitude ramps down linearly, so there
# is no visible seam between gated and ungated cells.
BTC_GATE_LO = 8.5 # half a quant step of internal variation
BTC_GATE_HI = 34.0 # two quant steps -> clearly detailed
def apply_btc_ordered_dither(rgb, mode="none", frame_index=0, strength=1.0,
gate=False):
"""Return the C80/BTC target with an absolute-coordinate Bayer offset.
``strength=1`` spans one RGB444 quantisation step peak-to-peak: the
midpoint-normalised 8x8 ranks produce offsets just inside +/-8.5. The
SAME scalar offset is added to R/G/B, avoiding independent-channel chroma
speckle. Static ``bayer`` is screen-anchored; ``bayer-t`` shifts the tile
one subpixel right per frame and repeats after eight frames.
``gate=True`` enables the variance gate: the offset is attenuated per
2x4 cell by how much real detail the CLEAN cell already carries (see
BTC_GATE_LO/HI), resolving the masks-vs-dither trade - flat areas get
smooth gradients, detailed areas keep their masks crisp.
Dithering happens after FFmpeg scale/denoise/contrast/saturation and before
the exhaustive BTC search, so masks and both RGB444 colours are judged
against the same dithered target. The caller keeps the clean target for
the classic AVF2 poster.
"""
if mode == "none" or strength <= 0.0:
return rgb
if mode not in ("bayer", "bayer-t"):
raise ValueError(f"unknown BTC dither mode: {mode}")
h, w = rgb.shape[:2]
phase = (int(frame_index) & 7) if mode == "bayer-t" else 0
yy = np.arange(h, dtype=np.int32)[:, None] & 7
# np.roll(tile, +phase): the old x sample moves one pixel right/frame.
xx = (np.arange(w, dtype=np.int32)[None, :] - phase) & 7
offset = BTC_BAYER8_CENTERED[yy, xx] * (17.0 * float(strength))
if gate:
# per-cell detail of the clean target: (24,4,80,2,3) std over the
# 8 subpixels, averaged across channels -> (24,80)
cells = rgb.reshape(24, 4, 80, 2, 3)
detail = cells.std(axis=(1, 3)).mean(axis=-1)
att = np.clip((BTC_GATE_HI - detail) / (BTC_GATE_HI - BTC_GATE_LO),
0.0, 1.0)
offset = offset * np.repeat(np.repeat(att, 4, 0), 2, 1)
return np.clip(rgb + offset[:, :, None], 0.0, 255.0).astype(np.float32)
def build_settings_comment(config):
"""Default AVF2 comment: the conversion settings, compactly.
Used when the user gives no --comment, so every file self-documents
how it was made. Defaults (1.0 gains, stretch, no denoise) are
omitted to keep it short; the audio mode is always included."""
parts = []
if config.get('profile') == PROFILE_C80M4:
parts.append('c80btc' if config.get('c80_btc') else 'c80m4')
else:
parts.append('apac')
if config.get('c80_btc'):
parts.append(f"stab={float(config.get('btc_stability', 0.0)):.2f}")
dither = config.get('btc_dither', 'none')
if dither != 'none':
s = float(config.get('btc_dither_strength', 0.5))
parts.append(f"dither={dither}@{s:g}"
+ ("+gate" if config.get('btc_dither_gate') else ""))
if config.get('denoise'):
parts.append('denoise')
if float(config.get('saturation', 1.0)) != 1.0:
parts.append(f"sat={float(config['saturation']):g}")
if float(config.get('contrast', 1.0)) != 1.0:
parts.append(f"con={float(config['contrast']):g}")
if config.get('aspect') == 'fit':
parts.append('fit')
if config.get('loudnorm'):
parts.append('loudnorm')
else:
parts.append(f"vol={float(config.get('volume', 0.0)):g}dB")
return " ".join(parts)
# --- AVF2 METADATA HEADER --- (built by the shared avf_format lib)
def build_avf2_header(is_pal, frame_count, poster_data, meta,
profile=PROFILE_APAC, c80_btc=False):
# Thin wrapper over avf_format; the converter stamps its own version.
m = dict(meta or {})
m["converter"] = f"avi2atari {__version__}"
return avf.build_avf2_header(is_pal, frame_count, poster_data, m, profile,
c80_btc)
def probe_duration_seconds(input_file):
# Best-effort media duration via ffprobe (ships with FFmpeg). None on failure.
try:
out = subprocess.run(
['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'csv=p=0', input_file],
capture_output=True, text=True, check=True
).stdout.strip()
return float(out)
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
return None
# --- PHASE TABLES (Precomputed) ---
# Atari generates colors via phase shift (Hue) and amplitude (Saturation).
# These tables map the digital color space to Atari's analog I/Q signal.
ITAB_PAL = np.zeros(16, dtype=np.float32)
QTAB_PAL = np.zeros(16, dtype=np.float32)
ITAB_NTSC = np.zeros(16, dtype=np.float32)
QTAB_NTSC = np.zeros(16, dtype=np.float32)
def init_tables():
# Generates lookup tables for color conversion (Phase Shift).
# PAL: 14 color cycles
for i in range(15):
ITAB_PAL[i+1] = 40.0 * math.cos(3.1415926535 * (float(i) + 0.5) / 7.0)
QTAB_PAL[i+1] = 40.0 * math.sin(3.1415926535 * (float(i) + 0.5) / 7.0)
# NTSC: 15 color cycles
for i in range(15):
ITAB_NTSC[i+1] = 40.0 * math.cos(3.1415926535 * float(i) / 7.5)
QTAB_NTSC[i+1] = 40.0 * math.sin(3.1415926535 * float(i) / 7.5)
init_tables()
# --- AUDIO KERNEL (Numba JIT) ---
@jit(nopython=True, fastmath=True)
def encode_audio_chunk(audio_data):
# Converts raw audio (8-bit Unsigned) to Atari POKEY format (4-bit PWM-ish).
# Mapping: PC 0..255 -> Atari 0..100.
# Uses simple linear scaling consistent with the original C++ encoder.
out = np.zeros(len(audio_data), dtype=np.uint8)
for i in range(len(audio_data)):
val = int(audio_data[i])
# Scaling: input 0-255 -> output 0-100
out_val = (val * 100) // 255
out[i] = out_val
return out
# --- C80M4 VIDEO KERNEL (Numba JIT) ---
@jit(nopython=True, fastmath=True)
def encode_c80m4_frame(rgb):
# rgb: float32 (48, 80, 3), 0..255. Serpentine Floyd-Steinberg dither
# down to RGB444, then packed into the 24-row cell layout.
q = np.zeros((48, 80, 3), dtype=np.uint8)
work = rgb.copy()
for y in range(48):
if y % 2 == 0:
xs, xe, xd = 0, 80, 1
else:
xs, xe, xd = 79, -1, -1
for x in range(xs, xe, xd):
for c in range(3):
v = work[y, x, c]
qq = int(round(v / 17.0))
if qq < 0:
qq = 0
if qq > 15:
qq = 15
q[y, x, c] = qq
e = v - qq * 17.0
if 0 <= x + xd < 80:
work[y, x + xd, c] += e * 0.4375 # 7/16
if y + 1 < 48:
work[y + 1, x, c] += e * 0.3125 # 5/16
if 0 <= x - xd < 80:
work[y + 1, x - xd, c] += e * 0.1875 # 3/16
if 0 <= x + xd < 80:
work[y + 1, x + xd, c] += e * 0.0625 # 1/16
out = np.zeros(7680, dtype=np.uint8)
for r in range(24):
base = r * 320
top = 2 * r # top pixel -> BG (high nibble)
bot = 2 * r + 1 # bottom pixel -> FG (low nibble)
for x in range(80):
out[base + x] = C80_HALFBLOCK
out[base + 80 + x] = (q[top, x, 0] << 4) | q[bot, x, 0]
out[base + 160 + x] = (q[top, x, 1] << 4) | q[bot, x, 1]
out[base + 240 + x] = (q[top, x, 2] << 4) | q[bot, x, 2]
return out.reshape((192, 40))
# --- C80/BTC VIDEO KERNEL (Numba JIT, parallel over cell rows) ---
@jit(nopython=True, fastmath=True, parallel=True)
def encode_c80btc_frame(rgb, leg_bg, leg_fg, prev_mask, prev_bg, prev_fg,
prev_valid, stability):
# rgb: float32 (96, 160, 3), 0..255 - the 160x96 subpixel source.
# leg_bg/leg_fg: uint8 (24, 80, 3) RGB444 - the HALFBLOCK BASELINE for
# the same 160x96 target (the classic encoder run on a box-2x2 downscale
# of it). NOTE: this is NOT byte-identical to a direct `--profile c80m4`
# run, whose ffmpeg scales the source straight to 80x48 with lanczos -
# the guarantee below is relative to the 160x96 reference, not to the
# old pipeline's bytes (Codex implementation review, P1).
#
# Per 2x4-subpixel cell: exhaustive search over the 128 unique
# partitions (mask and complement describe the same split), colours =
# per-side means QUANTISED to RGB444 before the error is measured
# (SSE in 0..255 RGB). The halfblock baseline (mask $F0 + its colours)
# seeds the search, so the result can never score worse than that
# baseline under this error function. Finally the free complement+swap
# picks the BG/FG orientation that the legacy half-block fallback
# (old players: BG top / FG bottom) renders best.
# Stateless by design (Codex review: temporal damping is phase 2).
#
# PHASE 2 - temporal hysteresis (encoder-only; the format, decoders and
# the Atari player are untouched): prev_mask (24,80) u8 + prev_bg/fg
# (24,80,3) u8 carry the previous frame's chosen cells. When
# prev_valid == 1 and the previous cell's error on the CURRENT block is
# <= best_err * (1 + stability), the previous cell is kept verbatim -
# static areas stop flickering, and the spatial regression is bounded
# by the stability factor per cell. prev_valid == 0 (or stability <= 0
# at the call site) disables the path entirely = the stateless v1.
# A scene cut self-heals: the old cell's error explodes past the bound
# and the stateless winner replaces it. The arrays are updated in
# place with each cell's final choice.
#
# Working state lives in small typed arrays (best/cand), not scalar
# locals: numba 0.60's scalar SSA unification mistyped the scalar
# version (or_(float64, int64) at the nibble pack).
out = np.zeros(7680, dtype=np.uint8)
for cy in prange(24):
# per-iteration (= per-thread) work buffers: cells are fully
# independent, so rows parallelise cleanly - each cell only reads
# and writes its own prev_* slot
px = np.zeros((8, 3), dtype=np.float32)
best = np.zeros(6, dtype=np.int32) # bg r,g,b | fg r,g,b
cand = np.zeros(6, dtype=np.int32)
base = cy * 320
for cx in range(80):
for r in range(4):
for c in range(2):
for k in range(3):
px[r * 2 + c, k] = rgb[cy * 4 + r, cx * 2 + c, k]
# seed: the classic C80 cell (mask $F0, legacy colours)
best_mask = 0xF0
for k in range(3):
best[k] = leg_bg[cy, cx, k]
best[3 + k] = leg_fg[cy, cx, k]
best_err = 0.0
for i in range(8):
o = 3 if i >= 4 else 0 # $F0: bits 4-7 = FG
for k in range(3):
d = px[i, k] - best[o + k] * 17.0
best_err += d * d
# exhaustive: masks 0..127 cover every partition exactly once
# (bit 7 clear -> subpixel 7 always on the "clear" side)
for m in range(128):
ns = 0
s0 = 0.0; s1 = 0.0; s2 = 0.0
c0 = 0.0; c1 = 0.0; c2 = 0.0
for i in range(8):
if (m >> i) & 1:
s0 += px[i, 0]; s1 += px[i, 1]; s2 += px[i, 2]
ns += 1
else:
c0 += px[i, 0]; c1 += px[i, 1]; c2 += px[i, 2]
nc = 8 - ns
if nc > 0:
c0 /= nc; c1 /= nc; c2 /= nc
if ns > 0:
s0 /= ns; s1 /= ns; s2 /= ns
else:
s0 = c0; s1 = c1; s2 = c2
# quantise both colours to RGB444 first, judge after
cand[3] = min(15, max(0, int(s0 / 17.0 + 0.5)))
cand[4] = min(15, max(0, int(s1 / 17.0 + 0.5)))
cand[5] = min(15, max(0, int(s2 / 17.0 + 0.5)))
cand[0] = min(15, max(0, int(c0 / 17.0 + 0.5)))
cand[1] = min(15, max(0, int(c1 / 17.0 + 0.5)))
cand[2] = min(15, max(0, int(c2 / 17.0 + 0.5)))
err = 0.0
for i in range(8):
o = 3 if (m >> i) & 1 else 0
for k in range(3):
d = px[i, k] - cand[o + k] * 17.0
err += d * d
if err >= best_err:
break
if err < best_err:
best_err = err
best_mask = m
for k in range(6):
best[k] = cand[k]
# orientation: complement+swap is free for BTC-aware playback;
# pick the one the legacy half-block fallback renders better
# (old players show BG on the top rows, FG on the bottom rows)
fa = 0.0
fb = 0.0
for i in range(8):
oa = 0 if i < 4 else 3
ob = 3 if i < 4 else 0
for k in range(3):
d = px[i, k] - best[oa + k] * 17.0
e = px[i, k] - best[ob + k] * 17.0
fa += d * d
fb += e * e
if fb < fa:
best_mask = best_mask ^ 0xFF
for k in range(3):
t = best[k]
best[k] = best[3 + k]
best[3 + k] = t
# temporal hysteresis: keep the previous frame's cell when it
# still explains the current block within the stability bound
if prev_valid == 1:
pm = prev_mask[cy, cx]
perr = 0.0
for i in range(8):
if (pm >> i) & 1:
for k in range(3):
d = px[i, k] - prev_fg[cy, cx, k] * 17.0
perr += d * d
else:
for k in range(3):
d = px[i, k] - prev_bg[cy, cx, k] * 17.0
perr += d * d
if perr <= best_err * (1.0 + stability):
best_mask = pm
for k in range(3):
best[k] = prev_bg[cy, cx, k]
best[3 + k] = prev_fg[cy, cx, k]
prev_mask[cy, cx] = best_mask
for k in range(3):
prev_bg[cy, cx, k] = best[k]
prev_fg[cy, cx, k] = best[3 + k]
out[base + cx] = best_mask
out[base + 80 + cx] = (best[0] << 4) | best[3]
out[base + 160 + cx] = (best[1] << 4) | best[4]
out[base + 240 + cx] = (best[2] << 4) | best[5]
return out.reshape((192, 40))
# --- VIDEO KERNEL (Numba JIT) ---
@jit(nopython=True, fastmath=True)
def encode_video_frame(y_plane, u_plane, v_plane, errfi, errfq, errfy, is_pal):
# Main video encoding engine.
# Performs:
# 1. YUV -> Atari I/Q conversion (Phase/Amplitude)
# 2. GTIA palette matching (16 hues / 16 luma levels)
# 3. Dithering (Error diffusion)
# 4. Bit packing (2 pixels per byte)
output = np.zeros((HEIGHT, 40), dtype=np.uint8)
# Local error buffers for dithering (reset per frame/line logic)
perr = np.zeros(82, dtype=np.int32)
nerr = np.zeros(82, dtype=np.int32)
pcerr = np.zeros(82, dtype=np.float32)
ncerr = np.zeros(82, dtype=np.float32)
pserr = np.zeros(82, dtype=np.float32)
nserr = np.zeros(82, dtype=np.float32)
ITAB = ITAB_PAL if is_pal else ITAB_NTSC
QTAB = QTAB_PAL if is_pal else QTAB_NTSC
for y in range(HEIGHT):
# Atari in AVF mode interleaves Chroma (Color) and Luma (Brightness) lines.
# PAL: Even=Chroma, Odd=Luma
# NTSC: Odd=Chroma, Even=Luma (due to different signal structure)
is_chroma = (y % 2 == 0) if is_pal else (y % 2 != 0)
if is_chroma:
# --- CHROMA PROCESSING (COLOR) ---
ncerr[:] = 0
nserr[:] = 0
# Serpentine scanning (left-to-right, then right-to-left) reduces dither artifacts
direction = -1 if (y & 2) else 1
start_x = 0 if direction == 1 else 79
chroma_y = y // 2
accum = 0
# IMPORTANT - two distinct index spaces are in play here:
# i = scan index (0..79, always advances in scan order)
# x = screen column (i counted from the left or the right)
# The error buffers are indexed by SCAN index, never by column. That is
# what makes the mirrored write pattern below correct: because the next
# line is scanned in the opposite direction, "the pixel below" is at
# scan index 79-i there, so 80-i is where its weight-5 share must land.
# Indexing these buffers by column instead silently diffuses every
# line's error onto the horizontally mirrored part of the next line.
for i in range(80):
x = start_x + (i * direction)
u_val = int(u_plane[chroma_y, x]) - 128
v_val = int(v_plane[chroma_y, x]) - 128
fu = float(u_val)
fv = float(v_val)
# YUV to I/Q conversion matrix
if is_pal:
red = 1.596 * fv
grn = -0.391 * fu - 0.813 * fv
blu = 2.017 * fu
else:
red = 1.396 * fv
grn = -0.342 * fu - 0.711 * fv
blu = 1.765 * fu
fi = (0.595*red - 0.274*grn - 0.321*blu) + pcerr[i + 1] / 25.0 + errfi[y, x]
fq = (0.211*red - 0.522*grn + 0.311*blu) + pserr[i + 1] / 25.0 + errfq[y, x]
satsq = fi*fi + fq*fq
# Phase calculation (Hue)
if is_pal:
rawhue = math.atan2(fq, fi) * (7.0 / 3.1415926535) - 0.5
else:
rawhue = math.atan2(fq, fi) * (7.5 / 3.1415926535) + 2.0
ihue = int(math.floor(rawhue + 0.5))
cycle = 14 if is_pal else 15
offset = 14000 if is_pal else 15000
color = ((ihue + offset) % cycle) + 1
out_color = 0
# Saturation threshold (40.0^2 = 1600). Below this, color is rendered as gray.
# This prevents excessive color noise in dark areas.
if satsq > 1600.0:
out_color += color
sc = 40.0 / math.sqrt(satsq)
fi *= sc
fq *= sc
# Quantization error calculation
ierror = fi - ITAB[out_color]
qerror = fq - QTAB[out_color]
# Error propagation to next pixels and lines (Floyd-Steinberg variant).
# errf* is the TEMPORAL term (9/25 of the error carried to the same
# pixel in the next frame), so it is keyed by column - a given line
# always scans the same way, so column and scan index stay in step
# from frame to frame either way.
errfi[y, x] = ierror * 0.36
errfq[y, x] = qerror * 0.36
pcerr[i + 2] += ierror * 7.0
ncerr[81 - i] += ierror * 3.0
ncerr[80 - i] += ierror * 5.0
ncerr[79 - i] += ierror
pserr[i + 2] += qerror * 7.0
nserr[81 - i] += qerror * 3.0
nserr[80 - i] += qerror * 5.0
nserr[79 - i] += qerror
# Packing 4-bit nibbles into a byte
if direction < 0:
accum = ((accum >> 4) + (out_color << 4)) & 0xFF
else:
accum = ((accum << 4) + out_color) & 0xFF
if (i % 2) == 1:
output[y, x // 2] = accum
pcerr[:] = ncerr
pserr[:] = nserr
else:
# --- LUMA PROCESSING (BRIGHTNESS) ---
nerr[:] = 0
direction = -1 if (y & 2) else 1
start_x = 0 if direction == 1 else 79
accum = 0
# Luma/chroma registration. Output line pair number n is carried by the
# chroma line at 2n and the luma line at 2n+1 (PAL) or by the luma line
# at 2n and the chroma line at 2n+1 (NTSC). The 4:2:0 chroma plane row n
# covers source rows 2n and 2n+1, so the luma line must average that
# SAME row pair or the two planes drift apart by one source line.
# PAL luma rows are odd, so they have to look one row back.
base_y = (y - 1) if is_pal else y
if base_y < 0:
base_y = 0
if base_y > HEIGHT - 2:
base_y = HEIGHT - 2
# See the note in the chroma branch: error buffers use the SCAN index i,
# pixel and output access use the screen column x.
for i in range(80):
x = start_x + (i * direction)
base_x = x * 2
# Luma sampling (2x2 averaging for stability)
p0 = int(y_plane[base_y, base_x])
p1 = int(y_plane[base_y, base_x + 1])
p2 = int(y_plane[base_y + 1, base_x])
p3 = int(y_plane[base_y + 1, base_x + 1])
# Scale YUV range (16-235) to full range (0-255)
val_a = (((p0 + p1 + p2 + p3 - 64) * 255 + 438) // 876)
diff = (perr[i + 1] + errfy[y, x]) / 25.0
val_a += int(diff)
if val_a < 0: val_a = 0
if val_a > 255: val_a = 255
# Quantize to 16 brightness levels (4-bit)
val_b = int((val_a + 8) // 17)
if val_b < 0: val_b = 0
if val_b > 15: val_b = 15
val_c = val_b * 17
err = val_a - val_c
errfy[y, x] = float(err * 9)
perr[i + 2] += int(err * 7)
nerr[81 - i] += int(err * 3)
nerr[80 - i] += int(err * 5)
nerr[79 - i] += int(err)
if direction < 0:
accum = ((accum >> 4) + (val_b << 4)) & 0xFF
else:
accum = ((accum << 4) + val_b) & 0xFF
if (i % 2) == 1:
output[y, x // 2] = accum
temp = perr
perr = nerr
nerr = temp
return output
# --- MUXING (STREAM INTERLEAVING) ---
def mux_frame(fout, vbuf, abuf, is_pal):
# Writes one video and audio frame in the interleaved AVF format.
# The format is rigid - 8704 bytes per frame.
# Video is split into blocks of 3 lines, interleaved with audio data.
v_flat = vbuf.flatten()
# 1. VIDEO PART (Video Blocks)
# Video: 192 lines / 3 = 64 blocks.
# Each video block is padded with zeroes.
for y in range(0, 192, 3):
fout.write(b'\x00')
fout.write(v_flat[y*40 : (y+1)*40])
fout.write(b'\x00\x00\x00')
fout.write(b'\x00')
fout.write(v_flat[(y+1)*40 : (y+2)*40])
fout.write(b'\x00\x00\x00')
fout.write(v_flat[(y+2)*40 : (y+3)*40])
# 2. AUDIO PART (Audio Blocks at the end of the frame)
# Audio data is "smeared" in a specific pattern so the CPU can read it in time.
off1 = 120 if is_pal else 70
off2 = 52
# Audio Loop 1 (32 audio lines)
for y in range(32):
fout.write(bytes([abuf[y]]))
fout.write(bytes([abuf[y + 0*32 + off1]]))
fout.write(bytes([abuf[y + 1*32 + off1]]))
fout.write(bytes([abuf[y + 2*32 + off1]]))
fout.write(bytes([abuf[y + 3*32 + off1]]))
fout.write(bytes([abuf[y + 4*32 + off1]]))
fout.write(bytes([abuf[y + 5*32 + off1]]))
fout.write(bytes([abuf[y + 0*32 + off2]]))
fout.write(bytes([abuf[y + 1*32 + off2]]))
fout.write(b'\x00')
# Audio Loop 2 (19 audio lines)
# NOTE: The padding difference between PAL and NTSC is critical for sync!
for y in range(19):
fout.write(bytes([abuf[y+32]]))
if is_pal:
# PAL: 1 byte data + 1 byte data + 8 zeroes = 10 bytes
fout.write(bytes([abuf[y + 2*32 + off2]]))
fout.write(b'\x00' * 8)
else:
# NTSC: 1 byte data + 9 zeroes = 10 bytes
fout.write(b'\x00' * 9)
# End (Padding to fill the sector)
fout.write(bytes([abuf[51]]))
fout.write(b'\x00')
# --- DOWNLOADER UTILS ---
def download_media(url):
# Downloads video from URL using yt-dlp.
# Saves to 'downloads' directory.
# Returns the path to the downloaded file and the video title.
if not YT_DLP_AVAILABLE:
print("ERROR: yt-dlp is not installed. Please install it with: pip install yt-dlp")
sys.exit(1)
print(f"--- Downloading from URL: {url} ---")
# Ensure downloads directory exists
download_dir = os.path.join(os.getcwd(), "downloads")
os.makedirs(download_dir, exist_ok=True)
# Prefer a single muxed MP4 and force an mp4 container. Modern YouTube
# serves DASH (separate video+audio) whose merged file often does NOT
# match prepare_filename()'s guessed extension - the classic "downloaded
# but no file" trap. merge_output_format pins it, and we also fall back
# to the newest file actually written to the download dir.
ydl_opts = {
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
'merge_output_format': 'mp4',
'outtmpl': os.path.join(download_dir, '%(title)s.%(ext)s'),
'quiet': False,
'no_warnings': True,
'restrictfilenames': True, # ASCII-only filenames
}
before = set(os.listdir(download_dir))
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
info = ydl.extract_info(url, download=True)
title = info.get('title', 'video')
filename = ydl.prepare_filename(info)
if not os.path.exists(filename):
# merge/postprocess changed the extension: take the newest
# file that appeared in the download dir during this call.
fresh = [os.path.join(download_dir, f)
for f in os.listdir(download_dir) if f not in before]
cand = fresh or [os.path.join(download_dir, f)
for f in os.listdir(download_dir)]
if not cand:
print("Error downloading URL: no file was written")
return None, None
filename = max(cand, key=os.path.getmtime)
sanitized_title = "".join(
c for c in title
if c.isalpha() or c.isdigit() or c in ' .-_').strip()
return filename, sanitized_title
except Exception as e:
print(f"Error downloading URL: {e}")
return None, None
# --- MAIN PROCESSING LOGIC ---
def process_single_file(input_file, system, output_file, config):
print(f"\n>>> Converting: {input_file} -> {output_file}")
is_pal = (system == "PAL")
fps = "49.86" if is_pal else "59.92"
audio_rate = "15557" if is_pal else "15700" # Sample rates consistent with VirtualDub
audio_chunk_size = 312 if is_pal else 262
# Using temporary files instead of pipes for stability.
pid = os.getpid()
temp_vid = f"temp_v_{pid}.raw"
temp_aud = f"temp_a_{pid}.raw"
# --- 1. FFMPEG FILTERS ---
# Force the output colour matrix to BT.601 ("smpte170m"). The downstream
# YUV->I/Q conversion in encode_video_frame is hard-coded to BT.601
# coefficients; modern HD content is usually tagged BT.709 and would yield
# subtly off greens/skin tones if fed through unconverted. Letting libswscale
# do the matrix conversion during the scale stage costs nothing extra.
c80 = config['profile'] == PROFILE_C80M4
c80_btc = c80 and config.get('c80_btc', False)
# --denoise: temporal-only hqdn3d AFTER the scaler (at 160x96-class
# resolution the temporal pass costs next to nothing; running it at
# source resolution made "Extracting Video" crawl on HD material).
# Spatial strengths are 0 (no blur) - static detail stays sharp; the
# temporal pass averages what does not move, killing the noise that
# drives per-frame cell re-decisions (glyph shimmer) in C80/BTC and
# APAC dither crawl alike.
denoise_filter = "hqdn3d=0:0:6:6," if config.get('denoise') else ""
if c80:
# C80M4: true-colour cells; the FPGA renders RGB directly, so no
# BT.601 trip - plain RGB from the scaler. BTC feeds the encoder a
# 160x96 subpixel grid, classic C80 an 80x48 pixel grid.
c80_w, c80_h = (160, 96) if c80_btc else (80, 48)
if config['aspect'] == 'fit':
scale_filter = (f"scale={c80_w}:{c80_h}:force_original_aspect_ratio=decrease:flags=lanczos,"
f"pad={c80_w}:{c80_h}:(ow-iw)/2:(oh-ih)/2:black")
else:
scale_filter = f"scale={c80_w}:{c80_h}:flags=lanczos"
vf_chain = (f"{scale_filter},"
f"{denoise_filter}"
f"eq=contrast={config['contrast']},"
f"hue=s={config['saturation']},"
f"fps={fps},format=rgb24")
if c80_btc:
print(" Profile: AVF-C80 M4 + BTC subcell masks "
"(GTIA2RGB COL80, 160x96 / 2x RGB444 per cell)")
else:
print(" Profile: AVF-C80 M4 (GTIA2RGB COL80, 80x48 RGB444)")
common_scale_opts = "flags=lanczos:in_color_matrix=auto:out_color_matrix=bt601"
if config['aspect'] == 'fit':
# Preserve source aspect ratio, pad with black to 160x192 (letter/pillarbox).
# Note: assumes square pixels for the source; ignores Atari's non-square
# pixel aspect on a CRT (a known compromise - acceptable on modern displays).
scale_filter = (
f"scale=160:192:force_original_aspect_ratio=decrease:{common_scale_opts},"
f"pad=160:192:(ow-iw)/2:(oh-ih)/2:black"
)
else:
# Stretch (legacy behaviour): fill the frame regardless of source AR.
scale_filter = f"scale=160:192:{common_scale_opts}"
# Saturation is applied via the `hue` filter rather than `eq=saturation=`,
# because `eq` saturates very weakly above 1.0 (clamp/curve in libavfilter)
# while `hue=s=` scales chroma linearly - so values like 1.5 actually do
# something visible. Contrast still goes through `eq` (no equivalent in `hue`).
# Both filters are no-ops at 1.0, so default behaviour is preserved.
if not c80:
vf_chain = (
f"{scale_filter},"
f"{denoise_filter}"
f"eq=contrast={config['contrast']},"
f"hue=s={config['saturation']},"
f"fps={fps},format=yuv420p"
)
# Audio Filters: Loudnorm (EBU R128) or Manual Gain
if config['loudnorm']:
# EBU R128 normalization
af_chain = "loudnorm=I=-16:TP=-1.5:LRA=11"
print(f" Audio: Loudness Normalization (I=-16 LUFS)")
else:
# Manual volume gain
af_chain = f"volume={config['volume']}dB"
print(f" Audio: Manual Gain {config['volume']}dB")
# --- 2. RAW EXTRACTION (FFmpeg) ---
try:
# Dump Video
print(f" [1/3] Extracting Video...")
subprocess.run([
'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error',
'-i', input_file,
'-vf', vf_chain,
'-f', 'rawvideo', temp_vid
], check=True)
# Dump Audio (Forced PCM Unsigned 8-bit)
# Added TRY/EXCEPT to handle video-only files
print(f" [2/3] Extracting Audio...")
use_dummy_audio = False
try:
subprocess.run([
'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error',
'-i', input_file,
'-vn',
'-af', af_chain,
'-ar', audio_rate,
'-ac', '1',
'-c:a', 'pcm_u8',
'-f', 'u8',
temp_aud
], check=True)
except subprocess.CalledProcessError:
print(" WARNING: No audio stream detected (or extraction failed).")
print(" Generating digital silence for compatibility.")
use_dummy_audio = True
except subprocess.CalledProcessError as e:
print(f"Error during FFmpeg extraction: {e}")
return
# --- 3. ENCODING & MUXING ---
print(f" [3/3] Encoding & Muxing... "
f"(numba: {'ON' if NUMBA_OK else 'OFF - SLOW, pure Python'})")
fout = open(output_file, "wb")
# The 8KB header is enabled by default. It is required for correct synchronization
# by the "movplay" player, which always skips the first 16 sectors (regardless of media).
if not config['no_header']:
fout.write(b'\x00' * (16 * 512))
# Dithering buffers
errfi = np.zeros((HEIGHT, 80), dtype=np.float32)
errfq = np.zeros((HEIGHT, 80), dtype=np.float32)
errfy = np.zeros((HEIGHT, 80), dtype=np.float32)
# C80/BTC temporal-hysteresis state (per file; see encode_c80btc_frame)
btc_stability = float(config.get('btc_stability', 0.0) or 0.0)
btc_dither = config.get('btc_dither', 'none')
btc_dither_strength = float(config.get('btc_dither_strength', 0.5))
btc_dither_gate = bool(config.get('btc_dither_gate', False))
btc_prev_mask = np.zeros((24, 80), dtype=np.uint8)
btc_prev_bg = np.zeros((24, 80, 3), dtype=np.uint8)
btc_prev_fg = np.zeros((24, 80, 3), dtype=np.uint8)
btc_prev_valid = 0
if c80_btc and btc_stability > 0.0:
print(f" BTC temporal stability: {btc_stability:.2f}")
if c80_btc and btc_dither != 'none':
temporal = "temporal, +1 px/frame" if btc_dither == 'bayer-t' else "static"
print(f" BTC dither: Bayer 8x8 ({temporal}, strength "
f"{btc_dither_strength:.2f}{', variance-gated' if btc_dither_gate else ''})")
if btc_dither == 'bayer-t' and btc_stability > 0.0:
print(" WARNING: bayer-t with BTC hysteresis attenuates the "
"deliberate frame-to-frame dither cycle")
f_vid = open(temp_vid, "rb")
# Open audio file only if it exists, otherwise we'll generate silence in loop
f_aud = None
if not use_dummy_audio:
f_aud = open(temp_aud, "rb")
frame_count = 0
start_time = time.time()
# AVF2: pick the poster frame index up front. Explicit --poster wins;
# otherwise 10% of the source duration (ffprobe), otherwise 10 s.
poster_idx = 0
poster_data = None
avf2_meta = None
if config['avf2']:
if config['poster'] is not None:
poster_time = config['poster']
else:
duration = probe_duration_seconds(input_file)
poster_time = duration * 0.10 if duration else 10.0
poster_idx = max(0, int(round(poster_time * float(fps))))
# Per-file metadata; untitled files fall back to the source basename
# so batch conversions still produce self-describing AVF2 files.
avf2_meta = {
'title': config['title'] or os.path.splitext(os.path.basename(input_file))[0],
'author': config['author'],
# no explicit comment -> self-document the conversion settings
'comment': config['comment'] or build_settings_comment(config),
}
try:
while True:
if c80_btc:
rgb_data = f_vid.read(160 * 96 * 3)
if len(rgb_data) != 46080: break
elif c80:
rgb_data = f_vid.read(80 * 48 * 3)
if len(rgb_data) != 11520: break
else:
y_data = f_vid.read(160 * 192)
if len(y_data) != 30720: break
u_data = f_vid.read(80 * 96)
v_data = f_vid.read(80 * 96)
# Read audio chunk or generate silence
if not use_dummy_audio:
a_data = f_aud.read(audio_chunk_size)
else:
a_data = b'' # Will be padded below
if len(a_data) < audio_chunk_size:
# Padding with silence (128 = silence in Unsigned 8-bit)
# This handles both end-of-file and dummy-audio scenarios
a_data += b'\x80' * (audio_chunk_size - len(a_data))
a_chunk = np.frombuffer(a_data, dtype=np.uint8)
if c80_btc:
rgb96_clean = np.frombuffer(rgb_data, dtype=np.uint8).reshape((96, 160, 3)).astype(np.float32)
# Ordered dither is a target pre-pass. Static Bayer remains
# compatible with temporal hysteresis; bayer-t deliberately
# changes the target phase every frame.
rgb96 = apply_btc_ordered_dither(
rgb96_clean, btc_dither, frame_count,
btc_dither_strength, btc_dither_gate)
# Halfblock-baseline pass first: box-2x2 the 160x96 target to
# 80x48 and run the classic encoder (with its serpentine FS
# dither) on THAT - an optimal-halfblock baseline for the
# 160x96 reference, not a byte-identical `--profile c80m4`
# result (which scales the source straight to 80x48). Its
# output doubles as the search seed AND the classic-encoded
# poster (the poster is never BTC - spec section 5).
rgb48 = rgb96.reshape(48, 2, 80, 2, 3).mean(axis=(1, 3))
classic_vid = encode_c80m4_frame(rgb48)
ca = classic_vid.reshape(24, 8, 40)
leg = np.stack([ca[:, 2:4, :].reshape(24, 80),
ca[:, 4:6, :].reshape(24, 80),
ca[:, 6:8, :].reshape(24, 80)], -1)
leg_bg = ((leg >> 4) & 0xF).astype(np.uint8)
leg_fg = (leg & 0xF).astype(np.uint8)
encoded_vid = encode_c80btc_frame(
rgb96, leg_bg, leg_fg,
btc_prev_mask, btc_prev_bg, btc_prev_fg,
btc_prev_valid if btc_stability > 0.0 else 0,
btc_stability)
btc_prev_valid = 1
elif c80:
rgb = np.frombuffer(rgb_data, dtype=np.uint8).reshape((48, 80, 3)).astype(np.float32)
encoded_vid = encode_c80m4_frame(rgb)
else:
y_plane = np.frombuffer(y_data, dtype=np.uint8).reshape((192, 160))
u_plane = np.frombuffer(u_data, dtype=np.uint8).reshape((96, 80))
v_plane = np.frombuffer(v_data, dtype=np.uint8).reshape((96, 80))
encoded_vid = encode_video_frame(y_plane, u_plane, v_plane, errfi, errfq, errfy, is_pal)
encoded_aud = encode_audio_chunk(a_chunk)
# AVF2 poster: keep overwriting until the chosen frame is reached,
# so a clip shorter than the poster time still gets its last frame.
# BTC streams store the CLASSIC C80 encoding as the poster