Skip to content

feat: --gif mode (frames → inline animated GIF)#33

Merged
Hinne1 merged 16 commits into
mainfrom
claude/gh-attach-gif-mode
Jul 5, 2026
Merged

feat: --gif mode (frames → inline animated GIF)#33
Hinne1 merged 16 commits into
mainfrom
claude/gh-attach-gif-mode

Conversation

@Hinne1

@Hinne1 Hinne1 commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

What

Adds gh attach --gif: assembles ordered PNG/JPEG frames into a single animated GIF and uploads that one GIF inline via the existing refs/uploads/issues/<N> path (a .gif is already in imageExtensions, so it renders inline and autoplays — an .mp4 would only link).

Encoding is pure Goimage/gif + github.com/ericpauley/go-quantize (median-cut palette per frame + Floyd–Steinberg dither). No ffmpeg, no npm, no native binaries — exactly one new MIT dependency.

Why

Foundation (slice C1) of a design to attach short video verification clips to frontend PRs: the house verify harness (Playwright) captures frames via CDP screencast, and this mode turns them into an inline autoplay clip on the PR — no ffmpeg (which breaks CI) and no JS deps.

Surface

gh attach --gif [--name clip.gif] [--delay 80] [--colors 256] \
          [--max-frames 300] [--size-ceiling 5242880] <PR> frame-*.png
  • Frames play in filename order (zero-pad them); all frames must share dimensions.
  • --delay ms per frame (rounded to GIF's 1/100s, clamped 2..65535); --colors 2–256.
  • Guards: --max-frames evenly samples down; --size-ceiling triggers one reduced re-encode (fewer colors, half the frames). Out-of-range --colors/--delay are rejected, not coerced. --name is gated by validateName.
  • The upload/render/comment path is unchanged--gif only collapses the frame list to one gif beforehand.

Verification

  • All new/changed logic is TDD'd; go test ./..., go vet, golangci-lint (0 issues) green.
  • End-to-end against GitHub: built the binary, ran --gif --key, uploaded, downloaded the blob back → valid GIF89a, 8 frames, loop=0, 480×240, correct delay; viewed it (legible text, clean gradient). Demo GIF posted below via gh attach itself.

Follow-ups (non-blocking)

Tracked in #32 — JSON-mode warning field, re-encode-failure fallback, combined-guards test, --name extension UX nudge, shared name-sanitization helper. All Minor from the whole-branch review; none block this PR.

https://claude.ai/code/session_01XGKirUaMS7kttgBwCzcJ3B

Hinne1 added 15 commits July 5, 2026 08:33
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.
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.
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.
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.
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.
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).
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.
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.
@Hinne1

Hinne1 commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Attachments

Demo: 8 frames → --gif → this inline autoplay clip (pure Go, no ffmpeg)

verify.gif
verify.gif

…ncode 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
@Hinne1 Hinne1 marked this pull request as ready for review July 5, 2026 08:23
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

Merging this branch changes the coverage (1 decrease, 1 increase)

Impacted Packages Coverage Δ 🤖
github.com/enthus-appdev/gh-attach/internal/cli 96.58% (-1.58%) 👎
github.com/enthus-appdev/gh-attach/internal/gifenc 96.67% (+96.67%) 🌟

Coverage by file

Changed files (no unit tests)

Changed File Coverage Δ Total Covered Missed 🤖
github.com/enthus-appdev/gh-attach/internal/cli/gif.go 86.89% (+86.89%) 61 (+61) 53 (+53) 8 (+8) 🌟
github.com/enthus-appdev/gh-attach/internal/cli/run.go 97.08% (-0.81%) 171 (+29) 166 (+27) 5 (+2) 👎
github.com/enthus-appdev/gh-attach/internal/gifenc/gifenc.go 96.67% (+96.67%) 30 (+30) 29 (+29) 1 (+1) 🌟

Please note that the "Total", "Covered", and "Missed" counts above refer to code statements instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code.

Changed unit test files

  • github.com/enthus-appdev/gh-attach/internal/cli/gif_test.go
  • github.com/enthus-appdev/gh-attach/internal/cli/run_gif_test.go
  • github.com/enthus-appdev/gh-attach/internal/gifenc/gifenc_test.go

@Hinne1 Hinne1 merged commit 663d13d into main Jul 5, 2026
15 checks passed
@Hinne1 Hinne1 deleted the claude/gh-attach-gif-mode branch July 5, 2026 08:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant