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)}
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 systemPrompt = `You prepare image-to-video prompts for complete comic-book pages. Treat the student's description as the source of truth. Create one concise, provider-ready image-to-video prompt. Preserve the full page composition, fixed panel borders, captions, lettering, speech bubbles, dialogue, character identity, clothing, colors, art style, and backgrounds. Do not crop, zoom, pan, rotate, reframe, or let anything cross panels unless explicitly requested. Animate only described actions with restrained secondary movement. Return JSON only: {"video_prompt":"..."}`
func (t *Tool) page(w http.ResponseWriter, r *http.Request) {
render(w, toolPage, map[string]any{"CSRF": claims(r).CSRFToken, "Duration": t.cfg.VideoDuration, "AspectRatio": t.cfg.VideoAspectRatio})
}
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))
}
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)
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"))
aspect := r.FormValue("aspect_ratio")
if err != nil || prompt == "" || len(prompt) > 12000 || errDuration != nil || duration != t.cfg.VideoDuration || aspect != t.cfg.VideoAspectRatio {
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, AspectRatio: aspect, 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)
}