Skip to content

Commit 663d13d

Browse files
authored
feat: --gif mode (frames → inline animated GIF) (#33)
* feat: add gifenc package + go-quantize dep (frames to animated GIF) * test: lock delay-rounding and zero-size-frame in gifenc * feat: add assembleGIF cli helper (frame files to temp gif) * fix: reject bare "." / ".." in assembleGIF opts.name filepath.Base has no separator to strip from a bare "." or "..", so it passed them through unchanged and Join resolved outside the temp dir. Fall back to the default name for those two literal cases and pin the fix with a regression test. * fix: reject bare separator in assembleGIF opts.name too filepath.Base of an all-separator path returns a single separator unchanged (nothing to strip), so a "/" name passed the existing "."/ ".." guard and Join collapsed it onto tmpDir itself, failing the write with an is-a-directory error instead of falling back to clip.gif. Extend the guard and pin it with a test case. * feat: add --gif mode to upload flow Wires --gif/--delay/--colors flags into runWithDeps and runUpload so the CLI can collapse ordered frame files into one animated GIF and upload that instead of the individual frames. --name now also labels the --gif output basename and is routed through validateName (not just assembleGIF's internal filepath.Base guard). --gif rejects stdin input, and --colors/--delay are validated at the boundary rather than silently coerced. * fix: bound --delay and require .gif extension on --gif --name Council review flagged two real gaps: --delay had no upper bound (gifenc rounds it into a 16-bit GIF centisecond field, so values above 655350ms would silently wrap and corrupt playback speed instead of erroring), and --gif --name didn't require a .gif extension (comment.go picks inline-vs-link rendering purely from the uploaded basename's extension, so a mismatched name would defeat --gif's whole point). Three other findings from the same review were checked against the actual code and refuted — no fix needed, see task-3-report.md. * test: lock the --delay=655350 boundary for --gif Adds the missing upper-boundary case for the --delay range check (655350ms accepted) so an off-by-one in the > vs >= comparison would be caught. The second council review round's other findings (claiming the .gif-extension guard on --name was reachable with an empty name, without --gif, or via stdin) were all refuted: the guard is nested inside opts.gif && opts.name != "", and --gif + stdin is already rejected earlier in the same function — both already covered by passing tests. * test: avoid cap() shadowing, decode-verify max-delay gif output Renames the cap variable to captured across the added gif validation tests (the brief's own verbatim Step-1 test still uses cap and is left untouched). Also strengthens TestRun_GifMode_AcceptsMaxDelay to decode the produced GIF and assert the encoded delay is exactly 65535 centiseconds, via a gifBytesCapturingGitClient that reads the pushed file's bytes inside PushAttachments (assembleGIF's deferred cleanup removes the temp file before runWithDeps returns, so bytes can't be read back afterward). * fix: drop unrequested .gif-name restriction (false rationale; keep --delay bound) * feat: add frame-cap and size-ceiling guards to --gif * test: add dedicated sampleEvenly boundary coverage * test: use slices.Equal and cover nil/empty in sampleEvenly test * fix: clamp gif delay to uint16 max to prevent re-encode-path wrap The per-frame delay is written to the GIF stream as a uint16 by encoding/gif; only the low side was clamped, so a delay above 65535 centiseconds (e.g. doubled delay on the --size-ceiling re-encode path) silently truncated to a wrong, too-fast value instead of erroring or saturating. * fix: check DecodeAll error in new upper-clamp test Council review flagged the ignored gif.DecodeAll error: a malformed GIF would leave g nil and panic on the next line instead of failing cleanly. Declined the accompanying boundary-value (65535 vs 65536 cs) suggestion as out of scope for this fix. * fix: document guard flags, reject negative guard values, clarify re-encode comment Council review found README missing docs for --max-frames/--size-ceiling, inconsistent boundary validation (negative values silently treated as "disabled" instead of rejected like --colors/--delay), and an inaccurate comment claiming delay-doubling always preserves playback speed. Claude-Session: https://claude.ai/code/session_01XGKirUaMS7kttgBwCzcJ3B
1 parent 0a0e9d5 commit 663d13d

9 files changed

Lines changed: 954 additions & 4 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,42 @@ gh attach --json 123 screenshot.png
5050
# Read file bytes from stdin with --name BASENAME (see "Reading from stdin" below)
5151
screencapture -i -t png - | gh attach --name shot.png 123 -
5252

53+
# Assemble ordered frames into one inline animated GIF and attach to PR 123
54+
gh attach --gif 123 frames/frame-*.png
55+
56+
# Name the clip and tune playback (per-frame delay in ms) + palette size
57+
gh attach --gif --name verify.gif --delay 80 --colors 128 123 frames/frame-*.png
58+
5359
# Download files back to disk (see "gh attach get" below)
5460
gh attach get 123 --output ./restored
5561
```
5662

5763
By default `gh attach` reads the target repo from the current clone's `origin` remote. Pass `--repo OWNER/NAME` (or a full GitHub URL) to target a different repo or to run from outside any git clone. Whenever `--repo` is used, `NUMBER` or `--key` must be passed explicitly — PR auto-detection only works inside a clone of the target repo.
5864

65+
### Animated GIF mode (`--gif`)
66+
67+
`--gif` assembles the supplied image frames (PNG or JPEG) into a single
68+
animated GIF and uploads *that* — the GIF renders inline and autoplays in
69+
the PR comment, unlike an `.mp4`, which GitHub can only link. Frames play
70+
in filename order, so zero-pad them (`frame-000.png`, `frame-001.png`, …).
71+
All frames must share the same dimensions. `--delay` sets per-frame time in
72+
milliseconds (default 80 ≈ 12 fps); `--colors` sets the per-frame palette
73+
size (default 256). No ffmpeg and no external binaries — encoding is pure Go.
74+
75+
Two guard flags bound runaway captures. `--max-frames` (default 300) caps
76+
the frame count — if the supplied frames exceed it, they're evenly sampled
77+
down to `--max-frames` so playback still covers the whole clip rather than
78+
truncating the tail (`0` disables the cap). `--size-ceiling` (default
79+
5242880 bytes = 5 MB) bounds the encoded GIF's size — if the first encode
80+
exceeds it, `gh attach` re-encodes once with fewer colors and half the
81+
frames (`0` disables the ceiling); if the reduced encode is still over,
82+
it's uploaded anyway with a warning.
83+
84+
```bash
85+
# Bound a long capture: cap frames and re-encode if the GIF is too big
86+
gh attach --gif --max-frames 200 --size-ceiling 3000000 123 frames/frame-*.png
87+
```
88+
5989
### JSON output
6090

6191
Pass `--json` to get a structured result object on stdout instead of the markdown table. Stderr is suppressed in JSON mode (no progress line, no `Uploaded:` URL list) so the output is pipe-friendly:

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
module github.com/enthus-appdev/gh-attach
22

33
go 1.26
4+
5+
require github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 h1:BBade+JlV/f7JstZ4pitd4tHhpN+w+6I+LyOS7B4fyU=
2+
github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4/go.mod h1:H7chHJglrhPPzetLdzBleF8d22WYOv7UM/lEKYiwlKM=

internal/cli/gif.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"image"
6+
_ "image/jpeg" // register JPEG decoder for image.Decode
7+
_ "image/png" // register PNG decoder for image.Decode
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
12+
"github.com/enthus-appdev/gh-attach/internal/gifenc"
13+
)
14+
15+
// gifAssembleOptions carries the flag-derived tunables for --gif mode.
16+
type gifAssembleOptions struct {
17+
name string // output basename; empty → "clip.gif"
18+
delayMS int
19+
numColors int
20+
maxFrames int // cap on frames; 0 → no cap. Excess frames are evenly sampled out.
21+
sizeCeiling int64 // byte ceiling; 0 → no ceiling. Over → one reduced re-encode.
22+
}
23+
24+
// assembleGIF decodes the ordered frame files (PNG or JPEG), applies
25+
// the frame cap, encodes an animated GIF, and (if a size ceiling is
26+
// set and exceeded) re-encodes once with a smaller palette and half
27+
// the frames. framePaths are used in slice order — globbed inputs
28+
// arrive lexicographically, so zero-padded frame-000.png … frame-NNN.png
29+
// names sort into playback order. Returns the temp gif path, a cleanup
30+
// closure (removes the temp dir; must be deferred by the caller), and
31+
// a human-readable warning — every guard that fired contributes a
32+
// message, joined with "; "; empty when none fired.
33+
func assembleGIF(framePaths []string, opts gifAssembleOptions) (string, func(), string, error) {
34+
if len(framePaths) == 0 {
35+
return "", nil, "", fmt.Errorf("no frames to assemble")
36+
}
37+
38+
// Accumulate — both the frame-cap and size-ceiling guards can fire
39+
// in one call, and neither message should silently clobber the other.
40+
var warnings []string
41+
42+
// Frame cap: evenly sample down to maxFrames so playback covers the
43+
// whole clip rather than truncating the tail.
44+
paths := framePaths
45+
if opts.maxFrames > 0 && len(paths) > opts.maxFrames {
46+
paths = sampleEvenly(paths, opts.maxFrames)
47+
warnings = append(warnings, fmt.Sprintf("capped %d frames to %d (--max-frames)", len(framePaths), len(paths)))
48+
}
49+
50+
frames := make([]image.Image, 0, len(paths))
51+
for _, p := range paths {
52+
img, err := decodeImageFile(p)
53+
if err != nil {
54+
return "", nil, "", err
55+
}
56+
frames = append(frames, img)
57+
}
58+
59+
data, err := gifenc.Encode(frames, gifenc.Options{DelayMS: opts.delayMS, NumColors: opts.numColors})
60+
if err != nil {
61+
return "", nil, "", err
62+
}
63+
64+
// Size ceiling: one reduced re-encode — fewer colors, half the
65+
// frames (delay doubled so playback speed is preserved — except at
66+
// extreme per-frame delays, where the doubled value is clamped at
67+
// the encoder's uint16 centisecond max instead of preserving speed).
68+
// The palette is never raised above what the caller asked for, or
69+
// the "reduction" could grow the file for a low --colors value.
70+
if opts.sizeCeiling > 0 && int64(len(data)) > opts.sizeCeiling {
71+
reducedColors := 64
72+
if opts.numColors > 0 && opts.numColors < reducedColors {
73+
reducedColors = opts.numColors
74+
}
75+
reduced := sampleEvenly(frames, (len(frames)+1)/2)
76+
data2, err2 := gifenc.Encode(reduced, gifenc.Options{DelayMS: opts.delayMS * 2, NumColors: reducedColors})
77+
if err2 != nil {
78+
return "", nil, "", err2
79+
}
80+
data = data2
81+
if int64(len(data)) > opts.sizeCeiling {
82+
warnings = append(warnings, fmt.Sprintf("gif is %d bytes, over the %d-byte ceiling even after reduction — uploaded anyway", len(data), opts.sizeCeiling))
83+
} else {
84+
warnings = append(warnings, fmt.Sprintf("gif exceeded the %d-byte ceiling — reduced to %d colors / %d frames", opts.sizeCeiling, reducedColors, len(reduced)))
85+
}
86+
}
87+
88+
// filepath.Base defends assembleGIF as a standalone function: the CLI
89+
// validates --name upstream, but a path in opts.name must never let
90+
// the write escape the temp dir. Base alone isn't enough for three
91+
// degenerate inputs it returns unchanged (nothing to strip): ".."
92+
// (Join would resolve outside tmpDir), "." (Join collapses to
93+
// tmpDir itself), and a bare separator "/" (same collapse — Base
94+
// of an all-separator path is a single separator). Reject all
95+
// three and fall back to the default name.
96+
name := "clip.gif"
97+
if opts.name != "" {
98+
base := filepath.Base(opts.name)
99+
if base != "." && base != ".." && base != "/" && base != string(filepath.Separator) {
100+
name = base
101+
}
102+
}
103+
tmpDir, err := os.MkdirTemp("", "gh-attach-gif-*")
104+
if err != nil {
105+
return "", nil, "", fmt.Errorf("create temp dir: %w", err)
106+
}
107+
cleanup := func() { _ = os.RemoveAll(tmpDir) }
108+
109+
gifPath := filepath.Join(tmpDir, name)
110+
if err := os.WriteFile(gifPath, data, 0o600); err != nil {
111+
cleanup()
112+
return "", nil, "", fmt.Errorf("write gif: %w", err)
113+
}
114+
return gifPath, cleanup, strings.Join(warnings, "; "), nil
115+
}
116+
117+
// sampleEvenly returns n items drawn at even intervals across in,
118+
// always including the first and (when n>1) the last. If n>=len(in)
119+
// or n<=0 it returns in unchanged.
120+
func sampleEvenly[T any](in []T, n int) []T {
121+
if n <= 0 || n >= len(in) {
122+
return in
123+
}
124+
if n == 1 {
125+
return in[:1]
126+
}
127+
out := make([]T, 0, n)
128+
// step across [0, len-1] so both ends are represented.
129+
for i := 0; i < n; i++ {
130+
idx := i * (len(in) - 1) / (n - 1)
131+
out = append(out, in[idx])
132+
}
133+
return out
134+
}
135+
136+
// decodeImageFile opens and decodes a single frame file. Format is
137+
// detected by image.Decode from the registered PNG/JPEG decoders.
138+
func decodeImageFile(path string) (image.Image, error) {
139+
f, err := os.Open(path)
140+
if err != nil {
141+
return nil, fmt.Errorf("open frame %s: %w", path, err)
142+
}
143+
defer func() { _ = f.Close() }()
144+
145+
img, _, err := image.Decode(f)
146+
if err != nil {
147+
return nil, fmt.Errorf("decode frame %s: %w", path, err)
148+
}
149+
return img, nil
150+
}

0 commit comments

Comments
 (0)