diff --git a/dither-image.js b/dither-image.js index 58cb9bf..93fddd9 100644 --- a/dither-image.js +++ b/dither-image.js @@ -5,14 +5,18 @@ import { import Jimp from 'Jimp'; -function zeroes(n) { +function repeat(val, n) { let arr = []; for (let i = 0; i < n; i++) { - arr.push(0); + arr.push(val); } return arr; } +function zeroes(n) { + return repeat(0, n); +} + function toLinear(val) { // use a 2.4 gamma approximation // this is BT.1886 compatible @@ -584,29 +588,60 @@ function decimate(input, palette, n) { // preface the reserved bits let buckets = reserved.slice().map((c) => [atariRGB[c]]).concat([input.slice()]); + if (input.length != 160) { + throw new Error('xxx bad input size'); + } + /* + let buckets = [input.slice()]; + if (reserved.length > 0) { + let pxPerReserved = input.length; + for (let c of reserved) { + for (let i = 0; i < pxPerReserved; i++) { + buckets[0].unshift(atariRGB[c]); + } + } + console.log(buckets[0].length, 'xxx'); + } + */ + + let magicSort = (picker) => (a, b) => { + let bychannel = picker(b) - picker(a); + if (bychannel) return bychannel; + + let byluma = b.luma() - a.luma(); + return byluma; + }; let medianCut = (bucket, range) => { if (bucket.length < 2) { - console.log(bucket); throw new Error('short bucket'); } //console.log('medianCut', bucket, range); // Sort by the channel with the greatest range, // then cut the bucket in two at the median. if (range.g >= range.r && range.g >= range.b) { - bucket.sort((a, b) => b.g - a.g); + //bucket.sort((a, b) => b.g - a.g); + bucket.sort(magicSort((rgb) => rgb.g)); } else if (range.r >= range.g && range.r >= range.b) { - bucket.sort((a, b) => b.r - a.r); + //bucket.sort((a, b) => b.r - a.r); + bucket.sort(magicSort((rgb) => rgb.r)); } else if (range.b >= range.g && range.b >= range.r) { - bucket.sort((a, b) => b.b - a.b); + //bucket.sort((a, b) => b.b - a.b); + bucket.sort(magicSort((rgb) => rgb.b)); } let half = bucket.length >> 1; //console.log('cutting', half, bucket.length); - return [bucket.slice(0, half), bucket.slice(half)]; + let [bottom, top] = [bucket.slice(0, half), bucket.slice(half)]; + //console.log({bottom, top}); + return [bottom, top]; + //return [bucket.slice(0, half), bucket.slice(half)]; }; while (buckets.length < n) { // Find the bucket with the greatest range in any channel let ranges = buckets.map((bucket) => { + if (bucket.length == 0) { + throw new Error('xxx empty bucket'); + } let red = bucket.map((rgb) => rgb.r); let green = bucket.map((rgb) => rgb.g); let blue = bucket.map((rgb) => rgb.b); @@ -622,21 +657,26 @@ function decimate(input, palette, n) { let greatest = 0; let index = -1; for (let i = 0; i < topRanges.length; i++) { - if (topRanges[i] >= greatest) { + //if (topRanges[i] >= greatest) { + if (topRanges[i] > greatest) { greatest = topRanges[i]; index = i; } } if (index == -1) { // We just ran out of colors! Pad the buckets. - while (buckets.length < n) { - buckets.push([new RGB(0, 0, 0)]); - } + //while (buckets.length < n) { + // buckets.push([new RGB(0, 0, 0)]); + //} break; } let [lo, hi] = medianCut(buckets[index], ranges[index]); buckets.splice(index, 1, lo, hi); } + + if (buckets.length > n) { + throw new Error('xxx too many colors assigned'); + } decimated = buckets.map((bucket) => { // Average the RGB colors in this chunk let rgb = bucket @@ -723,10 +763,7 @@ function decimate(input, palette, n) { let index = dists.indexOf(closest); return palette[index]; }); - // hack decimated.sort((a, b) => a - b); - //console.log(decimated); - decimated[0] = 0; // Palette fits return dither(decimated); @@ -742,11 +779,25 @@ async function loadImage(src) { let width = image.bitmap.width; let height = image.bitmap.height; - if (width != 160 || height != 160) { + + let aspect = width / height; + let dar = 2 / 1.2; + if (aspect > ((320 / 1.2) / 192)) { + // wide width = 160; - height = 160; - image = image.resize(width, height); + height = Math.round((width * image.bitmap.height / image.bitmap.width) * dar); + if (height & 1) { + height++; + } + } else { + // tall + height = 192; + width = Math.round((height * image.bitmap.width / image.bitmap.height) / dar); + if (width & 1) { + width++; + } } + image = image.resize(width, height); let rgba = image.bitmap.data.slice(); return { @@ -783,13 +834,12 @@ async function convert(source) { rgba } = await loadImage(source); - if (width !== 160) { - throw new Error(`expected 160px-compatible width, got ${width} pixels`); + if (width > 160) { + throw new Error(`expected <160px width, got ${width} pixels`); } - if (height !== 160) { - // @fixme support up to 240px - throw new Error(`expected 160px height, got ${height} pixels`); + if (height > 192) { + throw new Error(`expected <192px height, got ${height} pixels`); } if (rgba.length != width * 4 * height) { @@ -817,19 +867,33 @@ async function convert(source) { allColors.push(i); } + let left = [], right = []; + let padding = 0; + if (width < 160) { + padding = 160 - width; + + let black = new RGB(0, 0, 0); + left = repeat(black, padding >> 1); + right = repeat(black, padding + 1 >> 1); + } + let lines = []; for (let y = 0; y < height; y++) { let inputLine = input .slice(y * width, (y + 1) * width); + + if (padding) { + inputLine = left.concat(inputLine, right); + } if (y > 0) { let error = lines[y - 1].error; - inputLine = inputLine.map((rgb, x) => rgb.add(error[x])); + inputLine = inputLine.map((rgb, x) => rgb.add(error[x]).clamp()); } let line = decimate(inputLine, allColors, 4, y); lines.push(line); } return { - width, + width: width + padding, height, lines }; @@ -890,12 +954,14 @@ function genAssembly(width, height, nbits, lines) { return `.data .export frame1_top .export frame1_bottom + .export frame1_palette1_even .export frame1_palette1_odd .export frame1_palette2_even .export frame1_palette2_odd .export frame1_palette3_even .export frame1_palette3_odd + .export displaylist .segment "BUFFERS" @@ -904,6 +970,10 @@ function genAssembly(width, height, nbits, lines) { frame1_top: ${byte2byte(frame.bitmap.slice(0, half))} +.align 4096 +frame1_bottom: +${byte2byte(frame.bitmap.slice(half))} + .align 128 frame1_palette1_even: ${byte2byte(even(frame.palette1))} @@ -928,22 +998,19 @@ ${byte2byte(even(frame.palette3))} frame1_palette3_odd: ${byte2byte(odd(frame.palette3))} -.align 4096 -frame1_bottom: -${byte2byte(frame.bitmap.slice(half))} .align 1024 displaylist: - ; 40 lines overscan - .repeat 4 + ; 24 lines overscan + .repeat 2 .byte $70 ; 8 blank lines .endrep ; include a DLI to mark us as frame 0 .byte $f0 ; 8 blank lines - ; 160 lines graphics + ; ${height} lines graphics ; ANTIC mode e (160px 2bpp, 1 scan line per line) .byte $4e .addr frame1_top diff --git a/dither4.s b/dither4.s index 9dfb994..fd4c0ec 100644 --- a/dither4.s +++ b/dither4.s @@ -28,12 +28,13 @@ sample_ptr = sample_ptrl scanline = $86 frame_counter = $89 -height = 160 +;height = 160 +height = 192 bytes_per_line = 40 pages_per_frame = 32 lines_per_frame = 262 ;scanline_offset = 31 + (40 - 24) / 2 -scanline_offset = 46 +scanline_offset = 30 scanline_max = (lines_per_frame - scanline_offset) / 2 .data diff --git a/video-bulk/atarifiy.sh b/video-bulk/atarifiy.sh new file mode 100644 index 0000000..95b8300 --- /dev/null +++ b/video-bulk/atarifiy.sh @@ -0,0 +1,62 @@ +set -e + +INFILE="$1" +# additional params can be input to the extraction +# for time or seek +shift + +mkdir -p temp + +ffmpeg \ + -i "$INFILE" \ + -r 60000/1001 \ + -vf 'scale=256:-2' \ + -an \ + "$@" \ + -y "temp/$INFILE-%04d.png" + +ffmpeg \ + -i "$INFILE" \ + -vn \ + -ac 1 \ + -ar 15734 \ + -acodec pcm_u8 \ + "$@" \ + -y "temp/$INFILE-audio.wav" || echo no audio + +for frame in "temp/$INFILE-"[0-9][0-9][0-9][0-9].png +do + n="${frame#temp/$INFILE-}" + n="${n%.png}" + out="temp/$INFILE-dither-${n}" + last="${n:0-1}" + node ../dither-image.js "$frame" "$out" & + if (( last == 9 )) + then + echo "frame $n" + wait + fi +done +wait + +if [ -f "temp/$INFILE-audio.wav" ] +then + ffmpeg \ + -r 60000/1001 \ + -i "temp/$INFILE-dither-%04d.png" \ + -i "temp/$INFILE-audio.wav" \ + -ac 2 \ + -ar 48000 \ + -vf 'pad=w=534' \ + -pix_fmt yuv420p \ + -movflags +faststart \ + -y "$INFILE-dither.mp4" +else + ffmpeg \ + -r 60000/1001 \ + -i "temp/$INFILE-dither-%04d.png" \ + -vf 'pad=w=534' \ + -pix_fmt yuv420p \ + -movflags +faststart \ + -y "$INFILE-dither.mp4" +fi diff --git a/video-cat/combine.sh b/video-cat/combine.sh new file mode 100644 index 0000000..b3e81fb --- /dev/null +++ b/video-cat/combine.sh @@ -0,0 +1,10 @@ +ffmpeg \ + -r 30000/1001 \ + -i 'frames/dither-%04d.png' \ + -i 'cats-audio.wav' \ + -ac 2 \ + -ar 48000 \ + -vf 'pad=w=534' \ + -pix_fmt yuv420p \ + -movflags +faststart \ + -y cats-dither.mp4 diff --git a/video-cat/extract.sh b/video-cat/extract.sh new file mode 100644 index 0000000..b4faa3c --- /dev/null +++ b/video-cat/extract.sh @@ -0,0 +1,17 @@ +set -a + +mkdir -p frames + +ffmpeg \ + -i 'cats computer fun.mp4' \ + -vf 'scale=256:144' \ + -an \ + -y 'frames/cats-%04d.png' + +ffmpeg \ + -i 'cats computer fun.mp4' \ + -vn \ + -ac 1 \ + -ar 15734 \ + -acodec pcm_u8 \ + -y 'cats-audio.wav' diff --git a/video-cat/video.sh b/video-cat/video.sh new file mode 100644 index 0000000..105fc48 --- /dev/null +++ b/video-cat/video.sh @@ -0,0 +1,15 @@ +set -e + +for frame in frames/cats-[0-9][0-9][0-9][0-9].png +do + n="${frame#frames/cats-}" + n="${n%.png}" + out="frames/dither-${n}" + last="${n:0-1}" + node ../dither-image.js "$frame" "$out" & + if (( last == 9 )) + then + wait + fi +done +wait diff --git a/video-doom/combine.sh b/video-doom/combine.sh new file mode 100644 index 0000000..45e2f68 --- /dev/null +++ b/video-doom/combine.sh @@ -0,0 +1,10 @@ +ffmpeg \ + -r 60000/1001 \ + -i 'frames/dither-%04d.png' \ + -i 'doom-audio.wav' \ + -ac 2 \ + -ar 48000 \ + -vf 'pad=w=534' \ + -pix_fmt yuv420p \ + -movflags +faststart \ + -y doom-dither.mp4 diff --git a/video-doom/extract.sh b/video-doom/extract.sh new file mode 100644 index 0000000..87d2972 --- /dev/null +++ b/video-doom/extract.sh @@ -0,0 +1,22 @@ +set -a + +mkdir -p frames + +TIME=37.5 + +ffmpeg \ + -i 'doom-speedrun.webm' \ + -t $TIME \ + -r 60000/1001 \ + -vf 'scale=256:192' \ + -an \ + -y 'frames/doom-%04d.png' + +ffmpeg \ + -i 'doom-speedrun.webm' \ + -t $TIME \ + -vn \ + -ac 1 \ + -ar 15734 \ + -acodec pcm_u8 \ + -y 'doom-audio.wav' diff --git a/video-doom/video.sh b/video-doom/video.sh new file mode 100644 index 0000000..3b5730e --- /dev/null +++ b/video-doom/video.sh @@ -0,0 +1,15 @@ +set -e + +for frame in frames/doom-[0-9][0-9][0-9][0-9].png +do + n="${frame#frames/doom-}" + n="${n%.png}" + out="frames/dither-${n}" + last="${n:0-1}" + node ../dither-image.js "$frame" "$out" & + if (( last == 9 )) + then + wait + fi +done +wait diff --git a/video/combine.sh b/video/combine.sh index 02c75ea..38ba115 100644 --- a/video/combine.sh +++ b/video/combine.sh @@ -4,7 +4,7 @@ ffmpeg \ -i 'colamath-audio.wav' \ -ac 2 \ -ar 48000 \ - -vf 'pad=w=640:h=360:x=52:y=20' \ + -vf 'pad=w=534' \ -pix_fmt yuv420p \ -movflags +faststart \ -y colamath-dither.mp4 diff --git a/video/extract.sh b/video/extract.sh index e626e3c..237613c 100644 --- a/video/extract.sh +++ b/video/extract.sh @@ -4,7 +4,7 @@ mkdir -p frames ffmpeg \ -i colamath-dv.avi \ - -vf 'yadif=1,scale=160:200,crop=h=160' \ + -vf 'yadif=1,scale=256:192' \ -an \ -y 'frames/colamath-%04d.png'