diff --git a/docs/superpowers/plans/2026-07-15-pure-go-exiv-plan.md b/docs/superpowers/plans/2026-07-15-pure-go-exiv-plan.md new file mode 100644 index 0000000..0a06480 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-pure-go-exiv-plan.md @@ -0,0 +1,412 @@ +# Pure-Go WebP EXIF RIFF Injector Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Modify `helper/exif.go` and `helper/media_convert.go` to inject EXIF payloads directly into WebP RIFF byte slices entirely in Go memory, replacing `webpmux`. + +**Architecture:** We will implement an `InjectExif(webpBytes []byte, packName, publisher string) ([]byte, error)` function inside `helper/exif.go` to handle raw RIFF parsing in place, and strip out the old Temp File logic. Webpmux invocation in `helper/media_convert.go` will be removed. + +**Tech Stack:** Go (encoding/binary) + +## Global Constraints +- Target files: `helper/exif.go` and `helper/media_convert.go` +- Do not use CGO or any unstated 3rd party package for RIFF handling. Implement manually using `encoding/binary`. +- Format must be `VP8X` with the EXIF flag bit activated. + +--- + +### Task 1: Clean Up media_convert.go + +**Files:** +- Modify:`/Users/fd/Documents/perf/helper/media_convert.go` + +**Interfaces:** +- Consumes: Nothing +- Produces: `ConvertImageToWebp`, `ConvertVideoToWebp` + +- [ ] **Step 1: Simplify Convert methods** +Remove `InjectExif` from `media_convert.go` since it will be rewritten from scratch in `exif.go`. + +```go +package helper + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/google/uuid" +) + +func ConvertImageToWebp(imgData []byte) ([]byte, error) { + convertCmd := exec.Command("convert", "-", "-resize", "512x512", "-background", "none", "-compose", "Copy", "-gravity", "center", "-extent", "512x512", "-quality", "100", "png:-") + convertCmd.Stdin = bytes.NewReader(imgData) + var convertBuf bytes.Buffer + convertCmd.Stdout = &convertBuf + if err := convertCmd.Run(); err != nil { + return nil, fmt.Errorf("convert failed: %w", err) + } + + cwebpCmd := exec.Command("cwebp", "-quiet", "-mt", "-exact", "-q", "100", "-m", "6", "-alpha_q", "100", "-o", "-", "--", "-") + cwebpCmd.Stdin = &convertBuf + var cwebpBuf bytes.Buffer + cwebpCmd.Stdout = &cwebpBuf + if err := cwebpCmd.Run(); err != nil { + return nil, fmt.Errorf("cwebp failed: %w", err) + } + + return cwebpBuf.Bytes(), nil +} + +func ConvertVideoToWebp(vidData []byte) ([]byte, error) { + webpPath := filepath.Join("temp", fmt.Sprintf("%s.webp", uuid.New().String())) + defer os.Remove(webpPath) + + ffmpegCmd := exec.Command("ffmpeg", + "-y", "-hide_banner", "-loglevel", "error", + "-i", "pipe:0", "-f", "mp4", + "-ss", "00:00:00", "-t", "00:00:15", + "-vf", "fps=10,scale=720:-1:flags=lanczos:force_original_aspect_ratio=increase,crop=512:512,pad=512:512:(ow-iw)/2:(oh-ih)/2:color=#00000000,setsar=1", + "-compression_level", "6", + "-q:v", "60", "-loop", "0", + "-preset", "picture", "-an", "-fps_mode", "auto", + "-f", "webp", webpPath, + ) + ffmpegCmd.Stdin = bytes.NewReader(vidData) + if err := ffmpegCmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg failed: %w", err) + } + + return os.ReadFile(webpPath) +} +``` + +- [ ] **Step 2: Check standard test suite** +Run: `go build ./...` + +- [ ] **Step 3: Commit** +```bash +git add helper/media_convert.go +git commit -m "refactor: remove InjectExif shell wrapper from media_convert" +``` + +### Task 2: Implement Pure-Go RIFF Injector + +**Files:** +- Modify:`/Users/fd/Documents/perf/helper/exif.go` + +**Interfaces:** +- Produces: `InjectExif(webpData []byte, packName, publisher string) ([]byte, error)` +- Consumes: Nothing + +- [ ] **Step 1: Write `helper/exif.go`** +Overwrite `/Users/fd/Documents/perf/helper/exif.go` with the completely new implementation. The previous dynamic `temp/default.exif` file logic is gone. + +```go +package helper + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "math/big" +) + +type StickerMetadata struct { + PackId string `json:"sticker-pack-id"` + Name string `json:"sticker-pack-name"` + Publisher string `json:"sticker-pack-publisher"` + Emojis []string `json:"emojis,omitempty"` +} + +func writeUIntLE(buffer []byte, value, offset, byteLength int64) { + slice := make([]byte, byteLength) + val := new(big.Int) + val.SetUint64(uint64(value)) + valBytes := val.Bytes() + + tmp := make([]byte, len(valBytes)) + for i := range valBytes { + tmp[i] = valBytes[len(valBytes)-1-i] + } + copy(slice, tmp) + copy(buffer[offset:], slice) +} + +func createMetadataBytes(packName, publisher string) []byte { + exifObj := StickerMetadata{ + PackId: "com.wa.bot.sticker", + Name: packName, + Publisher: publisher, + Emojis: []string{"😀"}, + } + + exifJson, err := json.Marshal(exifObj) + if err != nil { + return nil + } + + bit := []byte{0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x41, 0x57, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x16, 0x00, 0x00, 0x00} + bit = append(bit, exifJson...) + writeUIntLE(bit, int64(len(exifJson)), 14, 4) + + return bit +} + +// InjectExif takes raw WebP bytes and constructs a new WebP RIFF byte slice containing the EXIF metadata +func InjectExif(webpData []byte, packName, publisher string) ([]byte, error) { + if len(webpData) < 12 || string(webpData[0:4]) != "RIFF" || string(webpData[8:12]) != "WEBP" { + return nil, fmt.Errorf("not a valid WEBP file") + } + + var width, height uint32 + hasVP8X := false + var vp8xFlags byte + var vp8xBytes []byte + + var chunks [][]byte + + // Default defaults for VP8X canvas sizes + width = 512 + height = 512 + + offset := 12 + for offset < len(webpData) { + if offset+8 > len(webpData) { + break + } + chunkID := string(webpData[offset : offset+4]) + chunkSize := binary.LittleEndian.Uint32(webpData[offset+4 : offset+8]) + + paddedSize := chunkSize + if paddedSize%2 != 0 { + paddedSize++ + } + + if offset+8+int(paddedSize) > len(webpData) { + break + } + + chunkData := webpData[offset+8 : offset+8+int(paddedSize)] + + switch chunkID { + case "VP8X": + hasVP8X = true + vp8xFlags = chunkData[0] + vp8xBytes = chunkData + // extract dimensions + wBytes := []byte{chunkData[4], chunkData[5], chunkData[6], 0} + hBytes := []byte{chunkData[7], chunkData[8], chunkData[9], 0} + width = binary.LittleEndian.Uint32(wBytes) + 1 + height = binary.LittleEndian.Uint32(hBytes) + 1 + case "VP8 ": + if !hasVP8X { + // extract 14 bit scale block + w := uint32(chunkData[6]) | (uint32(chunkData[7]) << 8) + width = w & 0x3FFF + h := uint32(chunkData[8]) | (uint32(chunkData[9]) << 8) + height = h & 0x3FFF + } + chunks = append(chunks, webpData[offset:offset+8+int(paddedSize)]) + case "VP8L": + if !hasVP8X { + // Parse bits 1-28 for w/h + b0 := uint32(chunkData[1]) + b1 := uint32(chunkData[2]) + b2 := uint32(chunkData[3]) + b3 := uint32(chunkData[4]) + width = 1 + (((b1 & 0x3F) << 8) | b0) + height = 1 + (((b3 & 0xF) << 10) | (b2 << 2) | ((b1 & 0xC0) >> 6)) + } + chunks = append(chunks, webpData[offset:offset+8+int(paddedSize)]) + default: + if chunkID != "EXIF" { + chunks = append(chunks, webpData[offset:offset+8+int(paddedSize)]) + } + } + offset += 8 + int(paddedSize) + } + + exifMeta := createMetadataBytes(packName, publisher) + if exifMeta == nil { + return nil, fmt.Errorf("failed to create exif byte payload") + } + + // Turn on EXIF flag (bit 3) in VP8X + if !hasVP8X { + vp8xBytes = make([]byte, 10) + vp8xFlags = 0x08 // EXIF flag is bit 3 + vp8xBytes[0] = vp8xFlags + w := width - 1 + h := height - 1 + vp8xBytes[4] = byte(w) + vp8xBytes[5] = byte(w >> 8) + vp8xBytes[6] = byte(w >> 16) + vp8xBytes[7] = byte(h) + vp8xBytes[8] = byte(h >> 8) + vp8xBytes[9] = byte(h >> 16) + } else { + vp8xBytes[0] = vp8xFlags | 0x08 + } + + var buf bytes.Buffer + buf.WriteString("RIFF") + buf.Write([]byte{0, 0, 0, 0}) // Total size placeholder + buf.WriteString("WEBP") + + // Write VP8X + buf.WriteString("VP8X") + vp8xSize := make([]byte, 4) + binary.LittleEndian.PutUint32(vp8xSize, 10) + buf.Write(vp8xSize) + buf.Write(vp8xBytes) + + // Write existing chunks + for _, chunk := range chunks { + buf.Write(chunk) + } + + // Write EXIF + buf.WriteString("EXIF") + exifSize := uint32(len(exifMeta)) + exifSizeSlice := make([]byte, 4) + binary.LittleEndian.PutUint32(exifSizeSlice, exifSize) + buf.Write(exifSizeSlice) + buf.Write(exifMeta) + if exifSize%2 != 0 { + buf.WriteByte(0) + } + + result := buf.Bytes() + totalSize := uint32(len(result) - 8) + binary.LittleEndian.PutUint32(result[4:8], totalSize) + + return result, nil +} +``` + +- [ ] **Step 2: Check test suite** +Run: `go build ./...` + +- [ ] **Step 3: Commit** +```bash +git add helper/exif.go +git commit -m "feat: implement pure-go RIFF EXIF injector" +``` + +### Task 3: Update cmds/media/sticker.go Signature + +**Files:** +- Modify:`/Users/fd/Documents/perf/cmds/media/sticker.go` + +**Interfaces:** +- Consumes: `helper.InjectExif(rawWebp, packName, publisher)` + +- [ ] **Step 1: Replace ResolveExif usages in `cmds/media/sticker.go`** +Read `ctx.Arguments()` directly to compute `packName` and `publisher` locally instead of calling `helper.ResolveExif`. Use the same ENV logic as fallback defaults if args are empty. Remove `helper.ResolveExif` code context cleanup. + +```go +package media + +import ( + "context" + "fmt" + "os" + "strings" + + hc "neo/context" + "neo/core" + "neo/helper" +) + +func init() { core.Default.Register(Sticker) } + +var Sticker = &core.Command{ + Name: "sticker", + Aliases: []string{"s", "stiker"}, + Category: "media", + Description: "Create sticker from image/video or rewrite existing sticker EXIF", + Run: func(ctx *hc.Ctx) { + ctx.React("👌") + + args := ctx.Arguments() + packName := os.Getenv("STICKER_NAME") + if packName == "" { + packName = "Bot" + } + publisher := os.Getenv("STICKER_PUBLISHER") + if publisher == "" { + publisher = "Bot" + } + + if len(args) > 0 { + argStr := strings.Join(args, " ") + parts := strings.Split(argStr, "|") + packName = strings.TrimSpace(parts[0]) + publisher = "Bot" + if len(parts) > 1 { + publisher = strings.TrimSpace(parts[1]) + } + } + + msg := ctx.Message() + quoted := hc.GetQuotedMessage(msg) + + var err error + var rawWebp []byte + var mediaData []byte + + if img := msg.GetImageMessage(); img != nil { + mediaData, err = ctx.Client().Download(context.Background(), img) + if err == nil { + rawWebp, err = helper.ConvertImageToWebp(mediaData) + } + } else if img := quoted.GetImageMessage(); img != nil && quoted != nil { + mediaData, err = ctx.Client().Download(context.Background(), img) + if err == nil { + rawWebp, err = helper.ConvertImageToWebp(mediaData) + } + } else if vid := msg.GetVideoMessage(); vid != nil { + mediaData, err = ctx.Client().Download(context.Background(), vid) + if err == nil { + rawWebp, err = helper.ConvertVideoToWebp(mediaData) + } + } else if vid := quoted.GetVideoMessage(); vid != nil && quoted != nil { + mediaData, err = ctx.Client().Download(context.Background(), vid) + if err == nil { + rawWebp, err = helper.ConvertVideoToWebp(mediaData) + } + } else if smsg := quoted.GetStickerMessage(); smsg != nil && quoted != nil { + rawWebp, err = ctx.Client().Download(context.Background(), smsg) + } else { + ctx.Reply("error: reply to image/video/sticker or send with caption") + return + } + + if err != nil { + ctx.Reply(fmt.Sprintf("error processing media: %v", err)) + return + } + + finalSticker, err := helper.InjectExif(rawWebp, packName, publisher) + if err != nil { + ctx.Reply(fmt.Sprintf("error injecting EXIF: %v", err)) + return + } + + ctx.SendSticker(finalSticker) + }, +} +``` + +- [ ] **Step 2: Verify Compilation** +Run: `go build ./...` + +- [ ] **Step 3: Commit** +```bash +git add cmds/media/sticker.go +git commit -m "feat: use pure-go exif injector in sticker command" +```