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
+5
View File
@@ -0,0 +1,5 @@
.git
.env
data
preface-tools
README.md
+11 -5
View File
@@ -11,12 +11,12 @@ 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_OPENROUTER_APP_NAME="Preface Tools - Comic Animator"
COMIC_ANIMATOR_PROMPT_MODEL=openai/gpt-5.6-luna
COMIC_ANIMATOR_PROMPT_FILE=prompts/comic-animator-system.txt
COMIC_ANIMATOR_VIDEO_MODEL=alibaba/happyhorse-1.1
COMIC_ANIMATOR_VIDEO_DURATION=4
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
@@ -28,3 +28,9 @@ 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
HTTP_READ_HEADER_TIMEOUT=5s
HTTP_READ_TIMEOUT=30s
HTTP_WRITE_TIMEOUT=10m
HTTP_IDLE_TIMEOUT=60s
HTTP_SHUTDOWN_TIMEOUT=10s
+5 -1
View File
@@ -1,13 +1,17 @@
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum* ./
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /preface-tools ./cmd/preface-tools
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /healthcheck ./cmd/healthcheck
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /preface-tools /usr/local/bin/preface-tools
COPY --from=build /healthcheck /usr/local/bin/healthcheck
COPY --from=build /src/prompts /app/prompts
VOLUME ["/app/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD ["/usr/local/bin/healthcheck", "http://127.0.0.1:8080/readyz"]
ENTRYPOINT ["/usr/local/bin/preface-tools"]
+240 -23
View File
@@ -1,46 +1,263 @@
# 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.
Preface Tools is a small, database-free internal classroom application. Its
Comic Animator workflow lets a student:
## Run locally
1. upload a complete comic page;
2. describe the movement they want;
3. generate and edit an image-to-video prompt with an OpenRouter LLM; and
4. ask an instructor to approve the paid video-generation request with a PIN.
Requires Go 1.26 or later. Copy `.env.example` to `.env`, replace every secret
and model placeholder, export the variables, then run:
The application has separate student and instructor sessions, CSRF protection,
rate-limited PIN checks, signed provider-facing image URLs, bounded uploads and
downloads, and an instructor recovery view for completed files.
## Requirements
- Docker Engine with Docker Compose v2 for the recommended deployment, or Go
1.26 or later for a local source build.
- An OpenRouter API key with access and sufficient credit for both configured
models.
- A public HTTPS URL that OpenRouter can reach. OpenRouter fetches the uploaded
comic through a short-lived signed URL when starting image-to-video jobs.
- A TLS-terminating reverse proxy for production. Caddy, Traefik, nginx, or an
existing internal ingress is sufficient.
## Quick start for local development
Copy the environment template and replace all secret placeholders:
```sh
cp .env.example .env
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.
Open `http://localhost:8080`. A local-only `PUBLIC_BASE_URL` is enough to view
the interface, but video generation cannot work until that value is an HTTPS
address reachable by OpenRouter. A temporary HTTPS tunnel is suitable for
development.
## Storage and restart behavior
The executable loads `.env` automatically and does not overwrite variables
already supplied by the process environment.
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.
## Configuration
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.
The supplied model and video defaults are:
```env
COMIC_ANIMATOR_PROMPT_MODEL=openai/gpt-5.6-luna
COMIC_ANIMATOR_VIDEO_MODEL=alibaba/happyhorse-1.1
COMIC_ANIMATOR_VIDEO_DURATION=4
COMIC_ANIMATOR_VIDEO_RESOLUTION=720p
COMIC_ANIMATOR_GENERATE_AUDIO=false
```
`720p` is the recommended balance of cost, generation time, and classroom
quality. [OpenRouter's video API](https://openrouter.ai/docs/guides/overview/multimodal/video-generation)
also defines `480p`, `1080p`, `1K`, `2K`, and `4K`, but each model supports only
a subset. Confirm the current capabilities through the
[video-models endpoint](https://openrouter.ai/docs/api/api-reference/video-generation/list-videos-models)
before changing resolution or duration. HappyHorse 1.1 advertises output up to
1080p; unsupported combinations will be rejected by the provider.
Important application variables:
| Variable | Purpose | Example/default |
| --- | --- | --- |
| `APP_ENV` | Enables secure production cookies when set to `production` | `development` |
| `HTTP_ADDRESS` | Server listen address | `:8080` |
| `PUBLIC_BASE_URL` | Public HTTPS origin reachable by OpenRouter | required |
| `STUDENT_PIN` | Shared classroom login PIN | required |
| `INSTRUCTOR_PIN` | Instructor login and paid-action approval PIN | required |
| `SESSION_SIGNING_SECRET` | Signs browser sessions; at least 32 characters | required |
| `SESSION_DURATION` | Browser session lifetime | `4h` |
| `LOG_LEVEL` | `debug`, `info`, `warn`, or `error` | `info` |
| `LOG_FORMAT` | `text` or `json` | `text` |
| `COMIC_ANIMATOR_OPENROUTER_API_KEY` | OpenRouter bearer token | required |
| `COMIC_ANIMATOR_PROMPT_MODEL` | Multimodal model that writes the video prompt | `openai/gpt-5.6-luna` |
| `COMIC_ANIMATOR_VIDEO_MODEL` | Image-to-video model | `alibaba/happyhorse-1.1` |
| `COMIC_ANIMATOR_PROMPT_FILE` | Reloadable LLM system-prompt path | `prompts/comic-animator-system.txt` |
| `COMIC_ANIMATOR_VIDEO_DURATION` | Requested video length in seconds | `4` |
| `COMIC_ANIMATOR_VIDEO_RESOLUTION` | Provider-supported resolution | `720p` |
| `COMIC_ANIMATOR_GENERATE_AUDIO` | Requests provider audio when supported | `false` |
| `COMIC_ANIMATOR_UPLOAD_DIR` | Temporary uploaded comic storage | `data/comic-animator/uploads` |
| `COMIC_ANIMATOR_OUTPUT_DIR` | Completed video storage | `data/comic-animator/outputs` |
| `COMIC_ANIMATOR_MAX_UPLOAD_BYTES` | Maximum source-image size | `20971520` (20 MiB) |
| `COMIC_ANIMATOR_MAX_VIDEO_BYTES` | Maximum downloaded video size | `536870912` (512 MiB) |
| `COMIC_ANIMATOR_QUEUE_CAPACITY` | In-memory generation queue capacity | `100` |
| `COMIC_ANIMATOR_POLL_INTERVAL` | Provider status polling frequency | `30s` |
| `COMIC_ANIMATOR_JOB_TIMEOUT` | Whole video-job deadline | `15m` |
| `COMIC_ANIMATOR_HTTP_TIMEOUT` | Individual OpenRouter request deadline | `60s` |
| `COMIC_ANIMATOR_SIGNING_SECRET` | Signs temporary image URLs; at least 32 characters | required |
| `COMIC_ANIMATOR_SIGNED_URL_TTL` | Provider image URL lifetime | `30m` |
Optional `COMIC_ANIMATOR_OPENROUTER_SITE_URL` and
`COMIC_ANIMATOR_OPENROUTER_APP_NAME` values populate OpenRouter attribution
headers. The complete template, including HTTP timeout settings, is in
`.env.example`.
Generate independent secrets rather than copying the placeholders:
```sh
openssl rand -hex 32
openssl rand -hex 32
```
Use the two results for `SESSION_SIGNING_SECRET` and
`COMIC_ANIMATOR_SIGNING_SECRET`. Choose non-trivial, different student and
instructor PINs. The `.env` file is ignored by Git; keep it readable only by the
deployment account.
## Editing the LLM system prompt
The LLM system message lives in
`prompts/comic-animator-system.txt`. The server reads it for every Generate
Prompt request, so saving the file changes the next request without restarting
the application. The student's movement description and uploaded image remain
a separate user message.
The generated `video_prompt` string is displayed in the third panel and can be
edited before it is sent to the video model. Missing, empty, or oversized system
prompt files fail safely; the default file is also checked during startup.
With Compose, the local `prompts` directory is mounted read-only inside the
container. Edit the host file normally; the updated content is visible to the
running process immediately.
## Production deployment with Docker Compose
1. Copy and secure the environment file:
```sh
cp .env.example .env
chmod 600 .env
```
2. Set at least the API key, PINs, signing secrets, and public URL. The public
URL must be the final HTTPS origin, without a path, for example:
```env
APP_ENV=production
PUBLIC_BASE_URL=https://preface-tools.internal.example.com
```
3. Build and start the service:
```sh
docker compose up -d --build
docker compose ps
docker compose logs -f preface-tools
```
4. Put a TLS reverse proxy in front of `127.0.0.1:8080`. The Compose file binds
only to loopback deliberately. If TLS is terminated by an ingress on another
host, adjust the `ports` mapping or use an external Docker network while
keeping the application container otherwise private.
The container runs as a non-root user with all Linux capabilities dropped, a
read-only root filesystem, `no-new-privileges`, and a named volume for runtime
data. It exposes:
- `GET /healthz` for liveness;
- `GET /readyz` for readiness.
Docker checks `/readyz` every 30 seconds. To inspect it manually:
```sh
curl -fsS http://127.0.0.1:8080/readyz
```
### Reverse-proxy notes
- Forward the original `Host` header and use HTTPS externally.
- Set `Strict-Transport-Security` at the TLS reverse proxy after confirming the
hostname is HTTPS-only.
- Do not expose port 8080 directly to an untrusted network.
- Allow normal video response sizes and request durations; completed downloads
may take several minutes on slow links.
- `PUBLIC_BASE_URL` must resolve publicly from OpenRouter even if the login UI
itself is restricted by VPN, identity-aware proxy, or network policy. The
provider-media route is protected by a short-lived signature and exposes only
the requested uploaded image.
- The browser currently loads DaisyUI/HTMX from jsDelivr and the Preface logo
from `preface.ai`, so client networks must allow those hosts.
## Usage
### Student workflow
1. Sign in with the student PIN.
2. Upload one PNG, JPEG, or WebP comic page, up to 20 MiB by default.
3. Describe panel movement and click **Generate Prompt**. The button is disabled
while the request is running.
4. Review or edit the generated video prompt.
5. Click **Generate Video** and ask an instructor to enter their PIN.
6. Follow progress in Recent Animations, then play or download the completed
video.
Recent student generations belong to that browser session. Logging out and back
in creates a new student session and therefore a new recent-generation view.
### Instructor workflow
Sign in with the instructor PIN to see generations retained by the current
process and completed output files found on disk. The disk-backed output view is
useful after restarts, but it is not a complete audit log.
## Storage, backups, and restarts
Uploads and generation metadata are held in memory. A restart loses queued and
in-progress jobs, student recent-generation associations, and detailed prompt
metadata. Completed video files are stored in the configured output directory
and survive Compose restarts in the `preface-data` volume.
OpenRouter does not provide this application with a complete restart recovery
mechanism for its asynchronous job state. Avoid deploying multiple replicas:
sessions can reach any replica, while uploads, queues, and job registries are
process-local. A single worker processes video generations sequentially; later
approved requests remain in the bounded in-memory queue.
Uploads and outputs are not automatically deleted. Monitor volume usage and
establish an internal retention process. Back up or export the named volume if
completed videos must be retained:
```sh
docker compose stop
docker run --rm -v preface-tools_preface-data:/data -v "$PWD":/backup \
alpine tar czf /backup/preface-data.tgz -C /data .
docker compose start
```
Adjust the generated volume name if the Compose project name differs.
## Updating and rollback
Build before replacing the running container, then inspect health and logs:
```sh
docker compose build --pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 preface-tools
```
For repeatable production rollbacks, tag images in a registry and replace
`preface-tools:local` in `compose.yml` with an immutable version tag rather than
building directly on the server.
## Verification
Run the local checks before deployment:
```sh
gofmt -w .
go test ./...
go test -race ./...
go vet ./...
go build ./...
docker compose config
docker build -t preface-tools:test .
```
No database, Node.js, npm, frontend build, or live OpenRouter call is required
by the automated test suite.
The automated test suite does not make live OpenRouter calls and does not spend
API credit.
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"fmt"
"net/http"
"os"
"time"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: healthcheck URL")
os.Exit(2)
}
client := &http.Client{Timeout: 2 * time.Second}
response, err := client.Get(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
fmt.Fprintln(os.Stderr, response.Status)
os.Exit(1)
}
}
+36
View File
@@ -0,0 +1,36 @@
services:
preface-tools:
build:
context: .
image: preface-tools:local
restart: unless-stopped
init: true
env_file:
- .env
environment:
APP_ENV: production
HTTP_ADDRESS: :8080
COMIC_ANIMATOR_PROMPT_FILE: /app/prompts/comic-animator-system.txt
COMIC_ANIMATOR_UPLOAD_DIR: /app/data/comic-animator/uploads
COMIC_ANIMATOR_OUTPUT_DIR: /app/data/comic-animator/outputs
ports:
- "127.0.0.1:8080:8080"
volumes:
- preface-data:/app/data
- ./prompts:/app/prompts:ro
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "/usr/local/bin/healthcheck", "http://127.0.0.1:8080/readyz"]
interval: 30s
timeout: 3s
start_period: 10s
retries: 3
volumes:
preface-data:
+9 -3
View File
@@ -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
View File
@@ -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 {
+41
View File
@@ -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")
}
}
+65
View File
@@ -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'
}
+34
View File
@@ -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
+60
View File
@@ -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)
}
}
}
+2 -2
View File
@@ -8,7 +8,7 @@ import (
)
type Config struct {
OpenRouterAPIKey, OpenRouterBaseURL, OpenRouterSiteURL, OpenRouterAppName, PromptModel, VideoModel, VideoResolution, VideoAspectRatio, PublicBaseURL, SigningSecret, UploadDirectory, OutputDirectory string
OpenRouterAPIKey, OpenRouterBaseURL, OpenRouterSiteURL, OpenRouterAppName, PromptModel, PromptFile, VideoModel, VideoResolution, PublicBaseURL, SigningSecret, UploadDirectory, OutputDirectory string
VideoDuration int
GenerateAudio bool
PollInterval, JobTimeout, HTTPTimeout, SignedURLTTL time.Duration
@@ -17,7 +17,7 @@ type Config struct {
}
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
+92
View File
@@ -0,0 +1,92 @@
You are a multimodal prompt compiler for image-to-video animation of complete comic-book pages.
INPUTS
You receive:
1. The original comic-page image.
2. A student's animation description.
3. Optionally, a target duration and provider name.
GOAL
Inspect the image carefully, interpret the student's intended animation in context, and produce one provider-ready image-to-video prompt that adds restrained motion while preserving the comic page as a fixed two-dimensional composition.
VISUAL ANALYSIS
Before writing the final prompt, silently inspect the image and determine:
- The number, order, and approximate position of panels.
- Which characters and important objects appear in each panel.
- Character appearance, clothing, pose, expression, and orientation.
- Existing actions, emotional beats, and visual storytelling.
- Speech bubbles, captions, sound effects, signs, numbers, clocks, and other text-bearing elements.
- Which regions must remain completely static.
- Which small motions are visually plausible without inventing unseen content.
Use visible evidence from the image to resolve vague student references such as:
- “the bear”
- “the character on the left”
- “they look surprised”
- “make it move”
- “animate the last scene”
- “make the cake look better”
When a reference could apply to more than one panel or character, select the most likely interpretation using panel order, visible composition, dialogue, and story context.
SOURCE-OF-TRUTH RULES
- The image is the source of truth for page layout, panel geometry, character identity, appearance, poses, clothing, props, colours, backgrounds, lettering, captions, speech bubbles, and art style.
- The student's description is the source of truth for intended actions and emotional performance.
- The LLM may infer only small, visually supported secondary motions needed to make a vague request usable.
- Do not infer new story events, new objects, new dialogue, major pose changes, off-screen movement, or interactions not supported by the image.
- When the description conflicts with the image or preservation requirements, preserve the image and reduce the requested action to the closest feasible movement.
AMBIGUITY RULES
- If the student gives a clear action and target, follow it.
- If the action is clear but the target is vague, identify the most likely visible target from the image.
- If the target is clear but the action is vague, choose minimal natural motion consistent with the current pose and expression.
- If both target and action are vague, add only subtle ambient animation such as blinking, breathing, tiny ear or hair movement, gentle steam, or a small facial reaction.
- If several interpretations are equally plausible, choose the one requiring the least visual change.
- Never create a dramatic action merely to make the result more interesting.
PRIORITY ORDER
1. Preserve the complete page and all panel boundaries.
2. Preserve all printed text and graphic design exactly.
3. Preserve character and object identity.
4. Perform the student's requested actions.
5. Add only subtle, image-supported secondary motion.
COMPOSITION REQUIREMENTS
- Keep the entire original page visible for the complete clip.
- Treat the page as a locked canvas and each comic panel as an independent sealed stage.
- Keep panel borders, gutters, margins, title text, captions, speech bubbles, dialogue, symbols, and decorative graphics completely stationary and unchanged.
- Maintain the original framing, perspective, scale, proportions, colours, lighting, line work, texture, and illustration style.
- Subjects must remain inside their original panels.
- Motion in one panel must not affect any other panel.
- Do not create transitions between panels.
CAMERA
Use a completely locked camera.
No crop, zoom, pan, tilt, roll, orbit, dolly, shake, reframing, parallax, page turn, depth extrusion, or perspective change.
MOTION
- Animate only actions requested by the student or minimal secondary motions clearly supported by the image.
- Keep movement small, readable, smooth, and suitable for a short illustrated loop.
- Prefer localized motion such as blinking, breathing, slight head turns, small hand gestures, ear movement, subtle facial reactions, steam, sparkles, or gently moving loose objects.
- Respect the character's visible anatomy, pose, balance, and available space.
- Preserve starting silhouettes and positions wherever possible.
- Do not reveal hidden limbs or unseen sides of objects unless already visually implied.
- Do not invent additional characters, props, dialogue, effects, or scene changes.
- If no clear action is requested for a panel, keep that panel static unless a minimal ambient motion is necessary.
- Begin from the exact source image and settle naturally close to the original composition by the end.
TEXT INTEGRITY
All existing words, numbers, punctuation, fonts, line breaks, speech bubbles, captions, signs, and clock faces must remain identical, sharp, legible, and motionless. Do not rewrite, morph, animate, duplicate, erase, or replace text.
PROMPT SPECIFICITY
The final video prompt should explicitly identify animated subjects by panel position and visible description, for example:
- “in the upper-left panel, the brown bear”
- “in the lower-right panel, the white rabbit wearing a red bow tie”
Avoid ambiguous pronouns when more than one character is visible.
OUTPUT RULES
Return valid JSON only.
Do not include analysis, markdown, or commentary.
Schema:
{"video_prompt":"<single polished provider-ready prompt>"}