Preface Tools
Preface Tools is a small, database-free internal classroom application. Its Comic Animator workflow lets a student:
- upload a complete comic page;
- describe the movement they want;
- generate and edit an image-to-video prompt with an OpenRouter LLM; and
- ask an instructor to approve the paid video-generation request with a PIN.
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:
cp .env.example .env
go run ./cmd/preface-tools
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.
The executable loads .env automatically and does not overwrite variables
already supplied by the process environment.
Configuration
The supplied model and video defaults are:
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
also defines 480p, 1080p, 1K, 2K, and 4K, but each model supports only
a subset. Confirm the current capabilities through the
video-models endpoint
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:
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
-
Copy and secure the environment file:
cp .env.example .env chmod 600 .env -
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:
APP_ENV=production PUBLIC_BASE_URL=https://preface-tools.internal.example.com -
Build and start the service:
docker compose up -d --build docker compose ps docker compose logs -f preface-tools -
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 theportsmapping 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 /healthzfor liveness;GET /readyzfor readiness.
Docker checks /readyz every 30 seconds. To inspect it manually:
curl -fsS http://127.0.0.1:8080/readyz
Reverse-proxy notes
- Forward the original
Hostheader and use HTTPS externally. - Set
Strict-Transport-Securityat 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_URLmust 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
- Sign in with the student PIN.
- Upload one PNG, JPEG, or WebP comic page, up to 20 MiB by default.
- Describe panel movement and click Generate Prompt. The button is disabled while the request is running.
- Review or edit the generated video prompt.
- Click Generate Video and ask an instructor to enter their PIN.
- 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:
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:
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:
gofmt -w .
go test ./...
go test -race ./...
go vet ./...
go build ./...
docker compose config
docker build -t preface-tools:test .
The automated test suite does not make live OpenRouter calls and does not spend API credit.