aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-02 20:53:32 +0200
committerDanilo M. <danix@danix.xyz>2026-10-02 20:53:32 +0200
commit5c5d338938e286a5a24048186d79e36b192fbe56 (patch)
tree0a073791809f650bc7a5f14b5048e5e3e6e86d12
parent0687e489e0e2b273f8c3c466ad77cb2066bc168f (diff)
downloadak820-upload-5c5d338938e286a5a24048186d79e36b192fbe56.tar.gz
ak820-upload-5c5d338938e286a5a24048186d79e36b192fbe56.zip
Add --gif option to write a preview GIFHEADmaster
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.md5
-rw-r--r--README.md6
-rw-r--r--src/main.rs38
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 <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);