@machinal/scenes 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/LICENSE +201 -0
- package/LICENSING.md +13 -0
- package/NOTICE +8 -0
- package/README.md +183 -2
- package/bin/machinal-scenes.mjs +89 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/runtime/artifact.d.ts +18 -0
- package/dist/runtime/artifact.js +26 -0
- package/dist/runtime/bridge.d.ts +8 -0
- package/dist/runtime/bridge.js +76 -0
- package/dist/runtime/choreography.d.ts +32 -0
- package/dist/runtime/choreography.js +81 -0
- package/dist/runtime/controller.d.ts +5 -0
- package/dist/runtime/controller.js +93 -0
- package/dist/runtime/geometry.d.ts +20 -0
- package/dist/runtime/geometry.js +34 -0
- package/dist/runtime/index.d.ts +14 -0
- package/dist/runtime/index.js +14 -0
- package/dist/runtime/layout.d.ts +17 -0
- package/dist/runtime/layout.js +48 -0
- package/dist/runtime/math.d.ts +4 -0
- package/dist/runtime/math.js +23 -0
- package/dist/runtime/media.d.ts +2 -0
- package/dist/runtime/media.js +69 -0
- package/dist/runtime/models.d.ts +14 -0
- package/dist/runtime/models.js +370 -0
- package/dist/runtime/objects.d.ts +8 -0
- package/dist/runtime/objects.js +24 -0
- package/dist/runtime/parameters.d.ts +17 -0
- package/dist/runtime/parameters.js +35 -0
- package/dist/runtime/resources.d.ts +5 -0
- package/dist/runtime/resources.js +63 -0
- package/dist/runtime/types.d.ts +76 -0
- package/dist/runtime/types.js +1 -0
- package/dist/runtime/validation.d.ts +4 -0
- package/dist/runtime/validation.js +37 -0
- package/dist/runtime/video.d.ts +90 -0
- package/dist/runtime/video.js +46 -0
- package/dist/svelte/SceneCanvas.svelte +15 -0
- package/dist/svelte/SceneCanvas.svelte.d.ts +8 -0
- package/dist/svelte/SceneThree.svelte +61 -0
- package/dist/svelte/SceneThree.svelte.d.ts +10 -0
- package/dist/svelte/SceneVideo.svelte +38 -0
- package/dist/svelte/SceneVideo.svelte.d.ts +18 -0
- package/dist/svelte/context.d.ts +12 -0
- package/dist/svelte/context.js +8 -0
- package/dist/svelte/index.d.ts +9 -0
- package/dist/svelte/index.js +8 -0
- package/dist/svelte/model-assets.d.ts +4 -0
- package/dist/svelte/model-assets.js +42 -0
- package/dist/svelte/mount.svelte.d.ts +3 -0
- package/dist/svelte/mount.svelte.js +42 -0
- package/dist/svelte/three-rigs.d.ts +20 -0
- package/dist/svelte/three-rigs.js +22 -0
- package/dist/svelte/three-session.d.ts +6 -0
- package/dist/svelte/three-session.js +66 -0
- package/dist/svelte/three.d.ts +15 -0
- package/dist/svelte/three.js +33 -0
- package/examples/quickstart/gitignore +6 -0
- package/examples/quickstart/index.html +48 -0
- package/examples/quickstart/package.json +27 -0
- package/examples/quickstart/scene.config.json +11 -0
- package/examples/quickstart/scene.html +13 -0
- package/examples/quickstart/src/ExportPanel.svelte +74 -0
- package/examples/quickstart/src/Title.svelte +22 -0
- package/examples/quickstart/src/export-api.ts +30 -0
- package/examples/quickstart/src/main.ts +114 -0
- package/examples/quickstart/src/playback.ts +6 -0
- package/examples/quickstart/src/request.ts +5 -0
- package/examples/quickstart/src/scene.ts +12 -0
- package/examples/quickstart/src/useLocalExport.svelte.ts +46 -0
- package/examples/quickstart/src/workspace.css +79 -0
- package/examples/quickstart/svelte.config.js +1 -0
- package/examples/quickstart/tools/local-export.mjs +120 -0
- package/examples/quickstart/tsconfig.json +15 -0
- package/examples/quickstart/vite.config.js +17 -0
- package/native/THIRD_PARTY_NOTICES.txt +158 -0
- package/native/darwin-arm64/scene-render +0 -0
- package/native/darwin-x64/scene-render +0 -0
- package/native/linux-x64/scene-render +0 -0
- package/native/manifest.json +33 -0
- package/native/win32-x64/scene-render.exe +0 -0
- package/node/cli-output.mjs +10 -0
- package/node/download.mjs +37 -0
- package/node/ffmpeg-releases.mjs +24 -0
- package/node/index.d.ts +50 -0
- package/node/index.mjs +2 -0
- package/node/platforms.mjs +14 -0
- package/node/render.mjs +59 -0
- package/node/toolchain.mjs +118 -0
- package/package.json +89 -4
- package/renderer/browser.go +116 -0
- package/renderer/browser_log.go +36 -0
- package/renderer/browser_log_test.go +48 -0
- package/renderer/browser_test.go +307 -0
- package/renderer/cmd/scene-render/main.go +128 -0
- package/renderer/cmd/scene-render/main_test.go +37 -0
- package/renderer/cmd/scene-render/server.go +42 -0
- package/renderer/encoder.go +116 -0
- package/renderer/encoder_test.go +22 -0
- package/renderer/go.mod +11 -0
- package/renderer/go.sum +10 -0
- package/renderer/network.go +50 -0
- package/renderer/output.go +93 -0
- package/renderer/pipeline.go +108 -0
- package/renderer/pipeline_test.go +108 -0
- package/renderer/render.go +141 -0
- package/renderer/request.go +140 -0
- package/renderer/request_test.go +94 -0
- package/renderer/types.go +113 -0
- package/renderer/worker.go +110 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"net"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/url"
|
|
9
|
+
"os"
|
|
10
|
+
"time"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func serveScene(directory, sceneURL string) (string, func(), error) {
|
|
14
|
+
info, err := os.Stat(directory)
|
|
15
|
+
if err != nil || !info.IsDir() {
|
|
16
|
+
return "", nil, fmt.Errorf("scene build directory is unavailable: %s", directory)
|
|
17
|
+
}
|
|
18
|
+
relative, err := url.Parse(sceneURL)
|
|
19
|
+
if err != nil || relative.IsAbs() || relative.Host != "" {
|
|
20
|
+
return "", nil, fmt.Errorf("with -serve, request URL must be a relative scene path")
|
|
21
|
+
}
|
|
22
|
+
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
23
|
+
if err != nil {
|
|
24
|
+
return "", nil, fmt.Errorf("listen for scene files: %w", err)
|
|
25
|
+
}
|
|
26
|
+
server := &http.Server{Handler: http.FileServer(http.Dir(directory)), ReadHeaderTimeout: 5 * time.Second}
|
|
27
|
+
go func() {
|
|
28
|
+
// Shutdown below is the expected termination path for this private file server.
|
|
29
|
+
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
|
30
|
+
server.Close()
|
|
31
|
+
}
|
|
32
|
+
}()
|
|
33
|
+
base := &url.URL{Scheme: "http", Host: listener.Addr().String(), Path: "/"}
|
|
34
|
+
closeServer := func() {
|
|
35
|
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
36
|
+
defer cancel()
|
|
37
|
+
if err := server.Shutdown(ctx); err != nil {
|
|
38
|
+
server.Close()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return base.ResolveReference(relative).String(), closeServer, nil
|
|
42
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
package sceneengine
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"os/exec"
|
|
9
|
+
"strconv"
|
|
10
|
+
"strings"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
const maxEncoderLogBytes = 32 << 10
|
|
14
|
+
|
|
15
|
+
type encoder struct {
|
|
16
|
+
command *exec.Cmd
|
|
17
|
+
input io.WriteCloser
|
|
18
|
+
log tailLog
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type tailLog struct{ buffer []byte }
|
|
22
|
+
|
|
23
|
+
func (l *tailLog) Write(data []byte) (int, error) {
|
|
24
|
+
count := len(data)
|
|
25
|
+
l.buffer = append(l.buffer, data...)
|
|
26
|
+
if len(l.buffer) > maxEncoderLogBytes {
|
|
27
|
+
l.buffer = bytes.Clone(l.buffer[len(l.buffer)-maxEncoderLogBytes:])
|
|
28
|
+
}
|
|
29
|
+
return count, nil
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func startEncoder(ctx context.Context, options Options, request Request, codec Codec, output string) (*encoder, error) {
|
|
33
|
+
args := encoderArgs(request, codec, output, options)
|
|
34
|
+
process := &encoder{command: exec.CommandContext(ctx, options.FFmpegExecutable, args...)}
|
|
35
|
+
process.command.Stderr = &process.log
|
|
36
|
+
input, err := process.command.StdinPipe()
|
|
37
|
+
if err != nil {
|
|
38
|
+
return nil, fmt.Errorf("open encoder stdin: %w", err)
|
|
39
|
+
}
|
|
40
|
+
process.input = input
|
|
41
|
+
if err := process.command.Start(); err != nil {
|
|
42
|
+
input.Close()
|
|
43
|
+
return nil, fmt.Errorf("start FFmpeg: %w", err)
|
|
44
|
+
}
|
|
45
|
+
return process, nil
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (e *encoder) finish() error {
|
|
49
|
+
closeErr := e.input.Close()
|
|
50
|
+
if err := e.command.Wait(); err != nil {
|
|
51
|
+
return fmt.Errorf("FFmpeg encoding failed: %w\n%s", err, string(e.log.buffer))
|
|
52
|
+
}
|
|
53
|
+
if closeErr != nil {
|
|
54
|
+
return fmt.Errorf("close encoder input: %w", closeErr)
|
|
55
|
+
}
|
|
56
|
+
return nil
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func encoderArgs(request Request, codec Codec, output string, options Options) []string {
|
|
60
|
+
args := []string{"-hide_banner", "-loglevel", "error", "-y", "-f", "image2pipe", "-framerate", decimal(request.Config.FPS), "-vcodec", "png", "-i", "pipe:0"}
|
|
61
|
+
for _, audio := range request.Audio {
|
|
62
|
+
args = append(args, "-i", audio.File)
|
|
63
|
+
}
|
|
64
|
+
if len(request.Audio) > 0 {
|
|
65
|
+
args = append(args, "-filter_complex", audioFilter(request), "-map", "0:v:0", "-map", "[audio]")
|
|
66
|
+
if codec == H264 {
|
|
67
|
+
args = append(args, "-c:a", "aac", "-b:a", "192k")
|
|
68
|
+
} else {
|
|
69
|
+
args = append(args, "-c:a", "libopus", "-b:a", "192k")
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
args = append(args, "-map", "0:v:0", "-an")
|
|
73
|
+
}
|
|
74
|
+
if codec == H264 {
|
|
75
|
+
encoder, crf, pixelFormat := "libx264", "18", "yuv420p"
|
|
76
|
+
if options.Lossless {
|
|
77
|
+
encoder, crf, pixelFormat = "libx264rgb", "0", "rgb24"
|
|
78
|
+
}
|
|
79
|
+
args = append(args, "-c:v", encoder, "-preset", options.X264Preset, "-crf", crf, "-pix_fmt", pixelFormat, "-movflags", "+faststart")
|
|
80
|
+
} else {
|
|
81
|
+
pixelFormat := "yuv420p"
|
|
82
|
+
if request.Config.Transparent {
|
|
83
|
+
pixelFormat = "yuva420p"
|
|
84
|
+
}
|
|
85
|
+
crf := "30"
|
|
86
|
+
if options.Lossless {
|
|
87
|
+
crf = "0"
|
|
88
|
+
}
|
|
89
|
+
args = append(args, "-c:v", "libvpx-vp9", "-crf", crf, "-b:v", "0", "-pix_fmt", pixelFormat, "-auto-alt-ref", "0")
|
|
90
|
+
if options.Lossless {
|
|
91
|
+
args = append(args, "-lossless", "1", "-deadline", "good", "-cpu-used", "4", "-row-mt", "1")
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if options.EncoderThreads > 0 {
|
|
95
|
+
args = append(args, "-threads", strconv.Itoa(options.EncoderThreads))
|
|
96
|
+
}
|
|
97
|
+
return append(args, "-t", decimal(request.Config.Duration()), output)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func audioFilter(request Request) string {
|
|
101
|
+
filters := make([]string, 0, len(request.Audio)+1)
|
|
102
|
+
labels := make([]string, 0, len(request.Audio))
|
|
103
|
+
for index, track := range request.Audio {
|
|
104
|
+
volume := 1.0
|
|
105
|
+
if track.Volume != nil {
|
|
106
|
+
volume = *track.Volume
|
|
107
|
+
}
|
|
108
|
+
label := fmt.Sprintf("[a%d]", index)
|
|
109
|
+
filters = append(filters, fmt.Sprintf("[%d:a:0]atrim=start=%s:duration=%s,asetpts=PTS-STARTPTS,volume=%s,adelay=%d:all=1%s", index+1, decimal(track.From), decimal(track.Duration), decimal(volume), int64(track.Start*1000+0.5), label))
|
|
110
|
+
labels = append(labels, label)
|
|
111
|
+
}
|
|
112
|
+
filters = append(filters, fmt.Sprintf("%samix=inputs=%d:normalize=0,apad,atrim=duration=%s[audio]", strings.Join(labels, ""), len(labels), decimal(request.Config.Duration())))
|
|
113
|
+
return strings.Join(filters, ";")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func decimal(value float64) string { return strconv.FormatFloat(value, 'f', -1, 64) }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
package sceneengine
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
func TestLosslessEncodingRetainsCaptureDetail(t *testing.T) {
|
|
9
|
+
request := fixtureRequest()
|
|
10
|
+
options := Options{Lossless: true, X264Preset: "ultrafast", EncoderThreads: 2}
|
|
11
|
+
args := strings.Join(encoderArgs(request, H264, "scene.mp4", options), " ")
|
|
12
|
+
for _, want := range []string{"-vcodec png", "-c:v libx264rgb", "-preset ultrafast", "-crf 0", "-pix_fmt rgb24", "-threads 2"} {
|
|
13
|
+
if !strings.Contains(args, want) {
|
|
14
|
+
t.Fatalf("missing %s in %s", want, args)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
request.Config.Transparent = true
|
|
18
|
+
args = strings.Join(encoderArgs(request, VP9, "scene.webm", options), " ")
|
|
19
|
+
if !strings.Contains(args, "-lossless 1") || !strings.Contains(args, "-pix_fmt yuva420p") {
|
|
20
|
+
t.Fatal(args)
|
|
21
|
+
}
|
|
22
|
+
}
|
package/renderer/go.mod
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module github.com/catalinfl/machinal-scenes/renderer
|
|
2
|
+
|
|
3
|
+
go 1.27.2
|
|
4
|
+
|
|
5
|
+
require (
|
|
6
|
+
github.com/chromedp/cdproto v0.157.8
|
|
7
|
+
github.com/chromedp/chromedp v0.20.1
|
|
8
|
+
gopkg.in/yaml.v3 v3.0.1
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
require github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875 // indirect
|
package/renderer/go.sum
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
github.com/chromedp/cdproto v0.157.8 h1:Tkq0AjsUlC3Z4sJfHBzV/uAAxnTF/AZyuSTjfV/1gvk=
|
|
2
|
+
github.com/chromedp/cdproto v0.157.8/go.mod h1:h68JaIuFr9o3yiayxEYhBdvaxgYO3shDrPSYhNlyfu0=
|
|
3
|
+
github.com/chromedp/chromedp v0.20.1 h1:983T9wmyM09JtROWlxhLQwljKOQovqt8CVE89W3U0NA=
|
|
4
|
+
github.com/chromedp/chromedp v0.20.1/go.mod h1:6z1Y5FdKUxRzxx2qcC5h1xjaBJrSWrZp1mjgITfNZig=
|
|
5
|
+
github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875 h1:XGPtjxqp0FS8jj1Z7QGcSOSgyq1fUPjNyJXvi3KfI4A=
|
|
6
|
+
github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
|
7
|
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
8
|
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
9
|
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
10
|
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package sceneengine
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"strings"
|
|
6
|
+
|
|
7
|
+
"github.com/chromedp/cdproto/cdp"
|
|
8
|
+
"github.com/chromedp/cdproto/fetch"
|
|
9
|
+
"github.com/chromedp/cdproto/network"
|
|
10
|
+
"github.com/chromedp/chromedp"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func allowRequest(raw string, allowed map[string]bool) bool {
|
|
14
|
+
if strings.HasPrefix(raw, "data:") || strings.HasPrefix(raw, "blob:") || raw == "about:blank" {
|
|
15
|
+
return true
|
|
16
|
+
}
|
|
17
|
+
origin, err := parseOrigin(raw)
|
|
18
|
+
return err == nil && allowed[origin]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func restrictNetwork(owner context.Context, requestURL string, extraOrigins []string, cancel context.CancelFunc) chromedp.Action[chromedp.Void] {
|
|
22
|
+
allowed := make(map[string]bool)
|
|
23
|
+
origin, _ := parseOrigin(requestURL)
|
|
24
|
+
allowed[origin] = true
|
|
25
|
+
for _, raw := range extraOrigins {
|
|
26
|
+
origin, _ := parseOrigin(raw)
|
|
27
|
+
allowed[origin] = true
|
|
28
|
+
}
|
|
29
|
+
return chromedp.Func(func(ctx context.Context, target *chromedp.Target) error {
|
|
30
|
+
events := cdp.Events(owner, target, fetch.RequestPaused)
|
|
31
|
+
go func() {
|
|
32
|
+
for request, err := range events {
|
|
33
|
+
if err != nil {
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
if allowRequest(request.Request.URL, allowed) {
|
|
37
|
+
_, err = cdp.Call(owner, target, fetch.ContinueRequest, fetch.ContinueRequestParams{RequestID: request.RequestID})
|
|
38
|
+
} else {
|
|
39
|
+
_, err = cdp.Call(owner, target, fetch.FailRequest, fetch.FailRequestParams{RequestID: request.RequestID, ErrorReason: network.ErrorReasonBlockedByClient})
|
|
40
|
+
}
|
|
41
|
+
if err != nil {
|
|
42
|
+
cancel()
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}()
|
|
47
|
+
_, err := cdp.Call(ctx, target, fetch.Enable, fetch.EnableParams{})
|
|
48
|
+
return err
|
|
49
|
+
})
|
|
50
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
package sceneengine
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/sha256"
|
|
5
|
+
"encoding/hex"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"fmt"
|
|
8
|
+
"io"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"time"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func stageOutput(output string) (string, error) {
|
|
15
|
+
if _, err := os.Stat(output); err == nil {
|
|
16
|
+
return "", fmt.Errorf("output already exists: %s", output)
|
|
17
|
+
} else if !os.IsNotExist(err) {
|
|
18
|
+
return "", fmt.Errorf("inspect output: %w", err)
|
|
19
|
+
}
|
|
20
|
+
if _, err := os.Stat(output + ".scene.json"); err == nil {
|
|
21
|
+
return "", fmt.Errorf("asset manifest already exists: %s.scene.json", output)
|
|
22
|
+
} else if !os.IsNotExist(err) {
|
|
23
|
+
return "", fmt.Errorf("inspect asset manifest: %w", err)
|
|
24
|
+
}
|
|
25
|
+
staged, err := os.CreateTemp(filepath.Dir(output), ".scene-*"+filepath.Ext(output))
|
|
26
|
+
if err != nil {
|
|
27
|
+
return "", fmt.Errorf("stage video output: %w", err)
|
|
28
|
+
}
|
|
29
|
+
if err := staged.Close(); err != nil {
|
|
30
|
+
os.Remove(staged.Name())
|
|
31
|
+
return "", fmt.Errorf("close staged output: %w", err)
|
|
32
|
+
}
|
|
33
|
+
return staged.Name(), nil
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func publishOutput(staged, output string, request Request, manifest Manifest, codec Codec, elapsed time.Duration, metrics RenderMetrics) (Result, error) {
|
|
37
|
+
hash, size, err := fingerprint(staged)
|
|
38
|
+
if err != nil {
|
|
39
|
+
return Result{}, err
|
|
40
|
+
}
|
|
41
|
+
absolute, err := filepath.Abs(output)
|
|
42
|
+
if err != nil {
|
|
43
|
+
return Result{}, fmt.Errorf("resolve output path: %w", err)
|
|
44
|
+
}
|
|
45
|
+
result := Result{Version: ProtocolVersion, File: absolute, SHA256: hash, Size: size, Duration: request.Config.Duration(), Width: request.Config.Width, Height: request.Config.Height, FPS: request.Config.FPS, Frames: request.Config.DurationInFrames, HasAudio: len(request.Audio) > 0, Transparent: request.Config.Transparent, Codec: codec, Scene: manifest, Elapsed: elapsed}
|
|
46
|
+
result.Metrics = metrics
|
|
47
|
+
data, err := json.MarshalIndent(result, "", " ")
|
|
48
|
+
if err != nil {
|
|
49
|
+
return Result{}, fmt.Errorf("encode asset manifest: %w", err)
|
|
50
|
+
}
|
|
51
|
+
// A same-directory hard link publishes complete bytes without replacing an
|
|
52
|
+
// existing asset if another process claimed the destination during rendering.
|
|
53
|
+
if err := os.Link(staged, output); err != nil {
|
|
54
|
+
return Result{}, fmt.Errorf("publish video asset: %w", err)
|
|
55
|
+
}
|
|
56
|
+
if err := writeExclusive(output+".scene.json", data); err != nil {
|
|
57
|
+
os.Remove(output)
|
|
58
|
+
return Result{}, err
|
|
59
|
+
}
|
|
60
|
+
return result, nil
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func fingerprint(path string) (string, int64, error) {
|
|
64
|
+
file, err := os.Open(path)
|
|
65
|
+
if err != nil {
|
|
66
|
+
return "", 0, fmt.Errorf("open rendered asset: %w", err)
|
|
67
|
+
}
|
|
68
|
+
defer file.Close()
|
|
69
|
+
hash := sha256.New()
|
|
70
|
+
size, err := io.Copy(hash, file)
|
|
71
|
+
if err != nil {
|
|
72
|
+
return "", 0, fmt.Errorf("fingerprint rendered asset: %w", err)
|
|
73
|
+
}
|
|
74
|
+
return hex.EncodeToString(hash.Sum(nil)), size, nil
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func writeExclusive(path string, data []byte) error {
|
|
78
|
+
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
|
79
|
+
if err != nil {
|
|
80
|
+
return fmt.Errorf("create asset manifest: %w", err)
|
|
81
|
+
}
|
|
82
|
+
_, writeErr := file.Write(data)
|
|
83
|
+
closeErr := file.Close()
|
|
84
|
+
if writeErr != nil {
|
|
85
|
+
os.Remove(path)
|
|
86
|
+
return fmt.Errorf("write asset manifest: %w", writeErr)
|
|
87
|
+
}
|
|
88
|
+
if closeErr != nil {
|
|
89
|
+
os.Remove(path)
|
|
90
|
+
return fmt.Errorf("close asset manifest: %w", closeErr)
|
|
91
|
+
}
|
|
92
|
+
return nil
|
|
93
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
package sceneengine
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"sync"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
type captureFunc func(ctx context.Context, frame int) ([]byte, error)
|
|
11
|
+
|
|
12
|
+
type capturedFrame struct {
|
|
13
|
+
index int
|
|
14
|
+
bytes []byte
|
|
15
|
+
err error
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Credits are returned only after ordered writes, bounding out-of-order buffers
|
|
19
|
+
// even when an early frame is substantially slower than every later frame.
|
|
20
|
+
func streamFrames(ctx context.Context, count int, captures []captureFunc, writer io.Writer, progress func(int)) error {
|
|
21
|
+
ctx, cancel := context.WithCancelCause(ctx)
|
|
22
|
+
defer cancel(nil)
|
|
23
|
+
window := len(captures) * 2
|
|
24
|
+
credits := make(chan struct{}, window)
|
|
25
|
+
jobs := make(chan int)
|
|
26
|
+
results := make(chan capturedFrame, len(captures))
|
|
27
|
+
var workers sync.WaitGroup
|
|
28
|
+
workers.Add(len(captures) + 1)
|
|
29
|
+
go scheduleFrames(ctx, count, credits, jobs, &workers)
|
|
30
|
+
for _, capture := range captures {
|
|
31
|
+
go captureFrames(ctx, capture, jobs, results, &workers)
|
|
32
|
+
}
|
|
33
|
+
defer workers.Wait()
|
|
34
|
+
defer cancel(context.Canceled)
|
|
35
|
+
pending := make(map[int][]byte)
|
|
36
|
+
next := 0
|
|
37
|
+
for next < count {
|
|
38
|
+
select {
|
|
39
|
+
case <-ctx.Done():
|
|
40
|
+
return context.Cause(ctx)
|
|
41
|
+
case result := <-results:
|
|
42
|
+
if result.err != nil {
|
|
43
|
+
return result.err
|
|
44
|
+
}
|
|
45
|
+
pending[result.index] = result.bytes
|
|
46
|
+
for {
|
|
47
|
+
frame, available := pending[next]
|
|
48
|
+
if !available {
|
|
49
|
+
break
|
|
50
|
+
}
|
|
51
|
+
written, err := writer.Write(frame)
|
|
52
|
+
if err != nil {
|
|
53
|
+
return fmt.Errorf("write frame %d to encoder: %w", next, err)
|
|
54
|
+
}
|
|
55
|
+
if written != len(frame) {
|
|
56
|
+
return fmt.Errorf("write frame %d to encoder: %w", next, io.ErrShortWrite)
|
|
57
|
+
}
|
|
58
|
+
delete(pending, next)
|
|
59
|
+
<-credits
|
|
60
|
+
next++
|
|
61
|
+
if progress != nil {
|
|
62
|
+
progress(next)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return nil
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func scheduleFrames(ctx context.Context, count int, credits chan<- struct{}, jobs chan<- int, workers *sync.WaitGroup) {
|
|
71
|
+
defer workers.Done()
|
|
72
|
+
defer close(jobs)
|
|
73
|
+
for index := range count {
|
|
74
|
+
select {
|
|
75
|
+
case <-ctx.Done():
|
|
76
|
+
return
|
|
77
|
+
case credits <- struct{}{}:
|
|
78
|
+
}
|
|
79
|
+
select {
|
|
80
|
+
case <-ctx.Done():
|
|
81
|
+
return
|
|
82
|
+
case jobs <- index:
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func captureFrames(ctx context.Context, capture captureFunc, jobs <-chan int, results chan<- capturedFrame, workers *sync.WaitGroup) {
|
|
88
|
+
defer workers.Done()
|
|
89
|
+
for {
|
|
90
|
+
select {
|
|
91
|
+
case <-ctx.Done():
|
|
92
|
+
return
|
|
93
|
+
case index, available := <-jobs:
|
|
94
|
+
if !available {
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
buffer, err := capture(ctx, index)
|
|
98
|
+
select {
|
|
99
|
+
case <-ctx.Done():
|
|
100
|
+
return
|
|
101
|
+
case results <- capturedFrame{index: index, bytes: buffer, err: err}:
|
|
102
|
+
}
|
|
103
|
+
if err != nil {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
package sceneengine
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"io"
|
|
9
|
+
"sync/atomic"
|
|
10
|
+
"testing"
|
|
11
|
+
"time"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestStreamFramesOrdersConcurrentResults(t *testing.T) {
|
|
15
|
+
capture := func(ctx context.Context, index int) ([]byte, error) {
|
|
16
|
+
if index%3 == 0 {
|
|
17
|
+
time.Sleep(5 * time.Millisecond)
|
|
18
|
+
}
|
|
19
|
+
return []byte(fmt.Sprintf("%02d|", index)), nil
|
|
20
|
+
}
|
|
21
|
+
var output bytes.Buffer
|
|
22
|
+
var progress []int
|
|
23
|
+
err := streamFrames(context.Background(), 30, []captureFunc{capture, capture, capture}, &output, func(written int) { progress = append(progress, written) })
|
|
24
|
+
if err != nil {
|
|
25
|
+
t.Fatal(err)
|
|
26
|
+
}
|
|
27
|
+
var expected bytes.Buffer
|
|
28
|
+
for index := 0; index < 30; index++ {
|
|
29
|
+
fmt.Fprintf(&expected, "%02d|", index)
|
|
30
|
+
}
|
|
31
|
+
if output.String() != expected.String() {
|
|
32
|
+
t.Fatalf("frames are out of order: %s", output.String())
|
|
33
|
+
}
|
|
34
|
+
if len(progress) != 30 || progress[29] != 30 {
|
|
35
|
+
t.Fatalf("invalid progress: %v", progress)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestStreamFramesBoundsReorderingWindow(t *testing.T) {
|
|
40
|
+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
41
|
+
defer cancel()
|
|
42
|
+
release := make(chan struct{})
|
|
43
|
+
var started atomic.Int64
|
|
44
|
+
capture := func(ctx context.Context, index int) ([]byte, error) {
|
|
45
|
+
started.Add(1)
|
|
46
|
+
if index == 0 {
|
|
47
|
+
select {
|
|
48
|
+
case <-release:
|
|
49
|
+
case <-ctx.Done():
|
|
50
|
+
return nil, ctx.Err()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return []byte{byte(index)}, nil
|
|
54
|
+
}
|
|
55
|
+
done := make(chan error, 1)
|
|
56
|
+
go func() { done <- streamFrames(ctx, 100, []captureFunc{capture, capture}, io.Discard, nil) }()
|
|
57
|
+
deadline := time.Now().Add(time.Second)
|
|
58
|
+
for started.Load() < 4 && time.Now().Before(deadline) {
|
|
59
|
+
time.Sleep(time.Millisecond)
|
|
60
|
+
}
|
|
61
|
+
time.Sleep(15 * time.Millisecond)
|
|
62
|
+
if value := started.Load(); value != 4 {
|
|
63
|
+
t.Errorf("expected window of four frames, got %d", value)
|
|
64
|
+
}
|
|
65
|
+
close(release)
|
|
66
|
+
if err := <-done; err != nil {
|
|
67
|
+
t.Fatal(err)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func TestStreamFramesCancelsOtherWorkersOnFailure(t *testing.T) {
|
|
72
|
+
expected := errors.New("broken frame")
|
|
73
|
+
blocked := make(chan struct{})
|
|
74
|
+
capture := func(ctx context.Context, index int) ([]byte, error) {
|
|
75
|
+
if index == 0 {
|
|
76
|
+
<-blocked
|
|
77
|
+
return nil, expected
|
|
78
|
+
}
|
|
79
|
+
close(blocked)
|
|
80
|
+
<-ctx.Done()
|
|
81
|
+
return nil, ctx.Err()
|
|
82
|
+
}
|
|
83
|
+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
84
|
+
defer cancel()
|
|
85
|
+
if err := streamFrames(ctx, 10, []captureFunc{capture, capture}, io.Discard, nil); !errors.Is(err, expected) {
|
|
86
|
+
t.Fatalf("expected capture failure, got %v", err)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func TestStreamFramesHonorsCancellation(t *testing.T) {
|
|
91
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
92
|
+
capture := func(ctx context.Context, _ int) ([]byte, error) { cancel(); <-ctx.Done(); return nil, ctx.Err() }
|
|
93
|
+
if err := streamFrames(ctx, 10, []captureFunc{capture}, io.Discard, nil); !errors.Is(err, context.Canceled) {
|
|
94
|
+
t.Fatalf("expected cancellation, got %v", err)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type shortWriter struct{}
|
|
99
|
+
|
|
100
|
+
func (shortWriter) Write(buffer []byte) (int, error) { return len(buffer) - 1, nil }
|
|
101
|
+
|
|
102
|
+
func TestStreamFramesRejectsTruncatedWrites(t *testing.T) {
|
|
103
|
+
capture := func(context.Context, int) ([]byte, error) { return []byte("frame"), nil }
|
|
104
|
+
err := streamFrames(context.Background(), 2, []captureFunc{capture}, shortWriter{}, nil)
|
|
105
|
+
if !errors.Is(err, io.ErrShortWrite) {
|
|
106
|
+
t.Fatalf("expected a truncated-write error, got %v", err)
|
|
107
|
+
}
|
|
108
|
+
}
|