Codex first iteration

This commit is contained in:
2026-07-12 02:56:51 +08:00
parent 16c626fbd7
commit 7cb409f5a6
20 changed files with 1771 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
package comicanimator
import (
"errors"
"os"
"strconv"
"time"
)
type Config struct {
OpenRouterAPIKey, OpenRouterBaseURL, OpenRouterSiteURL, OpenRouterAppName, PromptModel, VideoModel, VideoResolution, VideoAspectRatio, PublicBaseURL, SigningSecret, UploadDirectory, OutputDirectory string
VideoDuration int
GenerateAudio bool
PollInterval, JobTimeout, HTTPTimeout, SignedURLTTL time.Duration
MaxUploadBytes, MaxGeneratedVideoBytes int64
QueueCapacity int
}
func LoadConfigFromEnv() (Config, error) {
c := Config{OpenRouterAPIKey: os.Getenv("COMIC_ANIMATOR_OPENROUTER_API_KEY"), OpenRouterBaseURL: get("COMIC_ANIMATOR_OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"), OpenRouterSiteURL: os.Getenv("COMIC_ANIMATOR_OPENROUTER_SITE_URL"), OpenRouterAppName: get("COMIC_ANIMATOR_OPENROUTER_APP_NAME", "Preface Tools - Comic Animator"), PromptModel: os.Getenv("COMIC_ANIMATOR_PROMPT_MODEL"), VideoModel: os.Getenv("COMIC_ANIMATOR_VIDEO_MODEL"), VideoResolution: get("COMIC_ANIMATOR_VIDEO_RESOLUTION", "720p"), VideoAspectRatio: get("COMIC_ANIMATOR_VIDEO_ASPECT_RATIO", "16:9"), PublicBaseURL: os.Getenv("PUBLIC_BASE_URL"), SigningSecret: os.Getenv("COMIC_ANIMATOR_SIGNING_SECRET"), UploadDirectory: get("COMIC_ANIMATOR_UPLOAD_DIR", "data/comic-animator/uploads"), OutputDirectory: get("COMIC_ANIMATOR_OUTPUT_DIR", "data/comic-animator/outputs")}
var err error
c.VideoDuration, err = intenv("COMIC_ANIMATOR_VIDEO_DURATION", 6)
if err != nil {
return c, err
}
c.GenerateAudio, err = strconv.ParseBool(get("COMIC_ANIMATOR_GENERATE_AUDIO", "false"))
if err != nil {
return c, err
}
c.PollInterval, err = dur("COMIC_ANIMATOR_POLL_INTERVAL", "30s")
if err != nil {
return c, err
}
c.JobTimeout, err = dur("COMIC_ANIMATOR_JOB_TIMEOUT", "15m")
if err != nil {
return c, err
}
c.HTTPTimeout, err = dur("COMIC_ANIMATOR_HTTP_TIMEOUT", "60s")
if err != nil {
return c, err
}
c.SignedURLTTL, err = dur("COMIC_ANIMATOR_SIGNED_URL_TTL", "30m")
if err != nil {
return c, err
}
c.MaxUploadBytes, err = int64env("COMIC_ANIMATOR_MAX_UPLOAD_BYTES", 20971520)
if err != nil {
return c, err
}
c.MaxGeneratedVideoBytes, err = int64env("COMIC_ANIMATOR_MAX_VIDEO_BYTES", 536870912)
if err != nil {
return c, err
}
c.QueueCapacity, err = intenv("COMIC_ANIMATOR_QUEUE_CAPACITY", 100)
if err != nil {
return c, err
}
if c.OpenRouterAPIKey == "" || c.PromptModel == "" || c.VideoModel == "" || c.PublicBaseURL == "" || len(c.SigningSecret) < 32 {
return c, errors.New("Comic Animator OpenRouter key/models, PUBLIC_BASE_URL, and a 32+ character signing secret are required")
}
if c.VideoDuration < 1 || c.QueueCapacity < 1 || c.MaxUploadBytes < 1 || c.MaxGeneratedVideoBytes < 1 || c.PollInterval <= 0 || c.JobTimeout <= 0 || c.HTTPTimeout <= 0 || c.SignedURLTTL <= 0 {
return c, errors.New("Comic Animator numeric configuration is invalid")
}
return c, nil
}
func get(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func dur(k, d string) (time.Duration, error) { return time.ParseDuration(get(k, d)) }
func intenv(k string, d int) (int, error) {
if os.Getenv(k) == "" {
return d, nil
}
return strconv.Atoi(os.Getenv(k))
}
func int64env(k string, d int64) (int64, error) {
if os.Getenv(k) == "" {
return d, nil
}
return strconv.ParseInt(os.Getenv(k), 10, 64)
}
+88
View File
@@ -0,0 +1,88 @@
package comicanimator
import (
"errors"
"sort"
"sync"
"time"
)
type Upload struct {
ID, SessionID, Path, Name, MIMEType string
Size int64
CreatedAt time.Time
}
type GenerationStatus string
const (
Queued GenerationStatus = "queued"
Submitting GenerationStatus = "submitting"
Pending GenerationStatus = "pending"
InProgress GenerationStatus = "in_progress"
Downloading GenerationStatus = "downloading"
Completed GenerationStatus = "completed"
Failed GenerationStatus = "failed"
)
type Generation struct {
ID, SessionID, UploadID, OriginalName, ReviewedPrompt, ProviderJobID string
Status GenerationStatus
Duration int
Resolution, AspectRatio, OutputPath, OutputMIMEType string
OutputSize int64
ErrorCode, ErrorMessage string
CreatedAt, UpdatedAt time.Time
CompletedAt *time.Time
}
type store struct {
mu sync.RWMutex
uploads map[string]Upload
generations map[string]Generation
}
func newStore() *store {
return &store{uploads: map[string]Upload{}, generations: map[string]Generation{}}
}
func (s *store) putUpload(u Upload) { s.mu.Lock(); defer s.mu.Unlock(); s.uploads[u.ID] = u }
func (s *store) upload(id string) (Upload, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
u, ok := s.uploads[id]
return u, ok
}
func (s *store) putGeneration(g Generation) {
s.mu.Lock()
defer s.mu.Unlock()
s.generations[g.ID] = g
}
func (s *store) generation(id string) (Generation, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
g, ok := s.generations[id]
return g, ok
}
func (s *store) update(id string, fn func(*Generation)) {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.generations[id]
if !ok {
return
}
fn(&g)
g.UpdatedAt = time.Now().UTC()
s.generations[id] = g
}
func (s *store) list(session string) []Generation {
s.mu.RLock()
defer s.mu.RUnlock()
out := []Generation{}
for _, g := range s.generations {
if session == "" || g.SessionID == session {
out = append(out, g)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out
}
var errForbidden = errors.New("access denied")
@@ -0,0 +1,152 @@
package openrouter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
type Client struct {
BaseURL, APIKey, SiteURL, AppName string
HTTP *http.Client
}
func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.BaseURL, "/")+path, reader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.SiteURL != "" {
req.Header.Set("HTTP-Referer", c.SiteURL)
}
if c.AppName != "" {
req.Header.Set("X-Title", c.AppName)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("OpenRouter status %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
return resp, nil
}
type imageURL struct {
URL string `json:"url"`
}
type contentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *imageURL `json:"image_url,omitempty"`
}
type message struct {
Role string `json:"role"`
Content any `json:"content"`
}
func (c *Client) Prompt(ctx context.Context, model, description, dataURL, system string) (string, error) {
body := map[string]any{
"model": model,
"messages": []message{
{Role: "system", Content: system},
{Role: "user", Content: []contentPart{
{Type: "text", Text: description},
{Type: "image_url", ImageURL: &imageURL{URL: dataURL}},
}},
},
"response_format": map[string]any{"type": "json_schema", "json_schema": map[string]any{"name": "video_prompt", "strict": true, "schema": map[string]any{"type": "object", "properties": map[string]any{"video_prompt": map[string]string{"type": "string"}}, "required": []string{"video_prompt"}, "additionalProperties": false}}},
}
resp, err := c.do(ctx, http.MethodPost, "/chat/completions", body)
if err != nil {
return "", err
}
defer resp.Body.Close()
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err = json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&out); err != nil {
return "", err
}
if len(out.Choices) == 0 {
return "", fmt.Errorf("empty model response")
}
var result struct {
VideoPrompt string `json:"video_prompt"`
}
if err = json.Unmarshal([]byte(out.Choices[0].Message.Content), &result); err != nil {
return "", err
}
if strings.TrimSpace(result.VideoPrompt) == "" {
return "", fmt.Errorf("empty video prompt")
}
return result.VideoPrompt, nil
}
type VideoRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Duration int `json:"duration"`
Resolution string `json:"resolution"`
AspectRatio string `json:"aspect_ratio"`
GenerateAudio bool `json:"generate_audio"`
FrameImages []FrameImage `json:"frame_images"`
}
type FrameImage struct {
Type string `json:"type"`
ImageURL imageURL `json:"image_url"`
FrameType string `json:"frame_type"`
}
type VideoJob struct {
ID string `json:"id"`
Status string `json:"status"`
Error string `json:"error"`
UnsignedURLs []string `json:"unsigned_urls"`
}
func NewImageURL(raw string) imageURL { return imageURL{URL: raw} }
func (c *Client) Submit(ctx context.Context, v VideoRequest) (VideoJob, error) {
resp, err := c.do(ctx, http.MethodPost, "/videos", v)
if err != nil {
return VideoJob{}, err
}
defer resp.Body.Close()
var job VideoJob
err = json.NewDecoder(resp.Body).Decode(&job)
return job, err
}
func (c *Client) Poll(ctx context.Context, id string) (VideoJob, error) {
resp, err := c.do(ctx, http.MethodGet, "/videos/"+id, nil)
if err != nil {
return VideoJob{}, err
}
defer resp.Body.Close()
var job VideoJob
err = json.NewDecoder(resp.Body).Decode(&job)
return job, err
}
func (c *Client) Content(ctx context.Context, id string) (*http.Response, error) {
return c.do(ctx, http.MethodGet, "/videos/"+id+"/content?index=0", nil)
}
@@ -0,0 +1,73 @@
package openrouter
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func response(status int, contentType, body string) *http.Response {
return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(body))}
}
func TestPromptMultimodalRequest(t *testing.T) {
var body map[string]any
httpClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/chat/completions" {
t.Errorf("path %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer secret" {
t.Error("missing auth")
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
return response(200, "application/json", `{"choices":[{"message":{"content":"{\"video_prompt\":\"gentle motion\"}"}}]}`), nil
})}
c := Client{BaseURL: "https://openrouter.test", APIKey: "secret", HTTP: httpClient}
got, err := c.Prompt(context.Background(), "model", "student text", "data:image/png;base64,abc", "system")
if err != nil || got != "gentle motion" {
t.Fatalf("got %q %v", got, err)
}
encoded, _ := json.Marshal(body)
for _, want := range []string{"student text", "data:image/png;base64,abc", "json_schema"} {
if !strings.Contains(string(encoded), want) {
t.Errorf("request missing %q", want)
}
}
}
func TestVideoFlow(t *testing.T) {
httpClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/videos":
return response(202, "application/json", `{"id":"job1","status":"pending"}`), nil
case r.URL.Path == "/videos/job1":
return response(200, "application/json", `{"id":"job1","status":"completed"}`), nil
case r.URL.Path == "/videos/job1/content":
return response(200, "video/mp4", "video"), nil
default:
return response(404, "text/plain", "missing"), nil
}
})}
c := Client{BaseURL: "https://openrouter.test", APIKey: "x", HTTP: httpClient}
job, err := c.Submit(context.Background(), VideoRequest{Model: "m"})
if err != nil || job.ID != "job1" {
t.Fatal(job, err)
}
job, err = c.Poll(context.Background(), job.ID)
if err != nil || job.Status != "completed" {
t.Fatal(job, err)
}
resp, err := c.Content(context.Background(), job.ID)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
+311
View File
@@ -0,0 +1,311 @@
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, `<div class="preview"><img src="uploads/%s/preview" alt="Uploaded comic preview"><input type="hidden" name="upload_id" value="%s"><p>%s</p></div>`, 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, `<label for="reviewed_prompt">Editable video prompt</label><textarea id="reviewed_prompt" name="reviewed_prompt" rows="10" maxlength="12000" required>%s</textarea><small>%d characters</small>`, 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)
}
+62
View File
@@ -0,0 +1,62 @@
package comicanimator
import (
"bytes"
"html/template"
"net/url"
"os"
"path/filepath"
"testing"
"time"
)
func TestSignedURLTampering(t *testing.T) {
dir := t.TempDir()
tool := &Tool{cfg: Config{PublicBaseURL: "https://example.test", SigningSecret: string(make([]byte, 32)), SignedURLTTL: time.Minute}, store: newStore()}
u := Upload{ID: "upl_test", Path: filepath.Join(dir, "x.png")}
os.WriteFile(u.Path, []byte("x"), 0600)
tool.store.putUpload(u)
raw := tool.signedURL(u.ID)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
if parsed.Query().Get("signature") == "" || parsed.Query().Get("expires") == "" {
t.Fatal("missing signature fields")
}
if !stringsContains(parsed.Path, u.ID) {
t.Fatal("missing upload id")
}
}
func stringsContains(s, part string) bool { return len(s) >= len(part) && s[len(s)-len(part):] == part }
func TestValidateWebP(t *testing.T) {
good := append([]byte("RIFF\x10\x00\x00\x00WEBPVP8X"), make([]byte, 4)...)
if err := validateWebP(good); err != nil {
t.Fatal(err)
}
if err := validateWebP([]byte("not webp")); err == nil {
t.Fatal("corrupt webp accepted")
}
}
func TestTemplatesExecute(t *testing.T) {
if err := template.Must(template.New("tool").Parse(toolPage)).Execute(&bytes.Buffer{}, map[string]any{"CSRF": "x", "Duration": 6, "AspectRatio": "16:9"}); err != nil {
t.Fatal(err)
}
if err := template.Must(template.New("instructor").Parse(instructorPage)).Execute(&bytes.Buffer{}, map[string]any{"Outputs": []outputFile{{Name: "safe.mp4", Size: 10, Modified: time.Now()}}}); err != nil {
t.Fatal(err)
}
}
func TestVideoSignatures(t *testing.T) {
mp4 := append([]byte{0, 0, 0, 16}, []byte("ftypisom")...)
if !validVideoHeader("video/mp4", mp4) {
t.Fatal("valid MP4 rejected")
}
if validVideoHeader("video/mp4", []byte("not a video")) {
t.Fatal("invalid MP4 accepted")
}
if !validVideoHeader("video/webm", []byte{0x1a, 0x45, 0xdf, 0xa3}) {
t.Fatal("valid WebM rejected")
}
}
+108
View File
@@ -0,0 +1,108 @@
package comicanimator
import (
"fmt"
"html/template"
"mime"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
func render(w http.ResponseWriter, src string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := template.Must(template.New("page").Parse(src)).Execute(w, data); err != nil {
http.Error(w, "render error", 500)
}
}
func fragmentError(w http.ResponseWriter, status int, message string) {
w.WriteHeader(status)
fmt.Fprintf(w, `<p class="error" role="alert">%s</p>`, template.HTMLEscapeString(message))
}
func (t *Tool) renderCard(w http.ResponseWriter, g Generation, instructor bool) {
poll := ""
if g.Status != Completed && g.Status != Failed && !instructor {
poll = ` hx-get="generations/` + template.HTMLEscapeString(g.ID) + `/status" hx-trigger="every 5s" hx-swap="outerHTML"`
}
fmt.Fprintf(w, `<article class="generation"%s><div><strong>%s</strong><small>%s</small></div>`, poll, template.HTMLEscapeString(strings.ReplaceAll(string(g.Status), "_", " ")), g.CreatedAt.Format(time.RFC3339))
if instructor {
fmt.Fprintf(w, `<details><summary>Reviewed prompt</summary><p>%s</p></details>`, template.HTMLEscapeString(g.ReviewedPrompt))
if g.ProviderJobID != "" {
fmt.Fprintf(w, `<small>Provider job: %s</small>`, template.HTMLEscapeString(g.ProviderJobID))
}
}
if g.Status == Completed {
prefix := ""
if instructor {
prefix = "generations/"
}
fmt.Fprintf(w, `<video controls preload="metadata" src="%s%s/video"></video><a class="btn" href="%s%s/download">Download</a>`, prefix, g.ID, prefix, g.ID)
} else if g.Status == Failed {
fmt.Fprintf(w, `<p class="error">%s</p>`, template.HTMLEscapeString(g.ErrorMessage))
} else {
fmt.Fprint(w, `<p class="muted">Working…</p>`)
}
fmt.Fprint(w, "</article>")
}
func (t *Tool) instructorPage(w http.ResponseWriter, r *http.Request) {
render(w, instructorPage, map[string]any{"Outputs": t.outputs()})
}
func (t *Tool) instructorGenerations(w http.ResponseWriter, r *http.Request) {
for _, g := range t.store.list("") {
t.renderCard(w, g, true)
}
}
func (t *Tool) instructorVideo(w http.ResponseWriter, r *http.Request) {
t.generationFile(w, r, true, false)
}
func (t *Tool) instructorDownload(w http.ResponseWriter, r *http.Request) {
t.generationFile(w, r, true, true)
}
type outputFile struct {
Name string
Size int64
Modified time.Time
}
func (t *Tool) outputs() []outputFile {
entries, err := os.ReadDir(t.cfg.OutputDirectory)
if err != nil {
return nil
}
out := []outputFile{}
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".mp4" && ext != ".webm" && ext != ".mov" {
continue
}
info, err := e.Info()
if err == nil {
out = append(out, outputFile{Name: e.Name(), Size: info.Size(), Modified: info.ModTime()})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Modified.After(out[j].Modified) })
return out
}
func (t *Tool) outputDownload(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("filename")
if name != filepath.Base(name) {
http.NotFound(w, r)
return
}
path := filepath.Join(t.cfg.OutputDirectory, name)
if _, err := os.Stat(path); err != nil {
http.NotFound(w, r)
return
}
serveFile(w, r, path, mime.TypeByExtension(filepath.Ext(name)), r.URL.Query().Get("preview") != "1")
}
const toolPage = `<section class="tool-grid"><article class="card"><h2>1. Source image</h2><form id="upload-form" hx-post="uploads" hx-target="#upload-preview" hx-encoding="multipart/form-data"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="file" name="image" accept="image/png,image/jpeg,image/webp" required><button class="btn" type="submit">Upload image</button></form><div id="upload-preview"></div></article><article class="card"><h2>2. Describe motion</h2><form id="comic-form"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><label for="description">Describe the story and movement</label><textarea id="description" name="description" rows="12" required placeholder="Explain the panels in order, character actions, expressions, environmental movement, timing, and anything that must stay unchanged."></textarea><button class="btn" type="button" hx-post="prompt" hx-include="#comic-form,#upload-preview" hx-target="#prompt-result" hx-indicator="#prompt-spinner">Generate Video Prompt</button><span id="prompt-spinner" class="htmx-indicator">Working…</span><div id="prompt-result"></div></form></article><article class="card"><h2>4. Generate</h2><label>Duration</label><input form="comic-form" name="duration" value="{{.Duration}}" readonly><label>Aspect ratio</label><select form="comic-form" name="aspect_ratio"><option>{{.AspectRatio}}</option></select><button class="btn primary" type="button" data-open-approval>Generate Video</button></article></section><section class="card recent"><h2>Recent animations</h2><div id="generation-list" hx-get="generations" hx-trigger="load"></div></section><dialog id="approval-dialog" class="dialog"><form method="dialog"><button aria-label="Close">×</button></form><h2>Instructor approval</h2><p>An instructor PIN is required for this paid generation.</p><form id="generation-form" hx-post="generations" hx-include="#comic-form,#upload-preview" hx-target="#generation-list" hx-swap="afterbegin"><input type="password" name="instructor_pin" autocomplete="off" required><div id="approval-error"></div><button type="button" data-close-approval>Cancel</button><button class="btn primary" type="submit">Approve and Generate</button></form></dialog>`
const instructorPage = `<section class="card"><h2>Current process generations</h2><div hx-get="generations" hx-trigger="load, every 10s"></div></section><section class="card recent"><h2>Downloaded output files</h2><p class="muted">Files survive restarts; this is not a complete audit log.</p>{{range .Outputs}}<article class="generation"><strong>{{.Name}}</strong><small>{{.Modified.UTC.Format "2006-01-02 15:04:05Z"}} · {{.Size}} bytes</small><video controls preload="metadata" src="outputs/{{.Name}}/download?preview=1"></video><a class="btn" href="outputs/{{.Name}}/download">Download</a></article>{{else}}<p class="muted">No downloaded videos yet.</p>{{end}}</section>`
+166
View File
@@ -0,0 +1,166 @@
package comicanimator
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.michelsen.id/phill/preface-tools/internal/tools/comicanimator/openrouter"
)
func (t *Tool) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return nil
case gid := <-t.queue:
func() {
defer func() {
if v := recover(); v != nil {
t.fail(gid, "internal_error", "Generation failed unexpectedly.", fmt.Errorf("panic: %v", v))
}
}()
t.work(ctx, gid)
}()
}
}
}
func (t *Tool) work(appctx context.Context, id string) {
g, ok := t.store.generation(id)
if !ok {
return
}
t.store.update(id, func(x *Generation) { x.Status = Submitting })
ctx, cancel := context.WithTimeout(appctx, t.cfg.JobTimeout)
defer cancel()
job, err := t.client.Submit(ctx, openrouter.VideoRequest{Model: t.cfg.VideoModel, Prompt: g.ReviewedPrompt, Duration: g.Duration, Resolution: g.Resolution, AspectRatio: g.AspectRatio, GenerateAudio: t.cfg.GenerateAudio, FrameImages: []openrouter.FrameImage{{Type: "image_url", ImageURL: openrouter.NewImageURL(t.signedURL(g.UploadID)), FrameType: "first_frame"}}})
if err != nil {
t.fail(id, "provider_submission_failed", "The video provider could not start this generation.", err)
return
}
if job.ID == "" {
t.fail(id, "provider_submission_failed", "The video provider returned an invalid response.", fmt.Errorf("missing job id"))
return
}
t.store.update(id, func(x *Generation) { x.ProviderJobID = job.ID; x.Status = Pending })
for job.Status != "completed" {
if job.Status == "failed" || job.Status == "cancelled" || job.Status == "expired" {
t.fail(id, "provider_generation_failed", "The video provider could not complete this generation.", fmt.Errorf("provider status %s", job.Status))
return
}
timer := time.NewTimer(t.cfg.PollInterval)
select {
case <-ctx.Done():
timer.Stop()
code := "provider_timeout"
msg := "The video generation timed out."
if appctx.Err() != nil {
code = "worker_shutdown"
msg = "Generation stopped because the application shut down."
}
t.fail(id, code, msg, ctx.Err())
return
case <-timer.C:
}
job, err = t.client.Poll(ctx, job.ID)
if err != nil {
t.fail(id, "provider_poll_failed", "Could not check the video status.", err)
return
}
t.store.update(id, func(x *Generation) {
if job.Status == "in_progress" {
x.Status = InProgress
} else {
x.Status = Pending
}
})
}
t.store.update(id, func(x *Generation) { x.Status = Downloading })
resp, err := t.client.Content(ctx, job.ID)
if err != nil {
t.fail(id, "provider_download_failed", "Could not download the completed video.", err)
return
}
defer resp.Body.Close()
mt := strings.Split(resp.Header.Get("Content-Type"), ";")[0]
if mt != "video/mp4" && mt != "video/webm" && mt != "video/quicktime" {
t.fail(id, "generated_video_invalid", "The provider returned an invalid video.", fmt.Errorf("content type %q", mt))
return
}
ext := map[string]string{"video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov"}[mt]
header := make([]byte, 16)
hn, readErr := io.ReadFull(resp.Body, header)
if readErr != nil && readErr != io.ErrUnexpectedEOF {
t.fail(id, "provider_download_failed", "Could not download the completed video.", readErr)
return
}
if !validVideoHeader(mt, header[:hn]) {
t.fail(id, "generated_video_invalid", "The provider returned an invalid video.", fmt.Errorf("invalid %s signature", mt))
return
}
tmp, err := os.CreateTemp(t.cfg.OutputDirectory, ".video-*")
if err != nil {
t.fail(id, "output_write_failed", "Could not save the video.", err)
return
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
n, copyErr := io.Copy(tmp, io.LimitReader(io.MultiReader(bytes.NewReader(header[:hn]), resp.Body), t.cfg.MaxGeneratedVideoBytes+1))
syncErr := tmp.Sync()
closeErr := tmp.Close()
if copyErr != nil || syncErr != nil || closeErr != nil {
t.fail(id, "output_write_failed", "Could not save the video.", errorsJoin(copyErr, syncErr, closeErr))
return
}
if n > t.cfg.MaxGeneratedVideoBytes {
t.fail(id, "generated_video_too_large", "The generated video was too large.", fmt.Errorf("%d bytes", n))
return
}
now := time.Now().UTC()
name := now.Format("20060102T150405Z") + "_" + id + ext
dest := filepath.Join(t.cfg.OutputDirectory, name)
if err = os.Rename(tmpName, dest); err != nil {
t.fail(id, "output_write_failed", "Could not finalize the video.", err)
return
}
t.store.update(id, func(x *Generation) {
x.Status = Completed
x.OutputPath = dest
x.OutputMIMEType = mt
x.OutputSize = n
x.CompletedAt = &now
})
t.log.Info("video generation completed", "tool", "comic-animator", "generation_id", id, "provider_job_id", job.ID, "file_size", n)
}
func validVideoHeader(mt string, head []byte) bool {
switch mt {
case "video/mp4", "video/quicktime":
return len(head) >= 12 && string(head[4:8]) == "ftyp"
case "video/webm":
return len(head) >= 4 && head[0] == 0x1a && head[1] == 0x45 && head[2] == 0xdf && head[3] == 0xa3
default:
return false
}
}
func errorsJoin(errs ...error) error {
parts := []string{}
for _, e := range errs {
if e != nil {
parts = append(parts, e.Error())
}
}
if len(parts) == 0 {
return nil
}
return fmt.Errorf("%s", strings.Join(parts, "; "))
}
func (t *Tool) fail(id, code, message string, err error) {
t.store.update(id, func(x *Generation) { x.Status = Failed; x.ErrorCode = code; x.ErrorMessage = message })
t.log.Error("video generation failed", "tool", "comic-animator", "generation_id", id, "error_code", code, "error", err)
}