package comicanimator
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"html/template"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.michelsen.id/phill/preface-tools/internal/auth"
"git.michelsen.id/phill/preface-tools/internal/tools"
"git.michelsen.id/phill/preface-tools/internal/tools/comicanimator/openrouter"
)
type ApprovalVerifier interface{ VerifyInstructorPIN(string) bool }
type Tool struct {
cfg Config
log *slog.Logger
approval ApprovalVerifier
store *store
queue chan string
client *openrouter.Client
approvalLimiter *auth.Limiter
student, instructor http.Handler
}
func New(cfg Config, log *slog.Logger, approval ApprovalVerifier) (*Tool, error) {
if err := os.MkdirAll(cfg.UploadDirectory, 0750); err != nil {
return nil, err
}
if err := os.MkdirAll(cfg.OutputDirectory, 0750); err != nil {
return nil, err
}
t := &Tool{cfg: cfg, log: log, approval: approval, store: newStore(), queue: make(chan string, cfg.QueueCapacity)}
if _, err := t.systemPrompt(); err != nil {
return nil, fmt.Errorf("load Comic Animator system prompt: %w", err)
}
t.approvalLimiter = auth.NewLimiter(5, 5*time.Minute)
t.client = &openrouter.Client{BaseURL: cfg.OpenRouterBaseURL, APIKey: cfg.OpenRouterAPIKey, SiteURL: cfg.OpenRouterSiteURL, AppName: cfg.OpenRouterAppName, HTTP: &http.Client{Timeout: cfg.HTTPTimeout}}
sm := http.NewServeMux()
sm.HandleFunc("GET /", t.page)
sm.HandleFunc("POST /uploads", t.upload)
sm.HandleFunc("GET /uploads/{id}/preview", t.preview)
sm.HandleFunc("POST /prompt", t.prompt)
sm.HandleFunc("POST /generations", t.submit)
sm.HandleFunc("GET /generations", t.generations)
sm.HandleFunc("GET /generations/{id}/status", t.status)
sm.HandleFunc("GET /generations/{id}/video", t.video)
sm.HandleFunc("GET /generations/{id}/download", t.download)
sm.HandleFunc("GET /provider-media/{id}", t.providerMedia)
t.student = sm
im := http.NewServeMux()
im.HandleFunc("GET /", t.instructorPage)
im.HandleFunc("GET /generations", t.instructorGenerations)
im.HandleFunc("GET /generations/{id}/video", t.instructorVideo)
im.HandleFunc("GET /generations/{id}/download", t.instructorDownload)
im.HandleFunc("GET /outputs/{filename}/download", t.outputDownload)
t.instructor = im
return t, nil
}
func (t *Tool) Definition() tools.Definition {
return tools.Definition{Key: "comic-animator", Name: "Comic Animator", Description: "Bring a comic page gently to life."}
}
func (t *Tool) StudentHandler() http.Handler { return t.student }
func (t *Tool) InstructorHandler() http.Handler { return t.instructor }
func id(prefix string) string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return prefix + hex.EncodeToString(b)
}
func claims(r *http.Request) auth.Claims { c, _ := auth.ClaimsFrom(r); return c }
const maxSystemPromptBytes = 64 << 10
func (t *Tool) systemPrompt() (string, error) {
b, err := os.ReadFile(t.cfg.PromptFile)
if err != nil {
return "", err
}
if len(b) == 0 || len(b) > maxSystemPromptBytes {
return "", fmt.Errorf("system prompt must contain between 1 and %d bytes", maxSystemPromptBytes)
}
prompt := strings.TrimSpace(string(b))
if prompt == "" {
return "", fmt.Errorf("system prompt is empty")
}
return prompt, nil
}
func (t *Tool) page(w http.ResponseWriter, r *http.Request) {
render(w, toolPage, map[string]any{"CSRF": claims(r).CSRFToken, "Duration": t.cfg.VideoDuration})
}
func (t *Tool) upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, t.cfg.MaxUploadBytes+1<<20)
if err := r.ParseMultipartForm(t.cfg.MaxUploadBytes); err != nil {
fragmentError(w, http.StatusRequestEntityTooLarge, "Image is too large.")
return
}
f, h, err := r.FormFile("image")
if err != nil {
fragmentError(w, 400, "Choose an image.")
return
}
defer f.Close()
tmp, err := os.CreateTemp(t.cfg.UploadDirectory, ".upload-*")
if err != nil {
fragmentError(w, 500, "Could not store image.")
return
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
n, err := io.Copy(tmp, io.LimitReader(f, t.cfg.MaxUploadBytes+1))
tmp.Close()
if err != nil || n == 0 || n > t.cfg.MaxUploadBytes {
fragmentError(w, 413, "Invalid image size.")
return
}
rf, err := os.Open(tmpName)
if err != nil {
fragmentError(w, 500, "Could not inspect image.")
return
}
head := make([]byte, 512)
hn, _ := rf.Read(head)
_, seekErr := rf.Seek(0, 0)
var decodeErr error
if http.DetectContentType(head[:hn]) == "image/webp" {
decodeErr = validateWebP(head[:hn])
} else {
_, _, decodeErr = image.DecodeConfig(rf)
}
rf.Close()
if seekErr != nil || decodeErr != nil {
fragmentError(w, 415, "The image is corrupt or unsupported.")
return
}
mt := http.DetectContentType(head[:hn])
declared := strings.Split(h.Header.Get("Content-Type"), ";")[0]
if declared != "" && declared != "application/octet-stream" && declared != mt {
fragmentError(w, http.StatusUnsupportedMediaType, "The image type does not match its content.")
return
}
ext := map[string]string{"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"}[mt]
if ext == "" {
fragmentError(w, 415, "Use PNG, JPEG, or WebP.")
return
}
uid := id("upl_")
path := filepath.Join(t.cfg.UploadDirectory, uid+ext)
if err = os.Rename(tmpName, path); err != nil {
fragmentError(w, 500, "Could not store image.")
return
}
u := Upload{ID: uid, SessionID: claims(r).SessionID, Path: path, Name: filepath.Base(h.Filename), MIMEType: mt, Size: n, CreatedAt: time.Now().UTC()}
t.store.putUpload(u)
fmt.Fprintf(w, `

%s
`, template.HTMLEscapeString(uid), template.HTMLEscapeString(uid), template.HTMLEscapeString(u.Name), template.HTMLEscapeString(u.Name))
}
func validateWebP(head []byte) error {
if len(head) < 20 || string(head[:4]) != "RIFF" || string(head[8:12]) != "WEBP" {
return fmt.Errorf("invalid WebP header")
}
chunk := string(head[12:16])
if chunk != "VP8 " && chunk != "VP8L" && chunk != "VP8X" {
return fmt.Errorf("invalid WebP chunk")
}
return nil
}
func (t *Tool) ownedUpload(r *http.Request, id string) (Upload, error) {
u, ok := t.store.upload(id)
if !ok {
return u, os.ErrNotExist
}
if u.SessionID != claims(r).SessionID {
return u, errForbidden
}
return u, nil
}
func (t *Tool) preview(w http.ResponseWriter, r *http.Request) {
u, err := t.ownedUpload(r, r.PathValue("id"))
if err != nil {
http.NotFound(w, r)
return
}
serveFile(w, r, u.Path, u.MIMEType, false)
}
func (t *Tool) prompt(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 128<<10)
u, err := t.ownedUpload(r, r.FormValue("upload_id"))
desc := strings.TrimSpace(r.FormValue("description"))
if err != nil || desc == "" {
fragmentError(w, 400, "Upload an image and describe the movement first.")
return
}
if len(desc) > 12000 {
fragmentError(w, 400, "Description is too long.")
return
}
b, err := os.ReadFile(u.Path)
if err != nil {
fragmentError(w, 500, "Could not read image.")
return
}
dataURL := "data:" + u.MIMEType + ";base64," + base64.StdEncoding.EncodeToString(b)
systemPrompt, err := t.systemPrompt()
if err != nil {
t.log.Error("could not load system prompt", "path", t.cfg.PromptFile, "error", err)
fragmentError(w, 500, "The prompt configuration could not be loaded.")
return
}
ctx, cancel := context.WithTimeout(r.Context(), t.cfg.HTTPTimeout)
defer cancel()
result, err := t.client.Prompt(ctx, t.cfg.PromptModel, desc, dataURL, systemPrompt)
if err != nil {
t.log.Error("prompt generation failed", "error", err)
fragmentError(w, 502, "Prompt generation is temporarily unavailable.")
return
}
if len(result) > 12000 {
fragmentError(w, 502, "Generated prompt was too long.")
return
}
fmt.Fprintf(w, `%d characters`, template.HTMLEscapeString(result), len([]rune(result)))
}
func (t *Tool) submit(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 128<<10)
key := claims(r).SessionID
if !t.approvalLimiter.Allow(key) || !t.approval.VerifyInstructorPIN(r.FormValue("instructor_pin")) {
t.approvalLimiter.Fail(key)
w.Header().Set("HX-Retarget", "#approval-error")
w.Header().Set("HX-Reswap", "innerHTML")
fragmentError(w, 403, "Approval was not accepted.")
return
}
t.approvalLimiter.Success(key)
u, err := t.ownedUpload(r, r.FormValue("upload_id"))
prompt := strings.TrimSpace(r.FormValue("reviewed_prompt"))
duration, errDuration := strconv.Atoi(r.FormValue("duration"))
if err != nil || prompt == "" || len(prompt) > 12000 || errDuration != nil || duration != t.cfg.VideoDuration {
fragmentError(w, 400, "Check the image, prompt, and settings.")
return
}
now := time.Now().UTC()
g := Generation{ID: id("gen_"), SessionID: claims(r).SessionID, UploadID: u.ID, OriginalName: u.Name, ReviewedPrompt: prompt, Status: Queued, Duration: duration, Resolution: t.cfg.VideoResolution, CreatedAt: now, UpdatedAt: now}
t.store.putGeneration(g)
select {
case t.queue <- g.ID:
w.Header().Set("HX-Trigger", `{"generationQueued":{"id":"`+g.ID+`"}}`)
t.renderCard(w, g, false)
default:
t.store.update(g.ID, func(x *Generation) {
x.Status = Failed
x.ErrorCode = "internal_error"
x.ErrorMessage = "Generation queue is full."
})
fragmentError(w, 503, "Generation queue is full.")
}
}
func (t *Tool) generations(w http.ResponseWriter, r *http.Request) {
for _, g := range t.store.list(claims(r).SessionID) {
t.renderCard(w, g, false)
}
}
func (t *Tool) status(w http.ResponseWriter, r *http.Request) {
g, ok := t.store.generation(r.PathValue("id"))
if !ok || g.SessionID != claims(r).SessionID {
http.NotFound(w, r)
return
}
t.renderCard(w, g, false)
}
func (t *Tool) video(w http.ResponseWriter, r *http.Request) { t.generationFile(w, r, false, false) }
func (t *Tool) download(w http.ResponseWriter, r *http.Request) { t.generationFile(w, r, false, true) }
func (t *Tool) generationFile(w http.ResponseWriter, r *http.Request, instructor, download bool) {
g, ok := t.store.generation(r.PathValue("id"))
if !ok || g.Status != Completed || (!instructor && g.SessionID != claims(r).SessionID) {
http.NotFound(w, r)
return
}
serveFile(w, r, g.OutputPath, g.OutputMIMEType, download)
}
func serveFile(w http.ResponseWriter, r *http.Request, path, mt string, download bool) {
w.Header().Set("Content-Type", mt)
w.Header().Set("X-Content-Type-Options", "nosniff")
if download {
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filepath.Base(path), `"`, "")+`"`)
}
http.ServeFile(w, r, path)
}
func (t *Tool) signedURL(uploadID string) string {
exp := time.Now().Add(t.cfg.SignedURLTTL).Unix()
raw := uploadID + "\n" + strconv.FormatInt(exp, 10)
m := hmac.New(sha256.New, []byte(t.cfg.SigningSecret))
m.Write([]byte(raw))
return strings.TrimRight(t.cfg.PublicBaseURL, "/") + "/tools/comic-animator/provider-media/" + url.PathEscape(uploadID) + "?expires=" + strconv.FormatInt(exp, 10) + "&signature=" + hex.EncodeToString(m.Sum(nil))
}
func (t *Tool) providerMedia(w http.ResponseWriter, r *http.Request) {
exp, err := strconv.ParseInt(r.URL.Query().Get("expires"), 10, 64)
if err != nil || time.Now().Unix() > exp {
http.Error(w, "expired", 403)
return
}
raw := r.PathValue("id") + "\n" + strconv.FormatInt(exp, 10)
m := hmac.New(sha256.New, []byte(t.cfg.SigningSecret))
m.Write([]byte(raw))
got, err := hex.DecodeString(r.URL.Query().Get("signature"))
if err != nil || !hmac.Equal(got, m.Sum(nil)) {
http.Error(w, "forbidden", 403)
return
}
u, ok := t.store.upload(r.PathValue("id"))
if !ok {
http.NotFound(w, r)
return
}
serveFile(w, r, u.Path, u.MIMEType, false)
}