6cf8716d7e
Build backend images / build content-svc (push) Failing after 13s
Build backend images / build file-svc (push) Failing after 53s
Build backend images / build gateway (push) Failing after 1m22s
Build backend images / build identity-svc (push) Failing after 19s
Build backend images / build notification-svc (push) Failing after 21s
Build backend images / build render-svc (push) Failing after 20s
Build backend images / build studio-svc (push) Failing after 1m6s
C2 — real-AE scene snapshots on the node:
- node-agent: runner/snapshot.go RunSnapshot (aerender -comp <key> -s f -e f
→ findRenderedOutput → ffmpeg -frames:v 1 PNG); client ClaimSnapshot /
GetSnapshotUploadURL / ReportSnapshotResult / ReportSnapshotFail; snapshotLoop +
pollSnapshotOnce mirroring the scan loop (reuses the AE-exclusive lock).
- render-svc: GetSnapshotJobMeta + UploadURL handler presigns a PUT to the
public-read user-uploads bucket at snapshots/{project}/{scene}.png and returns a
permanent public_url (not the time-limited export presign); MINIO_UPLOAD_BUCKET +
MINIO_PUBLIC_URL config + compose env + /snapshot/:id/upload-url route.
Epic B — bind edited colours into the render:
- render-svc GetRenderBindings UNIONs studio.saved_shared_colors +
saved_scene_colors (type 'color') so the node writes them before render.
- node-agent binder.go routes type:"color" bindings into the bind-spec colors[]
array that bind.jsx already applies to the frshare colour layers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
211 lines
5.8 KiB
Go
211 lines
5.8 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
//go:embed bind.jsx
|
|
var bindScript []byte
|
|
|
|
func isMediaBinding(t string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(t)) {
|
|
case "media", "image", "video":
|
|
return true
|
|
case "audio", "voiceover":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func bindMediaType(t string) string {
|
|
switch strings.ToLower(strings.TrimSpace(t)) {
|
|
case "audio", "voiceover":
|
|
return "audio"
|
|
default:
|
|
return "media"
|
|
}
|
|
}
|
|
|
|
func mediaExt(url string) string {
|
|
u := url
|
|
if i := strings.IndexAny(u, "?#"); i >= 0 {
|
|
u = u[:i]
|
|
}
|
|
ext := filepath.Ext(u)
|
|
if ext == "" || len(ext) > 6 {
|
|
return ".bin"
|
|
}
|
|
return ext
|
|
}
|
|
|
|
func downloadFile(ctx context.Context, url, dst string) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("download %s: HTTP %d", url, resp.StatusCode)
|
|
}
|
|
f, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
_, err = io.Copy(f, resp.Body)
|
|
return err
|
|
}
|
|
|
|
// ── bind-spec (matches bind.jsx) ──────────────────────────────────────────────
|
|
type bindLayerSpec struct {
|
|
Key string `json:"key"`
|
|
Type string `json:"type"` // "text" | "media" | "audio"
|
|
Value string `json:"value"`
|
|
}
|
|
type bindColorSpec struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
}
|
|
type bindSpec struct {
|
|
Comp string `json:"comp,omitempty"`
|
|
Fps int `json:"fps,omitempty"`
|
|
Colors []bindColorSpec `json:"colors,omitempty"`
|
|
Layers []bindLayerSpec `json:"layers"`
|
|
}
|
|
|
|
// RunBinder writes the user's edited input values into the template (text layers'
|
|
// source text, media footage replacement) via bind.jsx, saving a "bound" .aep that
|
|
// aerender then renders. Returns the path to the bound .aep. Uses afterfx.exe.
|
|
//
|
|
// Media values are URLs; they are downloaded locally first (the JSX imports them by
|
|
// path). Text values are passed through the JSON bind-spec (json-escaped, never eval'd
|
|
// as code — bind.jsx eval()s valid JSON only).
|
|
func RunBinder(ctx context.Context, job *Job, workDir string) (string, error) {
|
|
if job.AEPFilePath == "" {
|
|
return "", fmt.Errorf("binder: empty aep path")
|
|
}
|
|
if job.AfterFxPath == "" {
|
|
return "", fmt.Errorf("binder: empty afterfx path")
|
|
}
|
|
|
|
dir := filepath.Join(workDir, "bind", job.JobID)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
spec := bindSpec{Comp: job.CompName, Fps: job.FrameRate, Layers: []bindLayerSpec{}}
|
|
for i, b := range job.Bindings {
|
|
key := strings.TrimSpace(b.Key)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
// Colours bind to frshare/frd_* text layers (hex source text) — bind.jsx
|
|
// propagates them via template expressions, not as visible layer content.
|
|
if strings.EqualFold(strings.TrimSpace(b.Type), "color") {
|
|
if strings.TrimSpace(b.Value) == "" {
|
|
continue
|
|
}
|
|
spec.Colors = append(spec.Colors, bindColorSpec{Key: key, Value: b.Value})
|
|
continue
|
|
}
|
|
if isMediaBinding(b.Type) {
|
|
if strings.TrimSpace(b.Value) == "" {
|
|
continue
|
|
}
|
|
mp := filepath.Join(dir, fmt.Sprintf("m%d%s", i, mediaExt(b.Value)))
|
|
if err := downloadFile(ctx, b.Value, mp); err != nil {
|
|
log.Printf("[bind] media download failed for %s: %v", key, err)
|
|
continue
|
|
}
|
|
spec.Layers = append(spec.Layers, bindLayerSpec{Key: key, Type: bindMediaType(b.Type), Value: mp})
|
|
} else {
|
|
spec.Layers = append(spec.Layers, bindLayerSpec{Key: key, Type: "text", Value: b.Value})
|
|
}
|
|
}
|
|
|
|
specBytes, err := json.Marshal(spec)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
specPath := filepath.Join(dir, "spec.json")
|
|
if err := os.WriteFile(specPath, specBytes, 0o644); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Save the bound .aep next to the original so its relative footage/fonts resolve.
|
|
savePath := filepath.Join(filepath.Dir(job.AEPFilePath), "bound.aep")
|
|
donePath := savePath + ".done"
|
|
errPath := savePath + ".error"
|
|
_ = os.Remove(donePath)
|
|
_ = os.Remove(errPath)
|
|
_ = os.Remove(savePath)
|
|
|
|
scriptPath := filepath.Join(workDir, "scripts", "bind.jsx")
|
|
if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.WriteFile(scriptPath, bindScript, 0o644); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
PrepareFreshAE() // kill stale AE + clear crash markers before launch
|
|
|
|
bctx, cancel := context.WithTimeout(ctx, 12*time.Minute)
|
|
defer cancel()
|
|
|
|
// AE opens the project itself (bind.jsx app.open); launch with -r script.
|
|
cmd := exec.CommandContext(bctx, job.AfterFxPath, "-r", scriptPath)
|
|
cmd.Env = append(os.Environ(),
|
|
"FR_BIND_AEP="+job.AEPFilePath,
|
|
"FR_BIND_SPEC="+specPath,
|
|
"FR_BIND_SAVE="+savePath,
|
|
)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Start(); err != nil {
|
|
return "", fmt.Errorf("start afterfx: %w", err)
|
|
}
|
|
exited := make(chan error, 1)
|
|
go func() { exited <- cmd.Wait() }()
|
|
|
|
for {
|
|
if eb, rerr := os.ReadFile(errPath); rerr == nil && len(eb) > 0 {
|
|
_ = cmd.Process.Kill()
|
|
return "", fmt.Errorf("binder script error: %s", string(eb))
|
|
}
|
|
if st, serr := os.Stat(savePath); serr == nil && st.Size() > 0 {
|
|
if _, derr := os.Stat(donePath); derr == nil {
|
|
_ = cmd.Process.Kill()
|
|
log.Printf("[bind] bound %d input(s) → %s", len(spec.Layers), savePath)
|
|
return savePath, nil
|
|
}
|
|
}
|
|
select {
|
|
case <-exited:
|
|
if st, serr := os.Stat(savePath); serr == nil && st.Size() > 0 {
|
|
return savePath, nil
|
|
}
|
|
return "", fmt.Errorf("binder exited without producing %s", filepath.Base(savePath))
|
|
case <-time.After(2 * time.Second):
|
|
case <-bctx.Done():
|
|
_ = cmd.Process.Kill()
|
|
return "", bctx.Err()
|
|
}
|
|
}
|
|
}
|