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

167 lines
5.5 KiB
Go

package comicanimator
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.michelsen.id/phill/preface-tools/internal/tools/comicanimator/openrouter"
)
func (t *Tool) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return nil
case gid := <-t.queue:
func() {
defer func() {
if v := recover(); v != nil {
t.fail(gid, "internal_error", "Generation failed unexpectedly.", fmt.Errorf("panic: %v", v))
}
}()
t.work(ctx, gid)
}()
}
}
}
func (t *Tool) work(appctx context.Context, id string) {
g, ok := t.store.generation(id)
if !ok {
return
}
t.store.update(id, func(x *Generation) { x.Status = Submitting })
ctx, cancel := context.WithTimeout(appctx, t.cfg.JobTimeout)
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"}}})
if err != nil {
t.fail(id, "provider_submission_failed", "The video provider could not start this generation.", err)
return
}
if job.ID == "" {
t.fail(id, "provider_submission_failed", "The video provider returned an invalid response.", fmt.Errorf("missing job id"))
return
}
t.store.update(id, func(x *Generation) { x.ProviderJobID = job.ID; x.Status = Pending })
for job.Status != "completed" {
if job.Status == "failed" || job.Status == "cancelled" || job.Status == "expired" {
t.fail(id, "provider_generation_failed", "The video provider could not complete this generation.", fmt.Errorf("provider status %s", job.Status))
return
}
timer := time.NewTimer(t.cfg.PollInterval)
select {
case <-ctx.Done():
timer.Stop()
code := "provider_timeout"
msg := "The video generation timed out."
if appctx.Err() != nil {
code = "worker_shutdown"
msg = "Generation stopped because the application shut down."
}
t.fail(id, code, msg, ctx.Err())
return
case <-timer.C:
}
job, err = t.client.Poll(ctx, job.ID)
if err != nil {
t.fail(id, "provider_poll_failed", "Could not check the video status.", err)
return
}
t.store.update(id, func(x *Generation) {
if job.Status == "in_progress" {
x.Status = InProgress
} else {
x.Status = Pending
}
})
}
t.store.update(id, func(x *Generation) { x.Status = Downloading })
resp, err := t.client.Content(ctx, job.ID)
if err != nil {
t.fail(id, "provider_download_failed", "Could not download the completed video.", err)
return
}
defer resp.Body.Close()
mt := strings.Split(resp.Header.Get("Content-Type"), ";")[0]
if mt != "video/mp4" && mt != "video/webm" && mt != "video/quicktime" {
t.fail(id, "generated_video_invalid", "The provider returned an invalid video.", fmt.Errorf("content type %q", mt))
return
}
ext := map[string]string{"video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov"}[mt]
header := make([]byte, 16)
hn, readErr := io.ReadFull(resp.Body, header)
if readErr != nil && readErr != io.ErrUnexpectedEOF {
t.fail(id, "provider_download_failed", "Could not download the completed video.", readErr)
return
}
if !validVideoHeader(mt, header[:hn]) {
t.fail(id, "generated_video_invalid", "The provider returned an invalid video.", fmt.Errorf("invalid %s signature", mt))
return
}
tmp, err := os.CreateTemp(t.cfg.OutputDirectory, ".video-*")
if err != nil {
t.fail(id, "output_write_failed", "Could not save the video.", err)
return
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
n, copyErr := io.Copy(tmp, io.LimitReader(io.MultiReader(bytes.NewReader(header[:hn]), resp.Body), t.cfg.MaxGeneratedVideoBytes+1))
syncErr := tmp.Sync()
closeErr := tmp.Close()
if copyErr != nil || syncErr != nil || closeErr != nil {
t.fail(id, "output_write_failed", "Could not save the video.", errorsJoin(copyErr, syncErr, closeErr))
return
}
if n > t.cfg.MaxGeneratedVideoBytes {
t.fail(id, "generated_video_too_large", "The generated video was too large.", fmt.Errorf("%d bytes", n))
return
}
now := time.Now().UTC()
name := now.Format("20060102T150405Z") + "_" + id + ext
dest := filepath.Join(t.cfg.OutputDirectory, name)
if err = os.Rename(tmpName, dest); err != nil {
t.fail(id, "output_write_failed", "Could not finalize the video.", err)
return
}
t.store.update(id, func(x *Generation) {
x.Status = Completed
x.OutputPath = dest
x.OutputMIMEType = mt
x.OutputSize = n
x.CompletedAt = &now
})
t.log.Info("video generation completed", "tool", "comic-animator", "generation_id", id, "provider_job_id", job.ID, "file_size", n)
}
func validVideoHeader(mt string, head []byte) bool {
switch mt {
case "video/mp4", "video/quicktime":
return len(head) >= 12 && string(head[4:8]) == "ftyp"
case "video/webm":
return len(head) >= 4 && head[0] == 0x1a && head[1] == 0x45 && head[2] == 0xdf && head[3] == 0xa3
default:
return false
}
}
func errorsJoin(errs ...error) error {
parts := []string{}
for _, e := range errs {
if e != nil {
parts = append(parts, e.Error())
}
}
if len(parts) == 0 {
return nil
}
return fmt.Errorf("%s", strings.Join(parts, "; "))
}
func (t *Tool) fail(id, code, message string, err error) {
t.store.update(id, func(x *Generation) { x.Status = Failed; x.ErrorCode = code; x.ErrorMessage = message })
t.log.Error("video generation failed", "tool", "comic-animator", "generation_id", id, "error_code", code, "error", err)
}