Production state

This commit is contained in:
2026-07-13 01:26:49 +08:00
parent 7cb409f5a6
commit 8c6e6a54cc
21 changed files with 769 additions and 76 deletions
+7 -7
View File
@@ -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 {
+1 -1
View File
@@ -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"`
}
+30 -7
View File
@@ -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:
+44 -1
View File
@@ -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
+1 -1
View File
@@ -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