Files
preface-tools/internal/tools/comicanimator/views.go
T
2026-07-13 01:26:49 +08:00

113 lines
11 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, `<div class="alert alert-error" role="alert"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 9v4m0 4h.01M10.3 3.7 2.4 17.4A2 2 0 0 0 4.1 20h15.8a2 2 0 0 0 1.7-2.6L13.7 3.7a2 2 0 0 0-3.4 0Z"/></svg><span>%s</span></div>`, 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"`
}
statusClass := "badge-info"
if g.Status == Completed {
statusClass = "badge-success"
}
if g.Status == Failed {
statusClass = "badge-error"
}
fmt.Fprintf(w, `<article class="generation"%s><div class="generation-header"><span class="badge badge-soft %s status-badge">%s</span><small>%s</small></div>`, poll, statusClass, template.HTMLEscapeString(strings.ReplaceAll(string(g.Status), "_", " ")), g.CreatedAt.Format("02 Jan 2006 · 15:04"))
if instructor {
fmt.Fprintf(w, `<details class="collapse collapse-arrow"><summary class="collapse-title">Reviewed prompt</summary><div class="collapse-content"><p>%s</p></div></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 := "generations/"
fmt.Fprintf(w, `<div class="generation-body"><video controls preload="metadata" src="%s%s/video"></video></div><div class="generation-actions"><a class="btn btn-primary btn-sm" href="%s%s/download"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M4 19h16"/></svg>Download video</a></div>`, prefix, g.ID, prefix, g.ID)
} else if g.Status == Failed {
fmt.Fprintf(w, `<div class="generation-body"><div class="alert alert-error"><span>%s</span></div></div>`, template.HTMLEscapeString(g.ErrorMessage))
} else {
fmt.Fprint(w, `<div class="generation-body working-state"><span class="loading loading-spinner loading-sm"></span><span>Creating your animation…</span></div>`)
}
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="workspace-heading"><div><div class="workspace-kicker"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Z"/></svg>Comic Animator</div><h1>Bring a comic page to life</h1><p>Upload a page, describe the motion, then review the animation prompt.</p></div><div class="workflow-steps" aria-label="Workflow"><span class="workflow-step"><b>1</b>Upload</span><span class="workflow-step"><b>2</b>Describe</span><span class="workflow-step"><b>3</b>Review</span><span class="workflow-step"><b>4</b>Generate</span></div></section><section class="tool-grid"><article class="card workspace-card"><div class="card-body"><div class="card-heading"><span class="step-number">01</span><div><h2>Source image</h2><p>Upload one complete comic page</p></div></div><div class="upload-zone"><form id="upload-form" class="upload-empty" hx-post="uploads" hx-target="closest .upload-zone" hx-encoding="multipart/form-data"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><div class="upload-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 16V4m0 0L8 8m4-4 4 4M4 15v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4"/></svg></div><strong>Choose your comic page</strong><p>PNG, JPEG or WebP · up to 20 MB</p><input class="file-input file-input-bordered file-input-sm" type="file" name="image" accept="image/png,image/jpeg,image/webp" required><button class="btn btn-outline btn-sm" type="submit"><span class="htmx-indicator loading loading-spinner loading-xs"></span>Upload image</button></form></div></div></article><article class="card workspace-card"><div class="card-body"><div class="card-heading"><span class="step-number">02</span><div><h2>Describe the movement</h2><p>Tell the animator what should move</p></div></div><form id="comic-form" class="motion-form"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="duration" value="{{.Duration}}"><textarea class="textarea textarea-bordered" id="description" name="description" required placeholder="Describe the panels in order. Mention character actions, expressions, environmental movement and timing. Be clear about anything that must stay unchanged."></textarea><p class="helper-copy">Tip: subtle, specific movement usually works best for a full comic page.</p><div class="prompt-actions"><span class="muted">The prompt remains fully editable.</span><button class="btn btn-secondary btn-sm" type="button" hx-post="prompt" hx-include="#comic-form,#upload-preview" hx-target="#prompt-result" hx-indicator="#prompt-spinner"><span id="prompt-spinner" class="htmx-indicator loading loading-spinner loading-xs"></span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Zm7 11 .8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14Z"/></svg>Generate prompt</button></div><div id="prompt-result" class="prompt-result"></div></form></div></article><article class="card workspace-card generate-card"><div class="card-body"><div class="card-heading"><span class="step-number">04</span><div><h2>Generate video</h2><p>Review the settings and request approval</p></div></div><div class="settings-list"><div class="setting-row"><span>Duration</span><strong>{{.Duration}} seconds</strong></div><div class="setting-row"><span>Audio</span><span class="badge badge-ghost">Off</span></div><div class="setting-row"><span>Source</span><span class="badge badge-soft badge-primary">First frame</span></div></div><div class="cost-note"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 8v4l2 2m7-2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg><span>Video generation is a paid action and requires an instructor PIN.</span></div><button class="btn btn-primary btn-block" type="button" data-open-approval><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m14.75 5.5 3.75 3.75M4 20l4.2-1 10.3-10.3a2.65 2.65 0 0 0-3.75-3.75L4.45 15.25 4 20Z"/></svg>Generate video</button></div></article></section><section class="recent-section"><div class="section-heading"><div><h2>Recent animations</h2><p>Animations created in this browser session</p></div><span class="badge badge-ghost">Current session</span></div><div id="generation-list" class="history-shell" hx-get="generations" hx-trigger="load"></div></section><dialog id="approval-dialog" class="modal"><div class="modal-box"><div class="modal-header"><div><span class="eyebrow">Final approval</span><h2>Instructor PIN required</h2><p>Approve this paid video generation.</p></div><button class="btn btn-circle btn-ghost btn-sm" type="button" data-close-approval aria-label="Close">×</button></div><form id="generation-form" class="modal-content" hx-post="generations" hx-include="#comic-form,#upload-preview" hx-target="#generation-list" hx-swap="afterbegin"><label class="form-control"><span class="label-text">Instructor PIN</span><input class="input input-bordered" type="password" name="instructor_pin" inputmode="numeric" autocomplete="off" placeholder="Enter instructor PIN" required></label><div id="approval-error"></div><div class="modal-action"><button class="btn btn-ghost" type="button" data-close-approval>Cancel</button><button class="btn btn-primary" type="submit"><span class="htmx-indicator loading loading-spinner loading-xs"></span>Approve and generate</button></div></form></div><form method="dialog" class="modal-backdrop"><button aria-label="Close dialog">close</button></form></dialog>`
const instructorPage = `<section class="workspace-heading"><div><div class="workspace-kicker">Instructor console</div><h1>Generation recovery</h1><p>Monitor this process and recover completed files retained on disk.</p></div></section><div class="instructor-grid"><section class="card instructor-panel"><div class="card-body"><div class="section-heading"><div><h2>Current process</h2><p>Running and completed generations since startup</p></div><span class="badge badge-soft badge-info">Live</span></div><div class="history-shell" hx-get="generations" hx-trigger="load, every 10s"></div></div></section><section class="card instructor-panel"><div class="card-body"><div class="section-heading"><div><h2>Downloaded files</h2><p>Restart-safe outputs retained on local storage</p></div><span class="badge badge-ghost">Not an audit log</span></div><div class="output-grid">{{range .Outputs}}<article class="output-card"><video controls preload="metadata" src="outputs/{{.Name}}/download?preview=1"></video><div class="output-meta"><strong title="{{.Name}}">{{.Name}}</strong><small>{{.Modified.UTC.Format "02 Jan 2006 · 15:04 UTC"}} · {{.Size}} bytes</small><a class="btn btn-outline btn-sm" href="outputs/{{.Name}}/download"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M4 19h16"/></svg>Download</a></div></article>{{else}}<div class="history-shell"></div>{{end}}</div></div></section></div>`