Production state
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
data
|
||||||
|
preface-tools
|
||||||
|
README.md
|
||||||
+11
-5
@@ -11,12 +11,12 @@ LOG_FORMAT=text
|
|||||||
COMIC_ANIMATOR_OPENROUTER_API_KEY=
|
COMIC_ANIMATOR_OPENROUTER_API_KEY=
|
||||||
COMIC_ANIMATOR_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
COMIC_ANIMATOR_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
COMIC_ANIMATOR_OPENROUTER_SITE_URL=
|
COMIC_ANIMATOR_OPENROUTER_SITE_URL=
|
||||||
COMIC_ANIMATOR_OPENROUTER_APP_NAME=Preface Tools - Comic Animator
|
COMIC_ANIMATOR_OPENROUTER_APP_NAME="Preface Tools - Comic Animator"
|
||||||
COMIC_ANIMATOR_PROMPT_MODEL=
|
COMIC_ANIMATOR_PROMPT_MODEL=openai/gpt-5.6-luna
|
||||||
COMIC_ANIMATOR_VIDEO_MODEL=
|
COMIC_ANIMATOR_PROMPT_FILE=prompts/comic-animator-system.txt
|
||||||
COMIC_ANIMATOR_VIDEO_DURATION=6
|
COMIC_ANIMATOR_VIDEO_MODEL=alibaba/happyhorse-1.1
|
||||||
|
COMIC_ANIMATOR_VIDEO_DURATION=4
|
||||||
COMIC_ANIMATOR_VIDEO_RESOLUTION=720p
|
COMIC_ANIMATOR_VIDEO_RESOLUTION=720p
|
||||||
COMIC_ANIMATOR_VIDEO_ASPECT_RATIO=16:9
|
|
||||||
COMIC_ANIMATOR_GENERATE_AUDIO=false
|
COMIC_ANIMATOR_GENERATE_AUDIO=false
|
||||||
COMIC_ANIMATOR_HTTP_TIMEOUT=60s
|
COMIC_ANIMATOR_HTTP_TIMEOUT=60s
|
||||||
COMIC_ANIMATOR_POLL_INTERVAL=30s
|
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_UPLOAD_BYTES=20971520
|
||||||
COMIC_ANIMATOR_MAX_VIDEO_BYTES=536870912
|
COMIC_ANIMATOR_MAX_VIDEO_BYTES=536870912
|
||||||
COMIC_ANIMATOR_QUEUE_CAPACITY=100
|
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
@@ -1,13 +1,17 @@
|
|||||||
FROM golang:1.26 AS build
|
FROM golang:1.26 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum* ./
|
COPY go.mod ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
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 /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
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /preface-tools /usr/local/bin/preface-tools
|
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"]
|
VOLUME ["/app/data"]
|
||||||
EXPOSE 8080
|
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"]
|
ENTRYPOINT ["/usr/local/bin/preface-tools"]
|
||||||
|
|||||||
@@ -1,46 +1,263 @@
|
|||||||
# Preface Tools
|
# Preface Tools
|
||||||
|
|
||||||
Preface Tools is a database-free classroom utility server. Its first tool,
|
Preface Tools is a small, database-free internal classroom application. Its
|
||||||
Comic Animator, lets a student upload a comic page, create and edit a
|
Comic Animator workflow lets a student:
|
||||||
multimodal OpenRouter prompt, obtain instructor approval, and generate a video.
|
|
||||||
|
|
||||||
## 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
|
The application has separate student and instructor sessions, CSRF protection,
|
||||||
and model placeholder, export the variables, then run:
|
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
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
go run ./cmd/preface-tools
|
go run ./cmd/preface-tools
|
||||||
```
|
```
|
||||||
|
|
||||||
The process fails at startup when required configuration is missing. In
|
Open `http://localhost:8080`. A local-only `PUBLIC_BASE_URL` is enough to view
|
||||||
production, use HTTPS and set `APP_ENV=production` so the session cookie is
|
the interface, but video generation cannot work until that value is an HTTPS
|
||||||
marked Secure. `PUBLIC_BASE_URL` must be an HTTPS address reachable by
|
address reachable by OpenRouter. A temporary HTTPS tunnel is suitable for
|
||||||
OpenRouter because it fetches a short-lived, signed source-image URL.
|
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.
|
## Configuration
|
||||||
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.
|
|
||||||
|
|
||||||
OpenRouter does not document an endpoint for listing all historical video jobs.
|
The supplied model and video defaults are:
|
||||||
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.
|
```env
|
||||||
Uploads and outputs are not automatically removed; operators must monitor disk
|
COMIC_ANIMATOR_PROMPT_MODEL=openai/gpt-5.6-luna
|
||||||
usage and introduce a retention policy appropriate to their deployment.
|
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
|
## Verification
|
||||||
|
|
||||||
|
Run the local checks before deployment:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
gofmt -w .
|
gofmt -w .
|
||||||
go test ./...
|
go test ./...
|
||||||
go test -race ./...
|
go test -race ./...
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go build ./...
|
go build ./...
|
||||||
|
docker compose config
|
||||||
|
docker build -t preface-tools:test .
|
||||||
```
|
```
|
||||||
|
|
||||||
No database, Node.js, npm, frontend build, or live OpenRouter call is required
|
The automated test suite does not make live OpenRouter calls and does not spend
|
||||||
by the automated test suite.
|
API credit.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -24,9 +25,14 @@ func Run() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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" {
|
if cfg.LogFormat == "json" {
|
||||||
handler = slog.NewJSONHandler(os.Stdout, nil)
|
handler = slog.NewJSONHandler(os.Stdout, handlerOptions)
|
||||||
}
|
}
|
||||||
log := slog.New(handler)
|
log := slog.New(handler)
|
||||||
a := auth.New(cfg.StudentPIN, cfg.InstructorPIN, cfg.SessionSecret, cfg.SessionDuration, cfg.Environment == "production")
|
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 {
|
if err = registry.Register(comic); err != nil {
|
||||||
return err
|
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)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
workerDone := make(chan error, 1)
|
workerDone := make(chan error, 1)
|
||||||
|
|||||||
+12
-1
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -21,7 +22,7 @@ func LoadConfigFromEnv() (Config, error) {
|
|||||||
values := []struct {
|
values := []struct {
|
||||||
target *time.Duration
|
target *time.Duration
|
||||||
key, fallback string
|
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 {
|
for _, value := range values {
|
||||||
*value.target, err = duration(value.key, value.fallback)
|
*value.target, err = duration(value.key, value.fallback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -31,6 +32,16 @@ func LoadConfigFromEnv() (Config, error) {
|
|||||||
if c.StudentPIN == "" || c.InstructorPIN == "" || len(c.SessionSecret) < 32 || c.PublicBaseURL == "" {
|
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")
|
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
|
return c, nil
|
||||||
}
|
}
|
||||||
func get(k, d string) string {
|
func get(k, d string) string {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setRequiredConfig(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("STUDENT_PIN", "student")
|
||||||
|
t.Setenv("INSTRUCTOR_PIN", "instructor")
|
||||||
|
t.Setenv("SESSION_SIGNING_SECRET", "12345678901234567890123456789012")
|
||||||
|
t.Setenv("LOG_LEVEL", "info")
|
||||||
|
t.Setenv("LOG_FORMAT", "text")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProductionConfigRequiresHTTPSPublicOrigin(t *testing.T) {
|
||||||
|
setRequiredConfig(t)
|
||||||
|
t.Setenv("APP_ENV", "production")
|
||||||
|
t.Setenv("PUBLIC_BASE_URL", "http://preface-tools.example.test")
|
||||||
|
if _, err := LoadConfigFromEnv(); err == nil {
|
||||||
|
t.Fatal("production HTTP public URL accepted")
|
||||||
|
}
|
||||||
|
t.Setenv("PUBLIC_BASE_URL", "https://preface-tools.example.test")
|
||||||
|
cfg, err := LoadConfigFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.WriteTimeout != 10*time.Minute {
|
||||||
|
t.Fatalf("write timeout = %s", cfg.WriteTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigRejectsPublicURLWithPath(t *testing.T) {
|
||||||
|
setRequiredConfig(t)
|
||||||
|
t.Setenv("APP_ENV", "development")
|
||||||
|
t.Setenv("PUBLIC_BASE_URL", "https://preface-tools.example.test/subpath")
|
||||||
|
if _, err := LoadConfigFromEnv(); err == nil {
|
||||||
|
t.Fatal("public URL with path accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadDotEnv loads simple KEY=VALUE entries without replacing variables that
|
||||||
|
// are already present in the process environment. A missing file is allowed.
|
||||||
|
func LoadDotEnv(path string) error {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open dotenv file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for lineNumber := 1; scanner.Scan(); lineNumber++ {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
||||||
|
key, value, found := strings.Cut(line, "=")
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if !found || !validEnvironmentKey(key) {
|
||||||
|
return fmt.Errorf("%s:%d: invalid environment assignment", path, lineNumber)
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if len(value) >= 2 && ((value[0] == '\'' && value[len(value)-1] == '\'') || (value[0] == '"' && value[len(value)-1] == '"')) {
|
||||||
|
value = value[1 : len(value)-1]
|
||||||
|
}
|
||||||
|
if _, exists := os.LookupEnv(key); !exists {
|
||||||
|
if err := os.Setenv(key, value); err != nil {
|
||||||
|
return fmt.Errorf("%s:%d: set environment: %w", path, lineNumber, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return fmt.Errorf("read dotenv file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validEnvironmentKey(key string) bool {
|
||||||
|
if key == "" || !isEnvironmentKeyStart(key[0]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 1; i < len(key); i++ {
|
||||||
|
if !isEnvironmentKeyStart(key[i]) && (key[i] < '0' || key[i] > '9') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEnvironmentKeyStart(char byte) bool {
|
||||||
|
return char == '_' || char >= 'A' && char <= 'Z' || char >= 'a' && char <= 'z'
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadDotEnv(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), ".env")
|
||||||
|
contents := "# comment\nDOTENV_TEST_ONE=value\nDOTENV_TEST_TWO=Preface Tools - Comic Animator\nDOTENV_TEST_THREE=\"quoted value\"\n"
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("DOTENV_TEST_ONE", "existing")
|
||||||
|
if err := LoadDotEnv(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := os.Getenv("DOTENV_TEST_ONE"); got != "existing" {
|
||||||
|
t.Fatalf("existing environment overwritten: %q", got)
|
||||||
|
}
|
||||||
|
if got := os.Getenv("DOTENV_TEST_TWO"); got != "Preface Tools - Comic Animator" {
|
||||||
|
t.Fatalf("unquoted value: %q", got)
|
||||||
|
}
|
||||||
|
if got := os.Getenv("DOTENV_TEST_THREE"); got != "quoted value" {
|
||||||
|
t.Fatalf("quoted value: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDotEnvMissingFile(t *testing.T) {
|
||||||
|
if err := LoadDotEnv(filepath.Join(t.TempDir(), "missing")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.michelsen.id/phill/preface-tools/internal/auth"
|
||||||
|
"git.michelsen.id/phill/preface-tools/internal/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testTool struct{}
|
||||||
|
|
||||||
|
func (testTool) Definition() tools.Definition { return tools.Definition{Key: "test", Name: "Test"} }
|
||||||
|
func (testTool) StudentHandler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`<section id="tool-fragment">Tool</section>`))
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
func (testTool) InstructorHandler() http.Handler { return testTool{}.StudentHandler() }
|
||||||
|
|
||||||
|
func TestNewWithToolRoutesDoesNotPanic(t *testing.T) {
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
if err := registry.Register(testTool{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
authService := auth.New("student", "instructor", string(make([]byte, 32)), time.Hour, false)
|
||||||
|
server := New(authService, registry, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
if server.Handler() == nil {
|
||||||
|
t.Fatal("missing handler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolRootUsesApplicationShell(t *testing.T) {
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
if err := registry.Register(testTool{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
authService := auth.New("student", "instructor", string(make([]byte, 32)), time.Hour, false)
|
||||||
|
claims, err := authService.Authenticate(auth.Student, "student", "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := New(authService, registry, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/tools/test/", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: auth.CookieName, Value: authService.Sign(claims)})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(response, request)
|
||||||
|
for _, expected := range []string{"<!doctype html>", `data-theme="silk"`, "daisyui@5.6.3", "Preface Tools", `id="tool-fragment"`} {
|
||||||
|
if !strings.Contains(response.Body.String(), expected) {
|
||||||
|
t.Errorf("rendered tool page missing %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
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
|
VideoDuration int
|
||||||
GenerateAudio bool
|
GenerateAudio bool
|
||||||
PollInterval, JobTimeout, HTTPTimeout, SignedURLTTL time.Duration
|
PollInterval, JobTimeout, HTTPTimeout, SignedURLTTL time.Duration
|
||||||
@@ -17,7 +17,7 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfigFromEnv() (Config, error) {
|
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
|
var err error
|
||||||
c.VideoDuration, err = intenv("COMIC_ANIMATOR_VIDEO_DURATION", 6)
|
c.VideoDuration, err = intenv("COMIC_ANIMATOR_VIDEO_DURATION", 6)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ type Generation struct {
|
|||||||
ID, SessionID, UploadID, OriginalName, ReviewedPrompt, ProviderJobID string
|
ID, SessionID, UploadID, OriginalName, ReviewedPrompt, ProviderJobID string
|
||||||
Status GenerationStatus
|
Status GenerationStatus
|
||||||
Duration int
|
Duration int
|
||||||
Resolution, AspectRatio, OutputPath, OutputMIMEType string
|
Resolution, OutputPath, OutputMIMEType string
|
||||||
OutputSize int64
|
OutputSize int64
|
||||||
ErrorCode, ErrorMessage string
|
ErrorCode, ErrorMessage string
|
||||||
CreatedAt, UpdatedAt time.Time
|
CreatedAt, UpdatedAt time.Time
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ type VideoRequest struct {
|
|||||||
Prompt string `json:"prompt"`
|
Prompt string `json:"prompt"`
|
||||||
Duration int `json:"duration"`
|
Duration int `json:"duration"`
|
||||||
Resolution string `json:"resolution"`
|
Resolution string `json:"resolution"`
|
||||||
AspectRatio string `json:"aspect_ratio"`
|
|
||||||
GenerateAudio bool `json:"generate_audio"`
|
GenerateAudio bool `json:"generate_audio"`
|
||||||
FrameImages []FrameImage `json:"frame_images"`
|
FrameImages []FrameImage `json:"frame_images"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ func New(cfg Config, log *slog.Logger, approval ApprovalVerifier) (*Tool, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
t := &Tool{cfg: cfg, log: log, approval: approval, store: newStore(), queue: make(chan string, cfg.QueueCapacity)}
|
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.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}}
|
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()
|
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 }
|
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) {
|
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) {
|
func (t *Tool) upload(w http.ResponseWriter, r *http.Request) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, t.cfg.MaxUploadBytes+1<<20)
|
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()}
|
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)
|
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 {
|
func validateWebP(head []byte) error {
|
||||||
@@ -201,6 +219,12 @@ func (t *Tool) prompt(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
dataURL := "data:" + u.MIMEType + ";base64," + base64.StdEncoding.EncodeToString(b)
|
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)
|
ctx, cancel := context.WithTimeout(r.Context(), t.cfg.HTTPTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
result, err := t.client.Prompt(ctx, t.cfg.PromptModel, desc, dataURL, systemPrompt)
|
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.")
|
fragmentError(w, 502, "Generated prompt was too long.")
|
||||||
return
|
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) {
|
func (t *Tool) submit(w http.ResponseWriter, r *http.Request) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 128<<10)
|
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"))
|
u, err := t.ownedUpload(r, r.FormValue("upload_id"))
|
||||||
prompt := strings.TrimSpace(r.FormValue("reviewed_prompt"))
|
prompt := strings.TrimSpace(r.FormValue("reviewed_prompt"))
|
||||||
duration, errDuration := strconv.Atoi(r.FormValue("duration"))
|
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 {
|
||||||
if err != nil || prompt == "" || len(prompt) > 12000 || errDuration != nil || duration != t.cfg.VideoDuration || aspect != t.cfg.VideoAspectRatio {
|
|
||||||
fragmentError(w, 400, "Check the image, prompt, and settings.")
|
fragmentError(w, 400, "Check the image, prompt, and settings.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
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)
|
t.store.putGeneration(g)
|
||||||
select {
|
select {
|
||||||
case t.queue <- g.ID:
|
case t.queue <- g.ID:
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package comicanimator
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -40,7 +42,7 @@ func TestValidateWebP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTemplatesExecute(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)
|
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 {
|
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) {
|
func TestVideoSignatures(t *testing.T) {
|
||||||
mp4 := append([]byte{0, 0, 0, 16}, []byte("ftypisom")...)
|
mp4 := append([]byte{0, 0, 0, 16}, []byte("ftypisom")...)
|
||||||
if !validVideoHeader("video/mp4", mp4) {
|
if !validVideoHeader("video/mp4", mp4) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -38,7 +38,7 @@ func (t *Tool) work(appctx context.Context, id string) {
|
|||||||
t.store.update(id, func(x *Generation) { x.Status = Submitting })
|
t.store.update(id, func(x *Generation) { x.Status = Submitting })
|
||||||
ctx, cancel := context.WithTimeout(appctx, t.cfg.JobTimeout)
|
ctx, cancel := context.WithTimeout(appctx, t.cfg.JobTimeout)
|
||||||
defer cancel()
|
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 {
|
if err != nil {
|
||||||
t.fail(id, "provider_submission_failed", "The video provider could not start this generation.", err)
|
t.fail(id, "provider_submission_failed", "The video provider could not start this generation.", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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>"}
|
||||||
Reference in New Issue
Block a user