Codex first iteration
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
APP_ENV=development
|
||||||
|
HTTP_ADDRESS=:8080
|
||||||
|
PUBLIC_BASE_URL=http://localhost:8080
|
||||||
|
STUDENT_PIN=change-me
|
||||||
|
INSTRUCTOR_PIN=change-me-too
|
||||||
|
SESSION_SIGNING_SECRET=replace-with-at-least-32-random-characters
|
||||||
|
SESSION_DURATION=4h
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FORMAT=text
|
||||||
|
|
||||||
|
COMIC_ANIMATOR_OPENROUTER_API_KEY=
|
||||||
|
COMIC_ANIMATOR_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
COMIC_ANIMATOR_OPENROUTER_SITE_URL=
|
||||||
|
COMIC_ANIMATOR_OPENROUTER_APP_NAME=Preface Tools - Comic Animator
|
||||||
|
COMIC_ANIMATOR_PROMPT_MODEL=
|
||||||
|
COMIC_ANIMATOR_VIDEO_MODEL=
|
||||||
|
COMIC_ANIMATOR_VIDEO_DURATION=6
|
||||||
|
COMIC_ANIMATOR_VIDEO_RESOLUTION=720p
|
||||||
|
COMIC_ANIMATOR_VIDEO_ASPECT_RATIO=16:9
|
||||||
|
COMIC_ANIMATOR_GENERATE_AUDIO=false
|
||||||
|
COMIC_ANIMATOR_HTTP_TIMEOUT=60s
|
||||||
|
COMIC_ANIMATOR_POLL_INTERVAL=30s
|
||||||
|
COMIC_ANIMATOR_JOB_TIMEOUT=15m
|
||||||
|
COMIC_ANIMATOR_SIGNING_SECRET=replace-with-another-32-character-secret
|
||||||
|
COMIC_ANIMATOR_SIGNED_URL_TTL=30m
|
||||||
|
COMIC_ANIMATOR_UPLOAD_DIR=data/comic-animator/uploads
|
||||||
|
COMIC_ANIMATOR_OUTPUT_DIR=data/comic-animator/outputs
|
||||||
|
COMIC_ANIMATOR_MAX_UPLOAD_BYTES=20971520
|
||||||
|
COMIC_ANIMATOR_MAX_VIDEO_BYTES=536870912
|
||||||
|
COMIC_ANIMATOR_QUEUE_CAPACITY=100
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
data/comic-animator/uploads/*
|
||||||
|
data/comic-animator/outputs/*
|
||||||
|
preface-tools
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
FROM golang:1.26 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum* ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /preface-tools ./cmd/preface-tools
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /preface-tools /usr/local/bin/preface-tools
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/usr/local/bin/preface-tools"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Preface Tools
|
||||||
|
|
||||||
|
Preface Tools is a database-free classroom utility server. Its first tool,
|
||||||
|
Comic Animator, lets a student upload a comic page, create and edit a
|
||||||
|
multimodal OpenRouter prompt, obtain instructor approval, and generate a video.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
Requires Go 1.26 or later. Copy `.env.example` to `.env`, replace every secret
|
||||||
|
and model placeholder, export the variables, then run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/preface-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
The process fails at startup when required configuration is missing. In
|
||||||
|
production, use HTTPS and set `APP_ENV=production` so the session cookie is
|
||||||
|
marked Secure. `PUBLIC_BASE_URL` must be an HTTPS address reachable by
|
||||||
|
OpenRouter because it fetches a short-lived, signed source-image URL.
|
||||||
|
|
||||||
|
## Storage and restart behavior
|
||||||
|
|
||||||
|
Uploads and runtime generation records are held in process-specific registries.
|
||||||
|
Completed videos are streamed immediately and atomically into
|
||||||
|
`data/comic-animator/outputs` (or the configured output directory). The
|
||||||
|
instructor page scans that directory, so downloaded videos remain recoverable
|
||||||
|
after restart.
|
||||||
|
|
||||||
|
OpenRouter does not document an endpoint for listing all historical video jobs.
|
||||||
|
Consequently, queued and in-progress jobs and rich metadata cannot be recovered
|
||||||
|
after a restart. The downloaded-file view is a recovery aid, not an audit log.
|
||||||
|
Uploads and outputs are not automatically removed; operators must monitor disk
|
||||||
|
usage and introduce a retention policy appropriate to their deployment.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gofmt -w .
|
||||||
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
No database, Node.js, npm, frontend build, or live OpenRouter call is required
|
||||||
|
by the automated test suite.
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"git.michelsen.id/phill/preface-tools/internal/auth"
|
||||||
|
"git.michelsen.id/phill/preface-tools/internal/httpserver"
|
||||||
|
"git.michelsen.id/phill/preface-tools/internal/tools"
|
||||||
|
"git.michelsen.id/phill/preface-tools/internal/tools/comicanimator"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run() error {
|
||||||
|
cfg, err := LoadConfigFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
comicCfg, err := comicanimator.LoadConfigFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var handler slog.Handler = slog.NewTextHandler(os.Stdout, nil)
|
||||||
|
if cfg.LogFormat == "json" {
|
||||||
|
handler = slog.NewJSONHandler(os.Stdout, nil)
|
||||||
|
}
|
||||||
|
log := slog.New(handler)
|
||||||
|
a := auth.New(cfg.StudentPIN, cfg.InstructorPIN, cfg.SessionSecret, cfg.SessionDuration, cfg.Environment == "production")
|
||||||
|
comic, err := comicanimator.New(comicCfg, log, a)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
if err = registry.Register(comic); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
srv := &http.Server{Addr: cfg.Address, Handler: httpserver.New(a, registry, log).Handler(), ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout}
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
workerDone := make(chan error, 1)
|
||||||
|
go func() { workerDone <- comic.Run(ctx) }()
|
||||||
|
serverDone := make(chan error, 1)
|
||||||
|
go func() { log.Info("server starting", "address", cfg.Address); serverDone <- srv.ListenAndServe() }()
|
||||||
|
select {
|
||||||
|
case err = <-serverDone:
|
||||||
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
stop()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stop()
|
||||||
|
return <-workerDone
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Environment, Address, PublicBaseURL, StudentPIN, InstructorPIN, SessionSecret, LogLevel, LogFormat string
|
||||||
|
SessionDuration, ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout, ShutdownTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfigFromEnv() (Config, error) {
|
||||||
|
c := Config{Environment: get("APP_ENV", "development"), Address: get("HTTP_ADDRESS", ":8080"), PublicBaseURL: os.Getenv("PUBLIC_BASE_URL"), StudentPIN: os.Getenv("STUDENT_PIN"), InstructorPIN: os.Getenv("INSTRUCTOR_PIN"), SessionSecret: os.Getenv("SESSION_SIGNING_SECRET"), LogLevel: get("LOG_LEVEL", "info"), LogFormat: get("LOG_FORMAT", "text")}
|
||||||
|
var err error
|
||||||
|
c.SessionDuration, err = duration("SESSION_DURATION", "4h")
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
values := []struct {
|
||||||
|
target *time.Duration
|
||||||
|
key, fallback string
|
||||||
|
}{{&c.ReadHeaderTimeout, "HTTP_READ_HEADER_TIMEOUT", "5s"}, {&c.ReadTimeout, "HTTP_READ_TIMEOUT", "30s"}, {&c.WriteTimeout, "HTTP_WRITE_TIMEOUT", "30s"}, {&c.IdleTimeout, "HTTP_IDLE_TIMEOUT", "60s"}, {&c.ShutdownTimeout, "HTTP_SHUTDOWN_TIMEOUT", "10s"}}
|
||||||
|
for _, value := range values {
|
||||||
|
*value.target, err = duration(value.key, value.fallback)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.StudentPIN == "" || c.InstructorPIN == "" || len(c.SessionSecret) < 32 || c.PublicBaseURL == "" {
|
||||||
|
return c, errors.New("STUDENT_PIN, INSTRUCTOR_PIN, PUBLIC_BASE_URL, and a 32+ character SESSION_SIGNING_SECRET are required")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
func get(k, d string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
func duration(k, d string) (time.Duration, error) { return time.ParseDuration(get(k, d)) }
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Student Role = "student"
|
||||||
|
Instructor Role = "instructor"
|
||||||
|
CookieName = "preface_session"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
Role Role `json:"role"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
IssuedAt int64 `json:"issued_at"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
Version string `json:"credential_version"`
|
||||||
|
CSRFToken string `json:"csrf_token"`
|
||||||
|
}
|
||||||
|
type Service struct {
|
||||||
|
studentPIN, instructorPIN string
|
||||||
|
secret []byte
|
||||||
|
duration time.Duration
|
||||||
|
secure bool
|
||||||
|
limiter *Limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(studentPIN, instructorPIN, secret string, duration time.Duration, secure bool) *Service {
|
||||||
|
return &Service{studentPIN: studentPIN, instructorPIN: instructorPIN, secret: []byte(secret), duration: duration, secure: secure, limiter: NewLimiter(5, 5*time.Minute)}
|
||||||
|
}
|
||||||
|
func constantEqual(a, b string) bool { return hmac.Equal([]byte(a), []byte(b)) }
|
||||||
|
func (s *Service) VerifyInstructorPIN(pin string) bool { return constantEqual(pin, s.instructorPIN) }
|
||||||
|
func randomToken() string {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
func (s *Service) version(role Role) string {
|
||||||
|
pin := s.studentPIN
|
||||||
|
if role == Instructor {
|
||||||
|
pin = s.instructorPIN
|
||||||
|
}
|
||||||
|
m := hmac.New(sha256.New, s.secret)
|
||||||
|
m.Write([]byte(string(role) + pin))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(m.Sum(nil)[:16])
|
||||||
|
}
|
||||||
|
func (s *Service) Authenticate(role Role, pin, key string) (Claims, error) {
|
||||||
|
if !s.limiter.Allow(key) {
|
||||||
|
return Claims{}, errors.New("too many attempts")
|
||||||
|
}
|
||||||
|
expected := s.studentPIN
|
||||||
|
if role == Instructor {
|
||||||
|
expected = s.instructorPIN
|
||||||
|
}
|
||||||
|
if !constantEqual(pin, expected) {
|
||||||
|
s.limiter.Fail(key)
|
||||||
|
return Claims{}, errors.New("invalid PIN")
|
||||||
|
}
|
||||||
|
s.limiter.Success(key)
|
||||||
|
now := time.Now()
|
||||||
|
c := Claims{Role: role, IssuedAt: now.Unix(), ExpiresAt: now.Add(s.duration).Unix(), Version: s.version(role), CSRFToken: randomToken()}
|
||||||
|
if role == Student {
|
||||||
|
c.SessionID = randomToken()
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
func (s *Service) Sign(c Claims) string {
|
||||||
|
b, _ := json.Marshal(c)
|
||||||
|
p := base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
m := hmac.New(sha256.New, s.secret)
|
||||||
|
m.Write([]byte(p))
|
||||||
|
return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
||||||
|
}
|
||||||
|
func (s *Service) Parse(raw string) (Claims, error) {
|
||||||
|
var c Claims
|
||||||
|
parts := strings.Split(raw, ".")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return c, errors.New("bad session")
|
||||||
|
}
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
m := hmac.New(sha256.New, s.secret)
|
||||||
|
m.Write([]byte(parts[0]))
|
||||||
|
if !hmac.Equal(sig, m.Sum(nil)) {
|
||||||
|
return c, errors.New("bad signature")
|
||||||
|
}
|
||||||
|
b, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(b, &c); err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
if time.Now().Unix() >= c.ExpiresAt || c.Version != s.version(c.Role) {
|
||||||
|
return c, errors.New("expired session")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
func (s *Service) SetCookie(w http.ResponseWriter, c Claims) {
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: s.Sign(c), Path: "/", HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode, MaxAge: int(s.duration.Seconds())})
|
||||||
|
}
|
||||||
|
func (s *Service) ClearCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: CookieName, Path: "/", HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode, MaxAge: -1})
|
||||||
|
}
|
||||||
|
func (s *Service) FromRequest(r *http.Request) (Claims, error) {
|
||||||
|
c, err := r.Cookie(CookieName)
|
||||||
|
if err != nil {
|
||||||
|
return Claims{}, err
|
||||||
|
}
|
||||||
|
return s.Parse(c.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextKey struct{}
|
||||||
|
|
||||||
|
func WithClaims(r *http.Request, c Claims) *http.Request {
|
||||||
|
return r.WithContext(context.WithValue(r.Context(), contextKey{}, c))
|
||||||
|
}
|
||||||
|
func ClaimsFrom(r *http.Request) (Claims, bool) {
|
||||||
|
c, ok := r.Context().Value(contextKey{}).(Claims)
|
||||||
|
return c, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
type attempt struct {
|
||||||
|
failures int
|
||||||
|
reset time.Time
|
||||||
|
}
|
||||||
|
type Limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
max int
|
||||||
|
window time.Duration
|
||||||
|
entries map[string]attempt
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLimiter(max int, window time.Duration) *Limiter {
|
||||||
|
return &Limiter{max: max, window: window, entries: map[string]attempt{}}
|
||||||
|
}
|
||||||
|
func (l *Limiter) Allow(k string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
a := l.entries[k]
|
||||||
|
if time.Now().After(a.reset) {
|
||||||
|
delete(l.entries, k)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return a.failures < l.max
|
||||||
|
}
|
||||||
|
func (l *Limiter) Fail(k string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
a := l.entries[k]
|
||||||
|
if time.Now().After(a.reset) {
|
||||||
|
a = attempt{reset: time.Now().Add(l.window)}
|
||||||
|
}
|
||||||
|
a.failures++
|
||||||
|
l.entries[k] = a
|
||||||
|
}
|
||||||
|
func (l *Limiter) Success(k string) { l.mu.Lock(); defer l.mu.Unlock(); delete(l.entries, k) }
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionsAndRotation(t *testing.T) {
|
||||||
|
s := New("1234", "9876", string(make([]byte, 32)), time.Hour, false)
|
||||||
|
c, err := s.Authenticate(Student, "1234", "ip")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.SessionID == "" || c.CSRFToken == "" {
|
||||||
|
t.Fatal("missing random claims")
|
||||||
|
}
|
||||||
|
raw := s.Sign(c)
|
||||||
|
got, err := s.Parse(raw)
|
||||||
|
if err != nil || got.Role != Student {
|
||||||
|
t.Fatalf("parse: %#v %v", got, err)
|
||||||
|
}
|
||||||
|
rotated := New("4321", "9876", string(make([]byte, 32)), time.Hour, false)
|
||||||
|
if _, err := rotated.Parse(raw); err == nil {
|
||||||
|
t.Fatal("PIN rotation did not invalidate session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestWrongPINAndExpiry(t *testing.T) {
|
||||||
|
s := New("1234", "9876", string(make([]byte, 32)), time.Hour, false)
|
||||||
|
if _, err := s.Authenticate(Instructor, "bad", "ip"); err == nil {
|
||||||
|
t.Fatal("wrong PIN accepted")
|
||||||
|
}
|
||||||
|
c, _ := s.Authenticate(Student, "1234", "another")
|
||||||
|
c.ExpiresAt = time.Now().Add(-time.Second).Unix()
|
||||||
|
if _, err := s.Parse(s.Sign(c)); err == nil {
|
||||||
|
t.Fatal("expired session accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.michelsen.id/phill/preface-tools/internal/auth"
|
||||||
|
"git.michelsen.id/phill/preface-tools/internal/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
auth *auth.Service
|
||||||
|
registry *tools.Registry
|
||||||
|
log *slog.Logger
|
||||||
|
handler http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(a *auth.Service, r *tools.Registry, log *slog.Logger) *Server {
|
||||||
|
s := &Server{auth: a, registry: r, log: log}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) })
|
||||||
|
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ready\n")) })
|
||||||
|
mux.HandleFunc("GET /static/app.css", css)
|
||||||
|
mux.HandleFunc("GET /static/app.js", js)
|
||||||
|
mux.HandleFunc("GET /login", s.loginPage)
|
||||||
|
mux.HandleFunc("POST /login/{role}", s.login)
|
||||||
|
mux.Handle("POST /logout", s.withAuth("", s.csrf(http.HandlerFunc(s.logout))))
|
||||||
|
mux.HandleFunc("GET /", s.root)
|
||||||
|
for _, tool := range r.List() {
|
||||||
|
key := tool.Definition().Key
|
||||||
|
studentPrefix := "/tools/" + key
|
||||||
|
suffix := http.StripPrefix(studentPrefix, tool.StudentHandler())
|
||||||
|
mux.Handle(studentPrefix+"/provider-media/", suffix)
|
||||||
|
mux.Handle(studentPrefix+"/", s.withAuth(auth.Student, s.csrf(s.shell(tool, suffix, false))))
|
||||||
|
if it, ok := tool.(tools.InstructorTool); ok {
|
||||||
|
prefix := "/instructor/tools/" + key
|
||||||
|
mux.Handle(prefix+"/", s.withAuth(auth.Instructor, s.csrf(s.shell(tool, http.StripPrefix(prefix, it.InstructorHandler()), true))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mux.Handle("GET /instructor", s.withAuth(auth.Instructor, http.HandlerFunc(s.instructorRoot)))
|
||||||
|
s.handler = s.middleware(mux)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func (s *Server) Handler() http.Handler { return s.handler }
|
||||||
|
func (s *Server) root(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := s.auth.FromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Role == auth.Instructor {
|
||||||
|
http.Redirect(w, r, "/instructor", 303)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list := s.registry.List()
|
||||||
|
if len(list) == 0 {
|
||||||
|
http.Error(w, "no tools configured", 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/tools/"+list[0].Definition().Key+"/", 303)
|
||||||
|
}
|
||||||
|
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { render(w, loginTemplate, nil) }
|
||||||
|
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||||
|
role := auth.Role(r.PathValue("role"))
|
||||||
|
if role != auth.Student && role != auth.Instructor {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := s.auth.Authenticate(role, r.FormValue("pin"), clientIP(r)+":"+string(role))
|
||||||
|
if err != nil {
|
||||||
|
renderStatus(w, loginTemplate, map[string]any{"Error": "Login was not accepted."}, 401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.auth.SetCookie(w, c)
|
||||||
|
if role == auth.Instructor {
|
||||||
|
http.Redirect(w, r, "/instructor", 303)
|
||||||
|
} else {
|
||||||
|
http.Redirect(w, r, "/", 303)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.auth.ClearCookie(w)
|
||||||
|
http.Redirect(w, r, "/login", 303)
|
||||||
|
}
|
||||||
|
func (s *Server) instructorRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list := s.registry.List()
|
||||||
|
for _, t := range list {
|
||||||
|
if _, ok := t.(tools.InstructorTool); ok {
|
||||||
|
http.Redirect(w, r, "/instructor/tools/"+t.Definition().Key+"/", 303)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Error(w, "no instructor tools", 404)
|
||||||
|
}
|
||||||
|
func (s *Server) withAuth(role auth.Role, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := s.auth.FromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", 303)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role != "" && c.Role != role {
|
||||||
|
http.Error(w, "forbidden", 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, auth.WithClaims(r, c))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func (s *Server) csrf(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, _ := auth.ClaimsFrom(r)
|
||||||
|
token := r.Header.Get("X-CSRF-Token")
|
||||||
|
if token == "" {
|
||||||
|
token = r.FormValue("csrf_token")
|
||||||
|
}
|
||||||
|
if token == "" || token != c.CSRFToken {
|
||||||
|
http.Error(w, "invalid CSRF token", 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if origin := r.Header.Get("Origin"); origin != "" && !strings.HasPrefix(origin, "http://"+r.Host) && !strings.HasPrefix(origin, "https://"+r.Host) {
|
||||||
|
http.Error(w, "invalid origin", 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func (s *Server) shell(tool tools.Tool, next http.Handler, instructor bool) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rw := &capture{header: http.Header{}}
|
||||||
|
next.ServeHTTP(rw, r)
|
||||||
|
if rw.status >= 400 {
|
||||||
|
copyHeader(w.Header(), rw.header)
|
||||||
|
w.WriteHeader(rw.status)
|
||||||
|
w.Write(rw.body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, _ := auth.ClaimsFrom(r)
|
||||||
|
data := map[string]any{"Title": tool.Definition().Name, "Content": template.HTML(rw.body), "Tools": s.registry.List(), "Selected": tool.Definition().Key, "Instructor": instructor, "CSRF": c.CSRFToken}
|
||||||
|
render(w, shellTemplate, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type capture struct {
|
||||||
|
header http.Header
|
||||||
|
body []byte
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capture) Header() http.Header { return c.header }
|
||||||
|
func (c *capture) WriteHeader(n int) { c.status = n }
|
||||||
|
func (c *capture) Write(b []byte) (int, error) {
|
||||||
|
if c.status == 0 {
|
||||||
|
c.status = 200
|
||||||
|
}
|
||||||
|
c.body = append(c.body, b...)
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
func copyHeader(dst, src http.Header) {
|
||||||
|
for k, v := range src {
|
||||||
|
dst[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (s *Server) middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
b := make([]byte, 8)
|
||||||
|
rand.Read(b)
|
||||||
|
rid := hex.EncodeToString(b)
|
||||||
|
w.Header().Set("X-Request-ID", rid)
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Referrer-Policy", "same-origin")
|
||||||
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||||
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' https://cdn.jsdelivr.net; script-src 'self' https://cdn.jsdelivr.net; img-src 'self' data:; media-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
|
||||||
|
defer func() {
|
||||||
|
if v := recover(); v != nil {
|
||||||
|
s.log.Error("request panic", "request_id", rid, "error", v)
|
||||||
|
http.Error(w, "internal server error", 500)
|
||||||
|
}
|
||||||
|
s.log.Info("request", "request_id", rid, "method", r.Method, "path", r.URL.Path, "duration", time.Since(start))
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
func render(w http.ResponseWriter, src string, data any) { renderStatus(w, src, data, 200) }
|
||||||
|
func renderStatus(w http.ResponseWriter, src string, data any, status int) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
template.Must(template.New("page").Parse(src)).Execute(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const head = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="htmx-config" content='{"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"[45]..","swap":true,"error":true},{"code":"...","swap":false,"error":true}]}'><title>{{if .Title}}{{.Title}} · {{end}}Preface Tools</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/basecoat-css@1.0.2/dist/basecoat.cdn.min.css"><link rel="stylesheet" href="/static/app.css"><script src="https://cdn.jsdelivr.net/npm/basecoat-css@1.0.2/dist/js/all.min.js" defer></script><script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" defer></script><script src="/static/app.js" defer></script></head><body>`
|
||||||
|
const loginTemplate = head + `<main class="login"><h1>PREFACE TOOLS</h1>{{with .Error}}<p class="error">{{.}}</p>{{end}}<div class="login-grid"><form class="card" method="post" action="/login/student"><h2>Student Login</h2><p>Access classroom tools</p><input type="password" name="pin" required autocomplete="current-password" aria-label="Student PIN"><button class="btn primary">Log in</button></form><form class="card" method="post" action="/login/instructor"><h2>Instructor Login</h2><p>Review generated outputs</p><input type="password" name="pin" required autocomplete="current-password" aria-label="Instructor PIN"><button class="btn">Log in</button></form></div></main></body></html>`
|
||||||
|
const shellTemplate = head + `<header><a href="/">PREFACE TOOLS</a><nav>{{if .Instructor}}<span>Instructor</span>{{else}}<select id="tool-selector" aria-label="Choose tool">{{range .Tools}}<option value="/tools/{{.Definition.Key}}/" {{if eq .Definition.Key $.Selected}}selected{{end}}>{{.Definition.Name}}</option>{{end}}</select>{{end}}<form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button>Log out</button></form></nav></header><main>{{.Content}}</main></body></html>`
|
||||||
|
|
||||||
|
func css(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
fmt.Fprint(w, `body{margin:0;background:#f7f7fa;color:#172033;font-family:ui-sans-serif,system-ui}header{height:64px;background:#fff;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;padding:0 max(1rem,4vw)}header>a{font-weight:800;letter-spacing:.08em}nav{display:flex;gap:1rem;align-items:center}main{max-width:1400px;margin:0 auto;padding:2rem}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:1.25rem;box-shadow:0 1px 2px #1018280d}.login{max-width:760px;text-align:center;padding-top:12vh}.login-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem;text-align:left}.tool-grid{display:grid;grid-template-columns:1fr 1.3fr .8fr;gap:1rem}.recent{margin-top:1rem}.preview img,.generation video{width:100%;max-height:420px;object-fit:contain;border-radius:8px}.generation{border-top:1px solid #e5e7eb;padding:1rem 0}.generation>div{display:flex;justify-content:space-between}.muted,small{color:#667085}.error{color:#b42318}.htmx-indicator{display:none}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{display:inline}textarea,input,select{width:100%;margin:.35rem 0 1rem}.btn{display:inline-flex;margin-top:.5rem}.primary{background:#4f46e5;color:#fff}dialog{max-width:440px;width:calc(100% - 2rem)}@media(max-width:900px){.tool-grid{grid-template-columns:1fr 1fr}.tool-grid>*:last-child{grid-column:1/-1}}@media(max-width:640px){main{padding:1rem}.tool-grid,.login-grid{grid-template-columns:1fr}.tool-grid>*:last-child{grid-column:auto}}`)
|
||||||
|
}
|
||||||
|
func js(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/javascript")
|
||||||
|
fmt.Fprint(w, `document.addEventListener("htmx:afterSwap",()=>window.basecoat?.initAll());document.addEventListener("htmx:historyRestore",()=>window.basecoat?.initAll({force:true}));document.addEventListener("change",e=>{if(e.target.id==="tool-selector")location.href=e.target.value});document.addEventListener("click",e=>{if(e.target.closest("[data-open-approval]"))document.querySelector("#approval-dialog")?.showModal();if(e.target.closest("[data-close-approval]"))document.querySelector("#approval-dialog")?.close()});document.addEventListener("generationQueued",()=>{const d=document.querySelector("#approval-dialog"),p=d?.querySelector('[name="instructor_pin"]');if(p)p.value="";d?.close()});`)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>`
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Definition struct{ Key, Name, Description string }
|
||||||
|
type Tool interface {
|
||||||
|
Definition() Definition
|
||||||
|
StudentHandler() http.Handler
|
||||||
|
}
|
||||||
|
type InstructorTool interface{ InstructorHandler() http.Handler }
|
||||||
|
type Runner interface{ Run(context.Context) error }
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("tool not found")
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
tools map[string]Tool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry() *Registry { return &Registry{tools: make(map[string]Tool)} }
|
||||||
|
func (r *Registry) Register(t Tool) error {
|
||||||
|
if t == nil || t.Definition().Key == "" {
|
||||||
|
return errors.New("tool key is required")
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if _, ok := r.tools[t.Definition().Key]; ok {
|
||||||
|
return errors.New("duplicate tool key")
|
||||||
|
}
|
||||||
|
r.tools[t.Definition().Key] = t
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (r *Registry) Get(key string) (Tool, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
t, ok := r.tools[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
func (r *Registry) List() []Tool {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
out := make([]Tool, 0, len(r.tools))
|
||||||
|
for _, t := range r.tools {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Definition().Name < out[j].Definition().Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeTool struct{ key, name string }
|
||||||
|
|
||||||
|
func (f fakeTool) Definition() Definition { return Definition{Key: f.key, Name: f.name} }
|
||||||
|
func (fakeTool) StudentHandler() http.Handler { return http.NotFoundHandler() }
|
||||||
|
func TestRegistry(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if err := r.Register(fakeTool{}); err == nil {
|
||||||
|
t.Fatal("empty key accepted")
|
||||||
|
}
|
||||||
|
if err := r.Register(fakeTool{key: "b", name: "Beta"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Register(fakeTool{key: "a", name: "Alpha"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Register(fakeTool{key: "a", name: "Again"}); err == nil {
|
||||||
|
t.Fatal("duplicate accepted")
|
||||||
|
}
|
||||||
|
if got := r.List(); len(got) != 2 || got[0].Definition().Key != "a" {
|
||||||
|
t.Fatalf("unstable list: %#v", got)
|
||||||
|
}
|
||||||
|
if _, err := r.Get("missing"); err != ErrNotFound {
|
||||||
|
t.Fatalf("got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user