From 5c5d338938e286a5a24048186d79e36b192fbe56 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 2 Oct 2026 20:53:32 +0200 Subject: Add --gif option to write a preview GIF Runs the same crop/scale/fps/frame-cap ffmpeg chain to a GIF file instead of uploading, so the result can be previewed or shared. Replaces the separate video2ak820 script. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +++-- README.md | 6 +++++- src/main.rs | 38 ++++++++++++++++++++++++++++++-------- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d5f5081..ef1fed7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,9 @@ Guidance for AI coding agents working in this repository. `ak820-upload`: single-file Rust CLI (`src/main.rs`, std only, no crates) that writes an animation to the AJAZZ AK820 Pro screen over Linux hidraw. ffmpeg (subprocess) decodes any input to raw RGB565LE frames; the program -builds the payload and drives the upload. `README.md` documents the protocol. +builds the payload and drives the upload. `--gif out.gif` runs the same +ffmpeg crop/scale/fps chain to a GIF file instead (preview/share, no device). +`README.md` documents the protocol. On the maintainer's machine the release binary is linked into PATH, so a `cargo build --release` is live immediately: @@ -46,4 +48,3 @@ replug it before retrying. ## Repo facts - License: GPL-2.0-only (`LICENSE`, SPDX header in `src/main.rs`). -- Companion repo: `video2ak820` (bash, video to GIF). diff --git a/README.md b/README.md index 680e683..7e5fb89 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,13 @@ to switch pages). ```sh cargo build --release -./target/release/ak820-upload [fps] +./target/release/ak820-upload [--gif out.gif] [fps] ``` +`--gif out.gif` writes exactly what would be uploaded (same crop, scale, +frame rate and frame cap) as a GIF for previewing or sharing, and does not +touch the keyboard. + `fps` defaults to 15 and must be between 2 and 50: the keyboard stores each frame's delay in 2 ms units in one byte, so no frame can last longer than about 510 ms. At 15 fps the 255-frame limit gives about 17 seconds. diff --git a/src/main.rs b/src/main.rs index fe2e00d..58633b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -112,30 +112,52 @@ fn payload(frames: &[u8], delay_ms: u32) -> Vec { out } -/// Decode any ffmpeg-readable input to raw 128x128 RGB565LE frames. -fn decode(input: &str, fps: u32) -> Vec { - let vf = format!("fps={fps},crop='min(iw,ih)':'min(iw,ih)',scale=128:128:flags=lanczos"); +/// Run ffmpeg on `input` with the keyboard's crop/scale/fps/frame cap, then +/// `extra` (filter suffix) and `out_args` (output options and target). +fn ffmpeg(input: &str, fps: u32, extra: &str, out_args: &[&str]) -> Vec { + let vf = format!("fps={fps},crop='min(iw,ih)':'min(iw,ih)',scale=128:128:flags=lanczos{extra}"); let out = Command::new("ffmpeg") - .args(["-v", "error", "-i", input, "-vf", &vf, "-frames:v"]) + .args(["-v", "error", "-y", "-i", input, "-filter_complex", &vf, "-frames:v"]) .arg(MAX_FRAMES.to_string()) - .args(["-f", "rawvideo", "-pix_fmt", "rgb565le", "-"]) + .args(out_args) .output() .unwrap_or_else(|e| die(format!("ffmpeg: {e}"))); - if !out.status.success() || out.stdout.len() < FRAME_BYTES { + if !out.status.success() { die(format!("ffmpeg failed on {input}: {}", String::from_utf8_lossy(&out.stderr).trim())); } out.stdout } +/// Decode any ffmpeg-readable input to raw 128x128 RGB565LE frames. +fn decode(input: &str, fps: u32) -> Vec { + let raw = ffmpeg(input, fps, "", &["-f", "rawvideo", "-pix_fmt", "rgb565le", "-"]); + if raw.len() < FRAME_BYTES { + die(format!("{input}: no frames decoded")); + } + raw +} + fn main() { - let args: Vec = std::env::args().skip(1).collect(); + let mut args: Vec = std::env::args().skip(1).collect(); + let gif = args.iter().position(|a| a == "--gif").map(|i| { + args.remove(i); + if i < args.len() { args.remove(i) } else { die("--gif needs an output file") } + }); let (input, fps) = match args.as_slice() { [i] => (i.as_str(), 15), [i, f] => (i.as_str(), f.parse().ok().filter(|f| (2..=50).contains(f)) .unwrap_or_else(|| die("fps must be 2..50 (max frame time is ~510 ms)"))), - _ => die("usage: ak820-upload [fps, default 15]"), + _ => die("usage: ak820-upload [--gif out.gif] [fps, default 15]"), }; + // --gif: write what would be uploaded as a GIF instead of uploading. + if let Some(out) = gif { + ffmpeg(input, fps, ",split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4", + &["-loop", "0", &out]); + eprintln!("wrote {out}"); + return; + } + let frames = decode(input, fps); let n = frames.len() / FRAME_BYTES; let data = payload(&frames, 1000 / fps); -- cgit v1.2.3