Files
preface-tools/internal/tools/comicanimator/views.go
T
2026-07-12 02:56:51 +08:00

109 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package comicanimator
import (
"fmt"
"html/template"
"mime"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
func render(w http.ResponseWriter, src string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := template.Must(template.New("page").Parse(src)).Execute(w, data); err != nil {
http.Error(w, "render error", 500)
}
}
func fragmentError(w http.ResponseWriter, status int, message string) {
w.WriteHeader(status)
fmt.Fprintf(w, `<p class="error" role="alert">%s</p>`, 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, `<article class="generation"%s><div><strong>%s</strong><small>%s</small></div>`, poll, template.HTMLEscapeString(strings.ReplaceAll(string(g.Status), "_", " ")), g.CreatedAt.Format(time.RFC3339))
if instructor {
fmt.Fprintf(w, `<details><summary>Reviewed prompt</summary><p>%s</p></details>`, template.HTMLEscapeString(g.ReviewedPrompt))
if g.ProviderJobID != "" {
fmt.Fprintf(w, `<small>Provider job: %s</small>`, template.HTMLEscapeString(g.ProviderJobID))
}
}
if g.Status == Completed {
prefix := ""
if instructor {
prefix = "generations/"
}
fmt.Fprintf(w, `<video controls preload="metadata" src="%s%s/video"></video><a class="btn" href="%s%s/download">Download</a>`, prefix, g.ID, prefix, g.ID)
} else if g.Status == Failed {
fmt.Fprintf(w, `<p class="error">%s</p>`, template.HTMLEscapeString(g.ErrorMessage))
} else {
fmt.Fprint(w, `<p class="muted">Working…</p>`)
}
fmt.Fprint(w, "</article>")
}
func (t *Tool) instructorPage(w http.ResponseWriter, r *http.Request) {
render(w, instructorPage, map[string]any{"Outputs": t.outputs()})
}
func (t *Tool) instructorGenerations(w http.ResponseWriter, r *http.Request) {
for _, g := range t.store.list("") {
t.renderCard(w, g, true)
}
}
func (t *Tool) instructorVideo(w http.ResponseWriter, r *http.Request) {
t.generationFile(w, r, true, false)
}
func (t *Tool) instructorDownload(w http.ResponseWriter, r *http.Request) {
t.generationFile(w, r, true, true)
}
type outputFile struct {
Name string
Size int64
Modified time.Time
}
func (t *Tool) outputs() []outputFile {
entries, err := os.ReadDir(t.cfg.OutputDirectory)
if err != nil {
return nil
}
out := []outputFile{}
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".mp4" && ext != ".webm" && ext != ".mov" {
continue
}
info, err := e.Info()
if err == nil {
out = append(out, outputFile{Name: e.Name(), Size: info.Size(), Modified: info.ModTime()})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Modified.After(out[j].Modified) })
return out
}
func (t *Tool) outputDownload(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("filename")
if name != filepath.Base(name) {
http.NotFound(w, r)
return
}
path := filepath.Join(t.cfg.OutputDirectory, name)
if _, err := os.Stat(path); err != nil {
http.NotFound(w, r)
return
}
serveFile(w, r, path, mime.TypeByExtension(filepath.Ext(name)), r.URL.Query().Get("preview") != "1")
}
const toolPage = `<section class="tool-grid"><article class="card"><h2>1. Source image</h2><form id="upload-form" hx-post="uploads" hx-target="#upload-preview" hx-encoding="multipart/form-data"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="file" name="image" accept="image/png,image/jpeg,image/webp" required><button class="btn" type="submit">Upload image</button></form><div id="upload-preview"></div></article><article class="card"><h2>2. Describe motion</h2><form id="comic-form"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><label for="description">Describe the story and movement</label><textarea id="description" name="description" rows="12" required placeholder="Explain the panels in order, character actions, expressions, environmental movement, timing, and anything that must stay unchanged."></textarea><button class="btn" type="button" hx-post="prompt" hx-include="#comic-form,#upload-preview" hx-target="#prompt-result" hx-indicator="#prompt-spinner">Generate Video Prompt</button><span id="prompt-spinner" class="htmx-indicator">Working…</span><div id="prompt-result"></div></form></article><article class="card"><h2>4. Generate</h2><label>Duration</label><input form="comic-form" name="duration" value="{{.Duration}}" readonly><label>Aspect ratio</label><select form="comic-form" name="aspect_ratio"><option>{{.AspectRatio}}</option></select><button class="btn primary" type="button" data-open-approval>Generate Video</button></article></section><section class="card recent"><h2>Recent animations</h2><div id="generation-list" hx-get="generations" hx-trigger="load"></div></section><dialog id="approval-dialog" class="dialog"><form method="dialog"><button aria-label="Close">×</button></form><h2>Instructor approval</h2><p>An instructor PIN is required for this paid generation.</p><form id="generation-form" hx-post="generations" hx-include="#comic-form,#upload-preview" hx-target="#generation-list" hx-swap="afterbegin"><input type="password" name="instructor_pin" autocomplete="off" required><div id="approval-error"></div><button type="button" data-close-approval>Cancel</button><button class="btn primary" type="submit">Approve and Generate</button></form></dialog>`
const instructorPage = `<section class="card"><h2>Current process generations</h2><div hx-get="generations" hx-trigger="load, every 10s"></div></section><section class="card recent"><h2>Downloaded output files</h2><p class="muted">Files survive restarts; this is not a complete audit log.</p>{{range .Outputs}}<article class="generation"><strong>{{.Name}}</strong><small>{{.Modified.UTC.Format "2006-01-02 15:04:05Z"}} · {{.Size}} bytes</small><video controls preload="metadata" src="outputs/{{.Name}}/download?preview=1"></video><a class="btn" href="outputs/{{.Name}}/download">Download</a></article>{{else}}<p class="muted">No downloaded videos yet.</p>{{end}}</section>`