113 lines
11 KiB
Go
113 lines
11 KiB
Go
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>`
|