109 lines
6.1 KiB
Go
109 lines
6.1 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, `<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>`
|