diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..af4a70a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.env +data +preface-tools +README.md diff --git a/.env.example b/.env.example index ae246ae..96d767c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index 97831a7..72c656e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index d8e36e1..3c4b5f8 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/healthcheck/main.go b/cmd/healthcheck/main.go new file mode 100644 index 0000000..4791350 --- /dev/null +++ b/cmd/healthcheck/main.go @@ -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) + } +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..c064be5 --- /dev/null +++ b/compose.yml @@ -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: diff --git a/internal/app/app.go b/internal/app/app.go index 3f6a2fb..b9590a6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/app/config.go b/internal/app/config.go index 2a1d710..406863f 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -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 { diff --git a/internal/app/config_test.go b/internal/app/config_test.go new file mode 100644 index 0000000..fe86fd9 --- /dev/null +++ b/internal/app/config_test.go @@ -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") + } +} diff --git a/internal/app/dotenv.go b/internal/app/dotenv.go new file mode 100644 index 0000000..bc56454 --- /dev/null +++ b/internal/app/dotenv.go @@ -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' +} diff --git a/internal/app/dotenv_test.go b/internal/app/dotenv_test.go new file mode 100644 index 0000000..9b850e3 --- /dev/null +++ b/internal/app/dotenv_test.go @@ -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) + } +} diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go index aa48bcc..40a59a2 100644 --- a/internal/httpserver/server.go +++ b/internal/httpserver/server.go @@ -32,24 +32,28 @@ func New(a *auth.Service, r *tools.Registry, log *slog.Logger) *Server { 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) + mux.HandleFunc("/", 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)))) + mux.Handle("GET "+studentPrefix+"/provider-media/", suffix) + mux.Handle(studentPrefix+"/", s.withAuth(auth.Student, s.csrf(s.shell(tool, suffix, false, studentPrefix+"/")))) 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(prefix+"/", s.withAuth(auth.Instructor, s.csrf(s.shell(tool, http.StripPrefix(prefix, it.InstructorHandler()), true, prefix+"/")))) } } - mux.Handle("GET /instructor", s.withAuth(auth.Instructor, http.HandlerFunc(s.instructorRoot))) + mux.Handle("/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) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } c, err := s.auth.FromRequest(r) if err != nil { http.Redirect(w, r, "/login", http.StatusSeeOther) @@ -91,6 +95,10 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", 303) } func (s *Server) instructorRoot(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } list := s.registry.List() for _, t := range list { if _, ok := t.(tools.InstructorTool); ok { @@ -102,6 +110,7 @@ func (s *Server) instructorRoot(w http.ResponseWriter, r *http.Request) { } func (s *Server) withAuth(role auth.Role, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") c, err := s.auth.FromRequest(r) if err != nil { http.Redirect(w, r, "/login", 303) @@ -136,9 +145,9 @@ func (s *Server) csrf(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } -func (s *Server) shell(tool tools.Tool, next http.Handler, instructor bool) http.Handler { +func (s *Server) shell(tool tools.Tool, next http.Handler, instructor bool, rootPath string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { + if r.URL.Path != rootPath { next.ServeHTTP(w, r) return } @@ -187,7 +196,7 @@ func (s *Server) middleware(next http.Handler) http.Handler { 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'") + 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: https://www.preface.ai; 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) @@ -208,19 +217,31 @@ func clientIP(r *http.Request) string { 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.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) template.Must(template.New("page").Parse(src)).Execute(w, data) } -const head = `{{if .Title}}{{.Title}} · {{end}}Preface Tools` -const loginTemplate = head + `

PREFACE TOOLS

{{with .Error}}

{{.}}

{{end}}

Student Login

Access classroom tools

Instructor Login

Review generated outputs

` -const shellTemplate = head + `
PREFACE TOOLS
{{.Content}}
` +const head = `{{if .Title}}{{.Title}} · {{end}}Preface Tools` +const loginTemplate = head + `
{{with .Error}}{{end}}
` +const shellTemplate = head + `
{{.Content}}
` 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}}`) + fmt.Fprint(w, ` +:root,[data-theme=preface]{color-scheme:light;--color-base-100:#fff;--color-base-200:#f6f7fb;--color-base-300:#e7e9f1;--color-base-content:#182033;--color-primary:#6657e8;--color-primary-content:#fff;--color-secondary:#e9e6ff;--color-secondary-content:#4438b8;--color-accent:#8b7cf6;--color-neutral:#20283a;--color-neutral-content:#fff;--color-info:#3977d6;--color-success:#168567;--color-warning:#b66a16;--color-error:#c13d4d;--radius-selector:.65rem;--radius-field:.75rem;--radius-box:1rem;--border:1px;--depth:0;--noise:0} +*{box-sizing:border-box}html{background:#f6f7fb}body{min-height:100vh;margin:0;background:radial-gradient(circle at 15% 0%,rgba(102,87,232,.07),transparent 28rem),#f6f7fb;color:#182033;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit}svg{width:1.15rem;height:1.15rem;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}.brand{display:inline-flex;align-items:center;gap:.7rem;color:#182033;font-size:.78rem;font-weight:800;letter-spacing:.115em;text-decoration:none}.brand-large{font-size:.9rem}.brand-mark{display:grid;width:2.15rem;height:2.15rem;place-items:center;border-radius:.7rem;background:#6657e8;color:#fff;font-size:1rem;letter-spacing:0;box-shadow:0 6px 18px rgba(102,87,232,.22)}.eyebrow{display:block;margin-bottom:.75rem;color:#6657e8;font-size:.72rem;font-weight:800;letter-spacing:.12em;text-transform:uppercase}.app-header{position:sticky;z-index:30;top:0;border-bottom:1px solid rgba(218,221,231,.9);background:rgba(255,255,255,.88);backdrop-filter:blur(18px)}.app-navbar{width:min(1480px,100%);min-height:4.5rem;margin:auto;padding:0 clamp(1rem,3vw,2.5rem)}.app-actions{gap:.75rem}.app-actions form{margin:0}.app-actions .btn svg{width:1rem}.tool-picker{display:flex;align-items:center;gap:.6rem}.tool-picker>span{color:#737b8f;font-size:.75rem;font-weight:700}.tool-picker .select{width:12rem;height:2.45rem;min-height:2.45rem;background:#fff;font-size:.85rem}.role-chip{display:flex;align-items:center;gap:.5rem;border:1px solid #e1e4ec;border-radius:999px;background:#fff;padding:.5rem .8rem;color:#4c556a;font-size:.78rem;font-weight:700}.app-main{width:min(1480px,100%);margin:0 auto;padding:clamp(1.25rem,3vw,2.5rem)} +.login-page{display:grid;min-height:100vh;grid-template-columns:minmax(20rem,.8fr) minmax(36rem,1.2fr);padding:0}.login-intro{position:relative;display:flex;overflow:hidden;min-height:100vh;flex-direction:column;justify-content:space-between;padding:clamp(2rem,5vw,5rem);background:#1e2638;color:#fff}.login-intro:after{position:absolute;right:-10rem;bottom:-12rem;width:35rem;height:35rem;border:1px solid rgba(255,255,255,.08);border-radius:50%;box-shadow:0 0 0 5rem rgba(255,255,255,.025),0 0 0 10rem rgba(255,255,255,.018);content:""}.login-intro .brand{color:#fff}.login-intro .brand-mark{background:#7869ef}.login-intro>div{position:relative;z-index:1;max-width:34rem}.login-intro h1{max-width:9ch;margin:0 0 1.25rem;font-size:clamp(3rem,5vw,5.4rem);font-weight:700;letter-spacing:-.055em;line-height:.96}.login-intro p{max-width:31rem;margin:0;color:#b9c0d0;font-size:1.05rem;line-height:1.75}.login-intro .eyebrow{color:#9f94ff}.login-note{display:flex;align-items:center;gap:.65rem;color:#cbd1dd;font-size:.8rem}.login-panel{display:grid;min-height:100vh;place-items:center;padding:clamp(1.5rem,5vw,5rem)}.login-panel-inner{width:min(100%,52rem)}.login-heading{margin-bottom:2rem}.login-heading h2{margin:0 0 .6rem;font-size:clamp(1.8rem,3vw,2.5rem);font-weight:700;letter-spacing:-.035em}.login-heading p{margin:0;color:#747c8f}.login-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.login-card{border:1px solid #e1e4ec;background:#fff;box-shadow:0 16px 50px rgba(25,32,51,.07);transition:transform .2s ease,border-color .2s ease,box-shadow .2s ease}.login-card:hover{transform:translateY(-2px);border-color:#c9c4fa;box-shadow:0 20px 55px rgba(25,32,51,.1)}.login-card .card-body{gap:1.15rem;padding:1.5rem}.login-card .card-title{margin:0;font-size:1.15rem}.login-card p{margin:.25rem 0 0;color:#767e91;font-size:.85rem;line-height:1.5}.login-card-icon{display:grid;width:2.8rem;height:2.8rem;place-items:center;border-radius:.85rem}.login-card-icon svg{width:1.35rem;height:1.35rem}.student-icon{background:#ece9ff;color:#5849d8}.instructor-icon{background:#e8f3f0;color:#13735a}.form-control{display:grid;gap:.45rem}.label-text{color:#4e5669;font-size:.78rem;font-weight:700}.input,.select,.textarea,.file-input{outline:none}.input:focus,.select:focus,.textarea:focus,.file-input:focus{border-color:#7769ee;box-shadow:0 0 0 3px rgba(102,87,232,.12)}.alert{margin-bottom:1rem;font-size:.85rem}.alert svg{flex:none} +.workspace-heading{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:1.5rem}.workspace-heading h1{margin:.2rem 0 .35rem;font-size:clamp(1.7rem,3vw,2.35rem);font-weight:700;letter-spacing:-.04em}.workspace-heading p{margin:0;color:#737b8e;font-size:.92rem}.workspace-kicker{display:flex;align-items:center;gap:.45rem;color:#6557dc;font-size:.73rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase}.workspace-kicker svg{width:.9rem}.workflow-steps{display:flex;gap:.45rem}.workflow-step{display:flex;align-items:center;gap:.4rem;border:1px solid #e0e3eb;border-radius:999px;background:#fff;padding:.4rem .65rem;color:#70788a;font-size:.7rem;font-weight:700}.workflow-step b{display:grid;width:1.25rem;height:1.25rem;place-items:center;border-radius:50%;background:#ece9ff;color:#5b4cda;font-size:.65rem}.tool-grid{display:grid;grid-template-columns:minmax(15rem,.85fr) minmax(23rem,1.35fr) minmax(15rem,.75fr);gap:1rem;align-items:stretch}.workspace-card{overflow:hidden;border:1px solid #e2e4ec;background:#fff;box-shadow:0 8px 30px rgba(29,37,56,.055)}.workspace-card .card-body{gap:1rem;padding:1.25rem}.card-heading{display:flex;align-items:flex-start;gap:.8rem}.step-number{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:.65rem;background:#eeecff;color:#5d4fdb;font-size:.75rem;font-weight:800}.card-heading h2{margin:.05rem 0 .2rem;font-size:.98rem;font-weight:750}.card-heading p{margin:0;color:#808798;font-size:.75rem;line-height:1.45}.upload-zone{display:grid;min-height:19rem;place-items:center;border:1.5px dashed #d7d9e5;border-radius:.85rem;background:#fafafe;text-align:center;transition:border-color .2s,background .2s}.upload-zone:hover{border-color:#9b91ed;background:#f8f7ff}.upload-empty{padding:1.25rem}.upload-icon{display:grid;width:3.25rem;height:3.25rem;margin:0 auto .85rem;place-items:center;border-radius:1rem;background:#ece9ff;color:#5b4cda}.upload-icon svg{width:1.45rem;height:1.45rem}.upload-empty strong{display:block;margin-bottom:.3rem;font-size:.88rem}.upload-empty p{margin:0 0 1rem;color:#8990a1;font-size:.73rem}.upload-empty .file-input{width:100%;max-width:16rem}.preview{position:relative;width:100%;height:100%;padding:.75rem}.preview img{width:100%;height:17.5rem;border-radius:.7rem;object-fit:contain;background:#eef0f5}.preview p{overflow:hidden;margin:.65rem 0 0;color:#687084;font-size:.72rem;text-overflow:ellipsis;white-space:nowrap}.motion-form{display:flex;min-height:100%;flex-direction:column}.motion-form .textarea{min-height:16.5rem;resize:vertical;background:#fbfbfd;line-height:1.6}.helper-copy{margin:0;color:#7b8294;font-size:.76rem;line-height:1.55}.prompt-actions{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.15rem}.prompt-result{margin-top:.25rem}.prompt-result:empty{display:none}.prompt-result label{display:block;margin:1rem 0 .45rem;color:#4e5669;font-size:.78rem;font-weight:750}.prompt-result textarea{width:100%;min-height:11rem;border:1px solid #d8dbe5;border-radius:.75rem;background:#f8f9fc;padding:.8rem;font-size:.82rem;line-height:1.55;resize:vertical}.prompt-result small{display:block;margin-top:.35rem;text-align:right}.generate-card{background:linear-gradient(160deg,#fff 0%,#faf9ff 100%)}.settings-list{display:grid;gap:.75rem;margin:.3rem 0}.setting-row{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #eceef3;padding:.65rem 0}.setting-row span{color:#747c8e;font-size:.77rem}.setting-value{display:flex!important;width:auto!important;min-width:4rem;margin:0!important;text-align:center;font-weight:750}.cost-note{display:flex;gap:.65rem;border:1px solid #e5e1ff;border-radius:.75rem;background:#f5f3ff;padding:.75rem;color:#5f55a6;font-size:.73rem;line-height:1.45}.cost-note svg{width:1rem;flex:none}.generate-card .btn-primary{margin-top:auto}.recent-section{margin-top:1.25rem}.section-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:.85rem}.section-heading h2{margin:0;font-size:1.05rem}.section-heading p{margin:.2rem 0 0;color:#838a9b;font-size:.75rem}.history-shell{min-height:7rem;border:1px solid #e1e4ec;border-radius:1rem;background:#fff;padding:0 1.2rem;box-shadow:0 8px 30px rgba(29,37,56,.04)}.history-shell:empty:after{display:grid;min-height:7rem;place-items:center;color:#9299a8;font-size:.8rem;content:"No animations in this session yet"}.generation{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.8rem 1.25rem;border-bottom:1px solid #eceef3;padding:1.15rem 0}.generation:last-child{border-bottom:0}.generation-header{display:flex;align-items:center;gap:.65rem}.generation-header small{color:#8a91a0}.generation-body{grid-column:1/-1}.generation video{width:min(100%,44rem);max-height:30rem;border-radius:.8rem;background:#151923}.generation-actions{display:flex;align-items:center;gap:.5rem}.generation details{grid-column:1/-1}.generation details p{max-width:70ch;color:#626a7d;font-size:.8rem;line-height:1.6}.status-badge{text-transform:capitalize}.working-state{display:flex;align-items:center;gap:.55rem;color:#767e91;font-size:.8rem}.muted,small{color:#7b8293}.error{color:#b3293b;font-size:.8rem}.htmx-indicator{display:none}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{display:inline-flex}.modal{background:rgba(17,23,36,.45);backdrop-filter:blur(4px)}.modal-box{max-width:29rem;border:1px solid #e3e5ec;padding:0;box-shadow:0 28px 90px rgba(17,23,36,.24)}.modal-header{display:flex;align-items:flex-start;justify-content:space-between;border-bottom:1px solid #eceef3;padding:1.35rem}.modal-header h2{margin:0 0 .3rem;font-size:1.15rem}.modal-header p{margin:0;color:#7a8294;font-size:.78rem}.modal-content{display:grid;gap:1rem;padding:1.35rem}.modal-action{margin:0;padding-top:.25rem}.instructor-grid{display:grid;gap:1.25rem}.instructor-panel{border:1px solid #e1e4ec;background:#fff;box-shadow:0 8px 30px rgba(29,37,56,.045)}.instructor-panel .card-body{padding:1.35rem}.output-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(18rem,1fr));gap:1rem}.output-card{overflow:hidden;border:1px solid #e4e6ed;border-radius:.85rem;background:#fafbfc}.output-card video{width:100%;height:12rem;background:#171b26;object-fit:contain}.output-meta{display:grid;gap:.5rem;padding:.85rem}.output-meta strong{overflow:hidden;font-size:.78rem;text-overflow:ellipsis;white-space:nowrap}.output-meta small{font-size:.68rem} +.login-simple{display:grid;min-height:100vh;place-items:center;padding:2rem;background:#f7f7f4}.login-box{width:min(100%,48rem)}.login-brand{text-align:center;margin-bottom:2rem}.login-brand img{width:8.75rem;height:auto;margin:0 auto 1.15rem}.login-brand h1{margin:0;color:#1d2433;font-size:2rem;font-weight:650;letter-spacing:-.04em}.login-brand p{margin:.45rem 0 0;color:#7c827f;font-size:.88rem}.login-simple .login-options{grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.login-simple .login-card{box-shadow:none}.login-simple .login-card .card-body{gap:1.2rem;padding:1.35rem}.login-card-title{display:flex;align-items:center;gap:.8rem}.login-card-title h2{margin:0;font-size:1rem;font-weight:700}.login-card-title p{margin:.15rem 0 0;font-size:.75rem}.login-simple .login-card-icon{margin:0;flex:none}.brand-logo{width:6.6rem;height:auto;object-fit:contain}.brand-divider{width:1px;height:1.35rem;background:#dcded9}.brand{gap:.75rem;color:#252c38;font-size:.83rem;font-weight:650;letter-spacing:0;text-transform:none}.tool-picker{gap:.7rem}.tool-picker>span:first-child{font-size:.7rem;text-transform:uppercase;letter-spacing:.06em}.select-wrap{display:flex;align-items:center;gap:.45rem;border:1px solid #dedfd9;border-radius:.65rem;background:#fff;padding-left:.7rem;box-shadow:0 1px 2px rgba(20,25,30,.04)}.select-wrap svg{width:.95rem;color:#6761c8}.tool-picker .select{width:10.75rem;border:0;background:transparent;padding-left:.1rem;box-shadow:none;font-weight:650}.tool-picker .select:focus{box-shadow:none}.workspace-heading{margin-bottom:1.75rem}.tool-grid{gap:1.15rem}.workspace-card .card-body{padding:1.4rem}.upload-empty{display:flex;width:100%;flex-direction:column;align-items:center}.upload-empty .file-input{margin-bottom:.65rem}.upload-empty .btn{width:100%;max-width:16rem}.prompt-actions{align-items:flex-end;flex-direction:column}.prompt-actions>.muted{align-self:flex-start}.prompt-actions .btn{width:100%;height:2.75rem;background:#5f57c8;color:#fff;border-color:#5f57c8}.prompt-actions .btn:hover{background:#5048b7;border-color:#5048b7}.section-heading{min-height:2.75rem}.section-heading>div{min-width:0}.section-heading>span{flex:none}.recent-section .section-heading{padding:0 .15rem}.history-shell{padding:0 1.35rem} +.workspace-heading{display:none}.app-main{padding-top:.75rem}.tool-grid{grid-template-columns:minmax(15rem,.8fr) minmax(24rem,1.25fr) minmax(16rem,.75fr);gap:.75rem}.workspace-card{border-radius:.65rem;box-shadow:0 2px 8px rgba(29,37,56,.035)}.workspace-card .card-body{gap:.8rem;padding:1rem}.card-heading{gap:.45rem}.step-number{display:block;width:auto;height:auto;border-radius:0;background:none;color:#182033;font-size:.78rem}.step-number:after{content:"."}.card-heading h2{margin:0 0 .15rem;font-size:.82rem}.card-heading p{font-size:.68rem}.upload-zone{min-height:17rem;border-radius:.5rem;background:#fff}.upload-icon{width:2.6rem;height:2.6rem;margin-bottom:.6rem;border-radius:.65rem}.upload-empty strong{font-size:.78rem}.upload-empty p{margin-bottom:.7rem;font-size:.66rem}.preview img{height:15rem}.motion-form .textarea{min-height:11rem;background:#fff;font-size:.78rem}.helper-copy{font-size:.68rem}.prompt-result label{margin-top:.65rem}.prompt-result textarea{min-height:7rem;border-radius:.5rem;background:#fff}.generate-card{background:#fff}.settings-list{gap:.35rem}.cost-note{border:0;background:transparent;padding:.25rem 0;color:#737b8e}.recent-section{margin-top:.75rem;border:1px solid #e1e4ec;border-radius:.65rem;background:#fff;padding:.9rem}.recent-section .section-heading{margin:0 0 .4rem;padding:0}.recent-section .section-heading h2{font-size:.82rem}.recent-section .section-heading p{font-size:.67rem}.recent-section .history-shell{border:0;border-radius:0;padding:0;box-shadow:none}.login-simple{position:relative;place-items:center;background:#fafaf8}.login-simple:before{position:absolute;top:2rem;left:2.25rem;color:#162238;font-size:.72rem;font-weight:850;letter-spacing:.13em;content:"PREFACE TOOLS"}.login-box{width:min(100%,42rem)}.login-brand img{width:7.75rem}.login-brand h1{font-size:1.8rem}.login-brand p:before{content:"Welcome to ";color:#273348}.login-simple .login-card{border-radius:.65rem;box-shadow:0 4px 14px rgba(29,37,56,.06)}.login-simple .login-card .card-body{padding:1.15rem}.login-simple:after{position:absolute;bottom:2rem;color:#78808d;font-size:.65rem;content:"© 2025 Preface Tools"} +.workspace-heading:has(+.instructor-grid){display:flex}.brand-logo,.login-brand img{filter:brightness(0)}.login-simple:before,.login-simple:after{content:none}.tool-picker>span:first-child,.app-actions .btn span{display:none}.tool-grid{min-height:clamp(31rem,62vh,43rem)}.workspace-card>.card-body{height:100%;min-height:0}.upload-zone{flex:1}.motion-form{display:flex;width:100%;min-height:0;flex:1;gap:.65rem}.motion-form>.textarea{width:100%;min-height:0;flex:1;resize:none}.motion-form>.helper-copy{flex:none}.prompt-actions{width:100%;flex:none;margin-top:0}.prompt-actions>.muted{display:none}.prompt-actions .btn{flex:none}.prompt-actions .btn:disabled{cursor:wait;opacity:.65}.generate-card .settings-list{display:flex;min-height:18rem;flex:1;flex-direction:column;margin:0;border:1px solid #e1e4ec;border-radius:.55rem;background:#fafbfc;padding:.75rem}.generate-card .settings-list>.setting-row{display:none}.generate-card .settings-list:empty:after{color:#858c9b;font-size:.72rem;line-height:1.5;content:"Generate a video prompt from your description to review it here."}.generate-card .prompt-result{display:flex;width:100%;height:100%;min-height:0;flex:1;flex-direction:column;margin:0}.generate-card .prompt-result:empty:after{color:#858c9b;font-size:.72rem;line-height:1.5;content:"Generate a video prompt from your description to review it here."}.generate-card .prompt-result label{flex:none;margin:0 0 .45rem}.generate-card .prompt-result textarea{width:100%;min-height:0;flex:1;resize:none}.generate-card .prompt-result small{flex:none} +.recent-section .section-heading>span{display:none}.generate-card .step-number{font-size:0}.generate-card .step-number:before{font-size:.78rem;content:"03"} +@media(max-width:1050px){.tool-grid{grid-template-columns:1fr 1.25fr}.generate-card{grid-column:1/-1}.generate-card .card-body{display:grid;grid-template-columns:1fr 1fr;align-items:center}.generate-card .card-heading{grid-column:1/-1}.login-page{grid-template-columns:.75fr 1.25fr}.login-options{grid-template-columns:1fr}} +@media(max-width:720px){.app-navbar{min-height:4rem}.brand-logo{width:5.4rem}.brand-divider,.brand>span:last-child{display:none}.tool-picker>span:first-child,.app-actions .btn span{display:none}.tool-picker .select{width:9rem}.app-main{padding:1rem}.workspace-heading{align-items:flex-start;flex-direction:column;gap:1rem}.workflow-steps{overflow:auto;width:100%;padding-bottom:.25rem}.workflow-step{white-space:nowrap}.tool-grid{grid-template-columns:1fr}.generate-card{grid-column:auto}.generate-card .card-body{display:flex}.login-simple{padding:1rem}.login-simple .login-options{grid-template-columns:1fr}.login-brand{margin-bottom:1.5rem}.login-brand img{width:7.5rem}.generation{grid-template-columns:1fr}.generation-actions{grid-column:1}.output-grid{grid-template-columns:1fr}.upload-zone{min-height:16rem}} +`) } 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()});`) + fmt.Fprint(w, `const placePrompt=()=>{const p=document.querySelector("#prompt-result"),s=document.querySelector(".generate-card .settings-list");if(p&&s&&!s.contains(p)){s.replaceChildren(p)}};const promptButton=e=>e.detail.elt?.matches?.('[hx-post="prompt"]')?e.detail.elt:null;document.addEventListener("DOMContentLoaded",placePrompt);document.addEventListener("htmx:beforeRequest",e=>{const b=promptButton(e);if(b){b.disabled=true;b.setAttribute("aria-busy","true")}});document.addEventListener("htmx:afterRequest",e=>{const b=promptButton(e);if(b){b.disabled=false;b.removeAttribute("aria-busy")}});document.addEventListener("htmx:afterSwap",placePrompt);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()});`) } diff --git a/internal/httpserver/server_test.go b/internal/httpserver/server_test.go new file mode 100644 index 0000000..03d13c8 --- /dev/null +++ b/internal/httpserver/server_test.go @@ -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(`
Tool
`)) + }) + 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{"", `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) + } + } +} diff --git a/internal/tools/comicanimator/config.go b/internal/tools/comicanimator/config.go index 779888c..d9a9826 100644 --- a/internal/tools/comicanimator/config.go +++ b/internal/tools/comicanimator/config.go @@ -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 { diff --git a/internal/tools/comicanimator/models.go b/internal/tools/comicanimator/models.go index d0d005c..819521e 100644 --- a/internal/tools/comicanimator/models.go +++ b/internal/tools/comicanimator/models.go @@ -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 diff --git a/internal/tools/comicanimator/openrouter/client.go b/internal/tools/comicanimator/openrouter/client.go index d6a1f58..2800742 100644 --- a/internal/tools/comicanimator/openrouter/client.go +++ b/internal/tools/comicanimator/openrouter/client.go @@ -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"` } diff --git a/internal/tools/comicanimator/tool.go b/internal/tools/comicanimator/tool.go index e988cef..39a09fe 100644 --- a/internal/tools/comicanimator/tool.go +++ b/internal/tools/comicanimator/tool.go @@ -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, `
Uploaded comic preview

%s

`, template.HTMLEscapeString(uid), template.HTMLEscapeString(uid), template.HTMLEscapeString(u.Name)) + fmt.Fprintf(w, `
Uploaded comic preview

%s

`, 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, `%d characters`, template.HTMLEscapeString(result), len([]rune(result))) + fmt.Fprintf(w, `%d characters`, 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: diff --git a/internal/tools/comicanimator/tool_test.go b/internal/tools/comicanimator/tool_test.go index 8e611ae..ff0c89c 100644 --- a/internal/tools/comicanimator/tool_test.go +++ b/internal/tools/comicanimator/tool_test.go @@ -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) { diff --git a/internal/tools/comicanimator/views.go b/internal/tools/comicanimator/views.go index ab696c4..a8c6efc 100644 --- a/internal/tools/comicanimator/views.go +++ b/internal/tools/comicanimator/views.go @@ -20,30 +20,34 @@ func render(w http.ResponseWriter, src string, data any) { } func fragmentError(w http.ResponseWriter, status int, message string) { w.WriteHeader(status) - fmt.Fprintf(w, ``, template.HTMLEscapeString(message)) + fmt.Fprintf(w, ``, 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, `
%s%s
`, poll, template.HTMLEscapeString(strings.ReplaceAll(string(g.Status), "_", " ")), g.CreatedAt.Format(time.RFC3339)) + statusClass := "badge-info" + if g.Status == Completed { + statusClass = "badge-success" + } + if g.Status == Failed { + statusClass = "badge-error" + } + fmt.Fprintf(w, `
%s%s
`, poll, statusClass, template.HTMLEscapeString(strings.ReplaceAll(string(g.Status), "_", " ")), g.CreatedAt.Format("02 Jan 2006 · 15:04")) if instructor { - fmt.Fprintf(w, `
Reviewed prompt

%s

`, template.HTMLEscapeString(g.ReviewedPrompt)) + fmt.Fprintf(w, `
Reviewed prompt

%s

`, template.HTMLEscapeString(g.ReviewedPrompt)) if g.ProviderJobID != "" { fmt.Fprintf(w, `Provider job: %s`, template.HTMLEscapeString(g.ProviderJobID)) } } if g.Status == Completed { - prefix := "" - if instructor { - prefix = "generations/" - } - fmt.Fprintf(w, `Download`, prefix, g.ID, prefix, g.ID) + prefix := "generations/" + fmt.Fprintf(w, `
`, prefix, g.ID, prefix, g.ID) } else if g.Status == Failed { - fmt.Fprintf(w, `

%s

`, template.HTMLEscapeString(g.ErrorMessage)) + fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(g.ErrorMessage)) } else { - fmt.Fprint(w, `

Working…

`) + fmt.Fprint(w, `
Creating your animation…
`) } fmt.Fprint(w, "
") } @@ -104,5 +108,5 @@ func (t *Tool) outputDownload(w http.ResponseWriter, r *http.Request) { serveFile(w, r, path, mime.TypeByExtension(filepath.Ext(name)), r.URL.Query().Get("preview") != "1") } -const toolPage = `

1. Source image

2. Describe motion

Working…

4. Generate

Recent animations

Instructor approval

An instructor PIN is required for this paid generation.

` -const instructorPage = `

Current process generations

Downloaded output files

Files survive restarts; this is not a complete audit log.

{{range .Outputs}}
{{.Name}}{{.Modified.UTC.Format "2006-01-02 15:04:05Z"}} · {{.Size}} bytesDownload
{{else}}

No downloaded videos yet.

{{end}}
` +const toolPage = `
Comic Animator

Bring a comic page to life

Upload a page, describe the motion, then review the animation prompt.

1Upload2Describe3Review4Generate
01

Source image

Upload one complete comic page

Choose your comic page

PNG, JPEG or WebP · up to 20 MB

02

Describe the movement

Tell the animator what should move

Tip: subtle, specific movement usually works best for a full comic page.

The prompt remains fully editable.
04

Generate video

Review the settings and request approval

Duration{{.Duration}} seconds
AudioOff
SourceFirst frame
Video generation is a paid action and requires an instructor PIN.

Recent animations

Animations created in this browser session

Current session
` +const instructorPage = `
Instructor console

Generation recovery

Monitor this process and recover completed files retained on disk.

Current process

Running and completed generations since startup

Live

Downloaded files

Restart-safe outputs retained on local storage

Not an audit log
{{range .Outputs}}
{{.Name}}{{.Modified.UTC.Format "02 Jan 2006 · 15:04 UTC"}} · {{.Size}} bytesDownload
{{else}}
{{end}}
` diff --git a/internal/tools/comicanimator/worker.go b/internal/tools/comicanimator/worker.go index 7746ebd..c4552e1 100644 --- a/internal/tools/comicanimator/worker.go +++ b/internal/tools/comicanimator/worker.go @@ -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 diff --git a/prompts/comic-animator-system.txt b/prompts/comic-animator-system.txt new file mode 100644 index 0000000..d00ec4b --- /dev/null +++ b/prompts/comic-animator-system.txt @@ -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":""}