Production state
This commit is contained in:
+9
-3
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -24,9 +25,14 @@ func Run() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var handler slog.Handler = slog.NewTextHandler(os.Stdout, nil)
|
||||
var level slog.Level
|
||||
if err := level.UnmarshalText([]byte(cfg.LogLevel)); err != nil {
|
||||
return fmt.Errorf("invalid LOG_LEVEL %q: %w", cfg.LogLevel, err)
|
||||
}
|
||||
handlerOptions := &slog.HandlerOptions{Level: level}
|
||||
var handler slog.Handler = slog.NewTextHandler(os.Stdout, handlerOptions)
|
||||
if cfg.LogFormat == "json" {
|
||||
handler = slog.NewJSONHandler(os.Stdout, nil)
|
||||
handler = slog.NewJSONHandler(os.Stdout, handlerOptions)
|
||||
}
|
||||
log := slog.New(handler)
|
||||
a := auth.New(cfg.StudentPIN, cfg.InstructorPIN, cfg.SessionSecret, cfg.SessionDuration, cfg.Environment == "production")
|
||||
@@ -38,7 +44,7 @@ func Run() error {
|
||||
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}
|
||||
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, MaxHeaderBytes: 64 << 10}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
workerDone := make(chan error, 1)
|
||||
|
||||
+12
-1
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
@@ -21,7 +22,7 @@ func LoadConfigFromEnv() (Config, error) {
|
||||
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"}}
|
||||
}{{&c.ReadHeaderTimeout, "HTTP_READ_HEADER_TIMEOUT", "5s"}, {&c.ReadTimeout, "HTTP_READ_TIMEOUT", "30s"}, {&c.WriteTimeout, "HTTP_WRITE_TIMEOUT", "10m"}, {&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 {
|
||||
@@ -31,6 +32,16 @@ func LoadConfigFromEnv() (Config, error) {
|
||||
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")
|
||||
}
|
||||
if c.LogFormat != "text" && c.LogFormat != "json" {
|
||||
return c, errors.New("LOG_FORMAT must be text or json")
|
||||
}
|
||||
publicURL, err := url.Parse(c.PublicBaseURL)
|
||||
if err != nil || publicURL.Host == "" || (publicURL.Scheme != "http" && publicURL.Scheme != "https") || (publicURL.Path != "" && publicURL.Path != "/") || publicURL.RawQuery != "" || publicURL.Fragment != "" || publicURL.User != nil {
|
||||
return c, errors.New("PUBLIC_BASE_URL must be an HTTP(S) origin without a path, query, credentials, or fragment")
|
||||
}
|
||||
if c.Environment == "production" && publicURL.Scheme != "https" {
|
||||
return c, errors.New("PUBLIC_BASE_URL must use HTTPS in production")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
func get(k, d string) string {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func setRequiredConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("STUDENT_PIN", "student")
|
||||
t.Setenv("INSTRUCTOR_PIN", "instructor")
|
||||
t.Setenv("SESSION_SIGNING_SECRET", "12345678901234567890123456789012")
|
||||
t.Setenv("LOG_LEVEL", "info")
|
||||
t.Setenv("LOG_FORMAT", "text")
|
||||
}
|
||||
|
||||
func TestProductionConfigRequiresHTTPSPublicOrigin(t *testing.T) {
|
||||
setRequiredConfig(t)
|
||||
t.Setenv("APP_ENV", "production")
|
||||
t.Setenv("PUBLIC_BASE_URL", "http://preface-tools.example.test")
|
||||
if _, err := LoadConfigFromEnv(); err == nil {
|
||||
t.Fatal("production HTTP public URL accepted")
|
||||
}
|
||||
t.Setenv("PUBLIC_BASE_URL", "https://preface-tools.example.test")
|
||||
cfg, err := LoadConfigFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.WriteTimeout != 10*time.Minute {
|
||||
t.Fatalf("write timeout = %s", cfg.WriteTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRejectsPublicURLWithPath(t *testing.T) {
|
||||
setRequiredConfig(t)
|
||||
t.Setenv("APP_ENV", "development")
|
||||
t.Setenv("PUBLIC_BASE_URL", "https://preface-tools.example.test/subpath")
|
||||
if _, err := LoadConfigFromEnv(); err == nil {
|
||||
t.Fatal("public URL with path accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LoadDotEnv loads simple KEY=VALUE entries without replacing variables that
|
||||
// are already present in the process environment. A missing file is allowed.
|
||||
func LoadDotEnv(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("open dotenv file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for lineNumber := 1; scanner.Scan(); lineNumber++ {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
||||
key, value, found := strings.Cut(line, "=")
|
||||
key = strings.TrimSpace(key)
|
||||
if !found || !validEnvironmentKey(key) {
|
||||
return fmt.Errorf("%s:%d: invalid environment assignment", path, lineNumber)
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 2 && ((value[0] == '\'' && value[len(value)-1] == '\'') || (value[0] == '"' && value[len(value)-1] == '"')) {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
if _, exists := os.LookupEnv(key); !exists {
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
return fmt.Errorf("%s:%d: set environment: %w", path, lineNumber, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("read dotenv file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validEnvironmentKey(key string) bool {
|
||||
if key == "" || !isEnvironmentKeyStart(key[0]) {
|
||||
return false
|
||||
}
|
||||
for i := 1; i < len(key); i++ {
|
||||
if !isEnvironmentKeyStart(key[i]) && (key[i] < '0' || key[i] > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isEnvironmentKeyStart(char byte) bool {
|
||||
return char == '_' || char >= 'A' && char <= 'Z' || char >= 'a' && char <= 'z'
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadDotEnv(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
contents := "# comment\nDOTENV_TEST_ONE=value\nDOTENV_TEST_TWO=Preface Tools - Comic Animator\nDOTENV_TEST_THREE=\"quoted value\"\n"
|
||||
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DOTENV_TEST_ONE", "existing")
|
||||
if err := LoadDotEnv(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := os.Getenv("DOTENV_TEST_ONE"); got != "existing" {
|
||||
t.Fatalf("existing environment overwritten: %q", got)
|
||||
}
|
||||
if got := os.Getenv("DOTENV_TEST_TWO"); got != "Preface Tools - Comic Animator" {
|
||||
t.Fatalf("unquoted value: %q", got)
|
||||
}
|
||||
if got := os.Getenv("DOTENV_TEST_THREE"); got != "quoted value" {
|
||||
t.Fatalf("quoted value: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDotEnvMissingFile(t *testing.T) {
|
||||
if err := LoadDotEnv(filepath.Join(t.TempDir(), "missing")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.michelsen.id/phill/preface-tools/internal/auth"
|
||||
"git.michelsen.id/phill/preface-tools/internal/tools"
|
||||
)
|
||||
|
||||
type testTool struct{}
|
||||
|
||||
func (testTool) Definition() tools.Definition { return tools.Definition{Key: "test", Name: "Test"} }
|
||||
func (testTool) StudentHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`<section id="tool-fragment">Tool</section>`))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
func (testTool) InstructorHandler() http.Handler { return testTool{}.StudentHandler() }
|
||||
|
||||
func TestNewWithToolRoutesDoesNotPanic(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
if err := registry.Register(testTool{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authService := auth.New("student", "instructor", string(make([]byte, 32)), time.Hour, false)
|
||||
server := New(authService, registry, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if server.Handler() == nil {
|
||||
t.Fatal("missing handler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRootUsesApplicationShell(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
if err := registry.Register(testTool{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authService := auth.New("student", "instructor", string(make([]byte, 32)), time.Hour, false)
|
||||
claims, err := authService.Authenticate(auth.Student, "student", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := New(authService, registry, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
request := httptest.NewRequest(http.MethodGet, "/tools/test/", nil)
|
||||
request.AddCookie(&http.Cookie{Name: auth.CookieName, Value: authService.Sign(claims)})
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
for _, expected := range []string{"<!doctype html>", `data-theme="silk"`, "daisyui@5.6.3", "Preface Tools", `id="tool-fragment"`} {
|
||||
if !strings.Contains(response.Body.String(), expected) {
|
||||
t.Errorf("rendered tool page missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,16 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
OpenRouterAPIKey, OpenRouterBaseURL, OpenRouterSiteURL, OpenRouterAppName, PromptModel, PromptFile, VideoModel, VideoResolution, 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")}
|
||||
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"), PromptFile: get("COMIC_ANIMATOR_PROMPT_FILE", "prompts/comic-animator-system.txt"), VideoModel: os.Getenv("COMIC_ANIMATOR_VIDEO_MODEL"), VideoResolution: get("COMIC_ANIMATOR_VIDEO_RESOLUTION", "720p"), 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 {
|
||||
|
||||
@@ -28,7 +28,7 @@ type Generation struct {
|
||||
ID, SessionID, UploadID, OriginalName, ReviewedPrompt, ProviderJobID string
|
||||
Status GenerationStatus
|
||||
Duration int
|
||||
Resolution, AspectRatio, OutputPath, OutputMIMEType string
|
||||
Resolution, OutputPath, OutputMIMEType string
|
||||
OutputSize int64
|
||||
ErrorCode, ErrorMessage string
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
|
||||
@@ -110,7 +110,6 @@ type VideoRequest struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ func New(cfg Config, log *slog.Logger, approval ApprovalVerifier) (*Tool, error)
|
||||
return nil, err
|
||||
}
|
||||
t := &Tool{cfg: cfg, log: log, approval: approval, store: newStore(), queue: make(chan string, cfg.QueueCapacity)}
|
||||
if _, err := t.systemPrompt(); err != nil {
|
||||
return nil, fmt.Errorf("load Comic Animator system prompt: %w", err)
|
||||
}
|
||||
t.approvalLimiter = auth.NewLimiter(5, 5*time.Minute)
|
||||
t.client = &openrouter.Client{BaseURL: cfg.OpenRouterBaseURL, APIKey: cfg.OpenRouterAPIKey, SiteURL: cfg.OpenRouterSiteURL, AppName: cfg.OpenRouterAppName, HTTP: &http.Client{Timeout: cfg.HTTPTimeout}}
|
||||
sm := http.NewServeMux()
|
||||
@@ -84,10 +87,25 @@ func id(prefix string) string {
|
||||
}
|
||||
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":"..."}`
|
||||
const maxSystemPromptBytes = 64 << 10
|
||||
|
||||
func (t *Tool) systemPrompt() (string, error) {
|
||||
b, err := os.ReadFile(t.cfg.PromptFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(b) == 0 || len(b) > maxSystemPromptBytes {
|
||||
return "", fmt.Errorf("system prompt must contain between 1 and %d bytes", maxSystemPromptBytes)
|
||||
}
|
||||
prompt := strings.TrimSpace(string(b))
|
||||
if prompt == "" {
|
||||
return "", fmt.Errorf("system prompt is empty")
|
||||
}
|
||||
return prompt, nil
|
||||
}
|
||||
|
||||
func (t *Tool) page(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, toolPage, map[string]any{"CSRF": claims(r).CSRFToken, "Duration": t.cfg.VideoDuration, "AspectRatio": t.cfg.VideoAspectRatio})
|
||||
render(w, toolPage, map[string]any{"CSRF": claims(r).CSRFToken, "Duration": t.cfg.VideoDuration})
|
||||
}
|
||||
func (t *Tool) upload(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, t.cfg.MaxUploadBytes+1<<20)
|
||||
@@ -152,7 +170,7 @@ func (t *Tool) upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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))
|
||||
fmt.Fprintf(w, `<div id="upload-preview" class="preview"><img src="uploads/%s/preview" alt="Uploaded comic preview"><input type="hidden" name="upload_id" value="%s"><p title="%s">%s</p></div>`, template.HTMLEscapeString(uid), template.HTMLEscapeString(uid), template.HTMLEscapeString(u.Name), template.HTMLEscapeString(u.Name))
|
||||
}
|
||||
|
||||
func validateWebP(head []byte) error {
|
||||
@@ -201,6 +219,12 @@ func (t *Tool) prompt(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
dataURL := "data:" + u.MIMEType + ";base64," + base64.StdEncoding.EncodeToString(b)
|
||||
systemPrompt, err := t.systemPrompt()
|
||||
if err != nil {
|
||||
t.log.Error("could not load system prompt", "path", t.cfg.PromptFile, "error", err)
|
||||
fragmentError(w, 500, "The prompt configuration could not be loaded.")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), t.cfg.HTTPTimeout)
|
||||
defer cancel()
|
||||
result, err := t.client.Prompt(ctx, t.cfg.PromptModel, desc, dataURL, systemPrompt)
|
||||
@@ -213,7 +237,7 @@ func (t *Tool) prompt(w http.ResponseWriter, r *http.Request) {
|
||||
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)))
|
||||
fmt.Fprintf(w, `<label for="reviewed_prompt">Video prompt</label><textarea class="textarea textarea-bordered" id="reviewed_prompt" name="reviewed_prompt" form="comic-form" 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)
|
||||
@@ -229,13 +253,12 @@ func (t *Tool) submit(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
if err != nil || prompt == "" || len(prompt) > 12000 || errDuration != nil || duration != t.cfg.VideoDuration {
|
||||
fragmentError(w, 400, "Check the image, prompt, and settings.")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
g := Generation{ID: id("gen_"), SessionID: claims(r).SessionID, UploadID: u.ID, OriginalName: u.Name, ReviewedPrompt: prompt, Status: Queued, Duration: duration, Resolution: t.cfg.VideoResolution, AspectRatio: aspect, CreatedAt: now, UpdatedAt: now}
|
||||
g := Generation{ID: id("gen_"), SessionID: claims(r).SessionID, UploadID: u.ID, OriginalName: u.Name, ReviewedPrompt: prompt, Status: Queued, Duration: duration, Resolution: t.cfg.VideoResolution, CreatedAt: now, UpdatedAt: now}
|
||||
t.store.putGeneration(g)
|
||||
select {
|
||||
case t.queue <- g.ID:
|
||||
|
||||
@@ -3,9 +3,11 @@ package comicanimator
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -40,7 +42,7 @@ func TestValidateWebP(t *testing.T) {
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := template.Must(template.New("tool").Parse(toolPage)).Execute(&bytes.Buffer{}, map[string]any{"CSRF": "x", "Duration": 6}); 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 {
|
||||
@@ -48,6 +50,47 @@ func TestTemplatesExecute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptReloadsFromFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "system-prompt.txt")
|
||||
if err := os.WriteFile(path, []byte("first prompt\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tool := &Tool{cfg: Config{PromptFile: path}}
|
||||
prompt, err := tool.systemPrompt()
|
||||
if err != nil || prompt != "first prompt" {
|
||||
t.Fatalf("first load = %q, %v", prompt, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("second prompt\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt, err = tool.systemPrompt()
|
||||
if err != nil || prompt != "second prompt" {
|
||||
t.Fatalf("reloaded prompt = %q, %v", prompt, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptRejectsEmptyFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "system-prompt.txt")
|
||||
if err := os.WriteFile(path, []byte(" \n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (&Tool{cfg: Config{PromptFile: path}}).systemPrompt(); err == nil {
|
||||
t.Fatal("empty system prompt accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudentCompletedCardUsesGenerationMediaRoutes(t *testing.T) {
|
||||
tool := &Tool{}
|
||||
response := httptest.NewRecorder()
|
||||
tool.renderCard(response, Generation{ID: "gen_test", Status: Completed}, false)
|
||||
body := response.Body.String()
|
||||
for _, route := range []string{`src="generations/gen_test/video"`, `href="generations/gen_test/download"`} {
|
||||
if !strings.Contains(body, route) {
|
||||
t.Errorf("student generation card missing %q", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoSignatures(t *testing.T) {
|
||||
mp4 := append([]byte{0, 0, 0, 16}, []byte("ftypisom")...)
|
||||
if !validVideoHeader("video/mp4", mp4) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -38,7 +38,7 @@ func (t *Tool) work(appctx context.Context, id string) {
|
||||
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"}}})
|
||||
job, err := t.client.Submit(ctx, openrouter.VideoRequest{Model: t.cfg.VideoModel, Prompt: g.ReviewedPrompt, Duration: g.Duration, Resolution: g.Resolution, 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
|
||||
|
||||
Reference in New Issue
Block a user