diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-02 20:53:32 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-02 20:53:32 +0200 |
| commit | 5c5d338938e286a5a24048186d79e36b192fbe56 (patch) | |
| tree | 0a073791809f650bc7a5f14b5048e5e3e6e86d12 | |
| parent | 0687e489e0e2b273f8c3c466ad77cb2066bc168f (diff) | |
| download | ak820-upload-master.tar.gz ak820-upload-master.zip | |
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 <noreply@anthropic.com>
| -rw-r--r-- | AGENTS.md | 5 | ||||
| -rw-r--r-- | README.md | 6 | ||||
| -rw-r--r-- | src/main.rs | 38 |
3 files changed, 38 insertions, 11 deletions
@@ -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). @@ -20,9 +20,13 @@ to switch pages). ```sh cargo build --release -./target/release/ak820-upload <image|gif|video> [fps] +./target/release/ak820-upload [--gif out.gif] <image|gif|video> [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<u8> { out } -/// Decode any ffmpeg-readable input to raw 128x128 RGB565LE frames. -fn decode(input: &str, fps: u32) -> Vec<u8> { - 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<u8> { + 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<u8> { + 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<String> = std::env::args().skip(1).collect(); + let mut args: Vec<String> = 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 <image|gif|video> [fps, default 15]"), + _ => die("usage: ak820-upload [--gif out.gif] <image|gif|video> [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); |
