@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,116 @@
|
|
|
1
|
+
package sceneengine
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os/exec"
|
|
7
|
+
"sync"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/chromedp/chromedp"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
const (
|
|
14
|
+
defaultConcurrency = 2
|
|
15
|
+
maxConcurrency = 16
|
|
16
|
+
defaultFrameTimeout = 30 * time.Second
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Renderer owns one Chromium process reused across sequential render jobs.
|
|
20
|
+
// Each active frame has an exclusive tab; concurrent Render calls are rejected.
|
|
21
|
+
type Renderer struct {
|
|
22
|
+
browser context.Context
|
|
23
|
+
cancelBrowser context.CancelFunc
|
|
24
|
+
cancelAllocator context.CancelFunc
|
|
25
|
+
options Options
|
|
26
|
+
lease chan struct{}
|
|
27
|
+
closeOnce sync.Once
|
|
28
|
+
browserLog *browserLog
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// New starts a pinned browser whose lifetime is owned by ctx or Close.
|
|
32
|
+
func New(ctx context.Context, options Options) (*Renderer, error) {
|
|
33
|
+
options, err := normalizeOptions(options)
|
|
34
|
+
if err != nil {
|
|
35
|
+
return nil, err
|
|
36
|
+
}
|
|
37
|
+
diagnostics := &browserLog{}
|
|
38
|
+
flags := append([]chromedp.ExecAllocatorOption{}, chromedp.DefaultExecAllocatorOptions[:]...)
|
|
39
|
+
flags = append(flags,
|
|
40
|
+
chromedp.CombinedOutput(diagnostics),
|
|
41
|
+
chromedp.ExecPath(options.BrowserExecutable),
|
|
42
|
+
chromedp.Flag("disable-background-timer-throttling", true),
|
|
43
|
+
chromedp.Flag("disable-renderer-backgrounding", true),
|
|
44
|
+
chromedp.Flag("disable-backgrounding-occluded-windows", true),
|
|
45
|
+
chromedp.Flag("force-color-profile", "srgb"),
|
|
46
|
+
chromedp.Flag("hide-scrollbars", true),
|
|
47
|
+
chromedp.Flag("mute-audio", true),
|
|
48
|
+
)
|
|
49
|
+
if options.NoSandbox {
|
|
50
|
+
flags = append(flags, chromedp.NoSandbox)
|
|
51
|
+
}
|
|
52
|
+
if options.EnableGPU {
|
|
53
|
+
flags = append(flags, chromedp.Flag("disable-gpu", false), chromedp.Flag("enable-gpu", true))
|
|
54
|
+
}
|
|
55
|
+
allocator, cancelAllocator := chromedp.NewExecAllocator(ctx, flags...)
|
|
56
|
+
browser, cancelBrowser := chromedp.NewContext(allocator)
|
|
57
|
+
renderer := &Renderer{browser: browser, cancelBrowser: cancelBrowser, cancelAllocator: cancelAllocator, options: options, lease: make(chan struct{}, 1), browserLog: diagnostics}
|
|
58
|
+
startupTimeout := time.AfterFunc(options.FrameTimeout, cancelBrowser)
|
|
59
|
+
defer startupTimeout.Stop()
|
|
60
|
+
if err := chromedp.Do(browser); err != nil {
|
|
61
|
+
renderer.Close()
|
|
62
|
+
return nil, diagnostics.wrap(fmt.Errorf("start Chromium: %w", err))
|
|
63
|
+
}
|
|
64
|
+
return renderer, nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func normalizeOptions(options Options) (Options, error) {
|
|
68
|
+
if options.BrowserExecutable == "" {
|
|
69
|
+
return options, fmt.Errorf("BrowserExecutable is required; use a pinned Chrome Headless Shell binary")
|
|
70
|
+
}
|
|
71
|
+
if options.FFmpegExecutable == "" {
|
|
72
|
+
options.FFmpegExecutable = "ffmpeg"
|
|
73
|
+
}
|
|
74
|
+
ffmpeg, err := exec.LookPath(options.FFmpegExecutable)
|
|
75
|
+
if err != nil {
|
|
76
|
+
return options, fmt.Errorf("locate FFmpeg: %w", err)
|
|
77
|
+
}
|
|
78
|
+
options.FFmpegExecutable = ffmpeg
|
|
79
|
+
if options.X264Preset == "" {
|
|
80
|
+
options.X264Preset = "medium"
|
|
81
|
+
}
|
|
82
|
+
switch options.X264Preset {
|
|
83
|
+
case "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow":
|
|
84
|
+
default:
|
|
85
|
+
return options, fmt.Errorf("invalid x264 preset")
|
|
86
|
+
}
|
|
87
|
+
if options.EncoderThreads < 0 || options.EncoderThreads > 32 {
|
|
88
|
+
return options, fmt.Errorf("encoder threads must be between 0 (automatic) and 32")
|
|
89
|
+
}
|
|
90
|
+
if options.Concurrency == 0 {
|
|
91
|
+
options.Concurrency = defaultConcurrency
|
|
92
|
+
}
|
|
93
|
+
if options.Concurrency < 1 || options.Concurrency > maxConcurrency {
|
|
94
|
+
return options, fmt.Errorf("concurrency must be between 1 and %d", maxConcurrency)
|
|
95
|
+
}
|
|
96
|
+
if options.FrameTimeout == 0 {
|
|
97
|
+
options.FrameTimeout = defaultFrameTimeout
|
|
98
|
+
}
|
|
99
|
+
if options.FrameTimeout < 0 {
|
|
100
|
+
return options, fmt.Errorf("frame timeout must be positive")
|
|
101
|
+
}
|
|
102
|
+
for _, origin := range options.AllowedOrigins {
|
|
103
|
+
if _, err := parseOrigin(origin); err != nil {
|
|
104
|
+
return options, fmt.Errorf("allowed origin %q: %w", origin, err)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return options, nil
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Close cancels active jobs and releases the browser process; it is idempotent.
|
|
111
|
+
func (r *Renderer) Close() {
|
|
112
|
+
r.closeOnce.Do(func() {
|
|
113
|
+
r.cancelBrowser()
|
|
114
|
+
r.cancelAllocator()
|
|
115
|
+
})
|
|
116
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package sceneengine
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"fmt"
|
|
6
|
+
"sync"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
const maxBrowserLogBytes = 8 << 10
|
|
10
|
+
|
|
11
|
+
type browserLog struct {
|
|
12
|
+
mu sync.Mutex
|
|
13
|
+
buffer []byte
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func (l *browserLog) Write(data []byte) (int, error) {
|
|
17
|
+
l.mu.Lock()
|
|
18
|
+
defer l.mu.Unlock()
|
|
19
|
+
l.buffer = append(l.buffer, data...)
|
|
20
|
+
if len(l.buffer) > maxBrowserLogBytes {
|
|
21
|
+
l.buffer = bytes.Clone(l.buffer[len(l.buffer)-maxBrowserLogBytes:])
|
|
22
|
+
}
|
|
23
|
+
return len(data), nil
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func (l *browserLog) wrap(err error) error {
|
|
27
|
+
if l == nil || err == nil {
|
|
28
|
+
return err
|
|
29
|
+
}
|
|
30
|
+
l.mu.Lock()
|
|
31
|
+
defer l.mu.Unlock()
|
|
32
|
+
if len(l.buffer) == 0 {
|
|
33
|
+
return err
|
|
34
|
+
}
|
|
35
|
+
return fmt.Errorf("%w\nChromium diagnostics:\n%s", err, l.buffer)
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package sceneengine
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"strings"
|
|
6
|
+
"sync"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestBrowserLogRetainsBoundedFailureDetails(t *testing.T) {
|
|
11
|
+
var log browserLog
|
|
12
|
+
data := []byte(strings.Repeat("x", maxBrowserLogBytes) + "\nNo space left on device")
|
|
13
|
+
written, err := log.Write(data)
|
|
14
|
+
if err != nil || written != len(data) {
|
|
15
|
+
t.Fatalf("write browser output: %d, %v", written, err)
|
|
16
|
+
}
|
|
17
|
+
if len(log.buffer) != maxBrowserLogBytes {
|
|
18
|
+
t.Fatalf("browser log has %d bytes", len(log.buffer))
|
|
19
|
+
}
|
|
20
|
+
cause := errors.New("capture frame 1405 failed")
|
|
21
|
+
failure := log.wrap(cause)
|
|
22
|
+
if !errors.Is(failure, cause) || !strings.HasSuffix(failure.Error(), "No space left on device") {
|
|
23
|
+
t.Fatalf("lost capture failure or browser diagnostic: %v", failure)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestBrowserLogWithoutOutputPreservesOriginalError(t *testing.T) {
|
|
28
|
+
cause := errors.New("capture failed")
|
|
29
|
+
for _, log := range []*browserLog{nil, {}} {
|
|
30
|
+
if log.wrap(cause) != cause {
|
|
31
|
+
t.Fatal("empty browser output changed the original error")
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func TestBrowserLogSupportsConcurrentOutputAndFailures(t *testing.T) {
|
|
37
|
+
var log browserLog
|
|
38
|
+
var writers sync.WaitGroup
|
|
39
|
+
for range 4 {
|
|
40
|
+
writers.Go(func() {
|
|
41
|
+
for range 100 {
|
|
42
|
+
log.Write([]byte("browser diagnostic\n"))
|
|
43
|
+
log.wrap(errors.New("capture failed"))
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
writers.Wait()
|
|
48
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
package sceneengine
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"errors"
|
|
8
|
+
"image/png"
|
|
9
|
+
"net/http"
|
|
10
|
+
"net/http/httptest"
|
|
11
|
+
"os"
|
|
12
|
+
"os/exec"
|
|
13
|
+
"path/filepath"
|
|
14
|
+
"strings"
|
|
15
|
+
"testing"
|
|
16
|
+
"time"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
func browserFixture(t *testing.T, progress func(Progress)) (*Renderer, Request) {
|
|
20
|
+
t.Helper()
|
|
21
|
+
browser, ffmpeg, directory := os.Getenv("SCENE_BROWSER"), os.Getenv("SCENE_FFMPEG"), os.Getenv("SCENE_FIXTURE_DIR")
|
|
22
|
+
if browser == "" || ffmpeg == "" || directory == "" {
|
|
23
|
+
t.Skip("configure SCENE_BROWSER, SCENE_FFMPEG and SCENE_FIXTURE_DIR for native tests")
|
|
24
|
+
}
|
|
25
|
+
server := httptest.NewServer(http.FileServer(http.Dir(directory)))
|
|
26
|
+
t.Cleanup(server.Close)
|
|
27
|
+
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
28
|
+
t.Cleanup(cancel)
|
|
29
|
+
renderer, err := New(ctx, Options{BrowserExecutable: browser, FFmpegExecutable: ffmpeg, Concurrency: 2, FrameTimeout: 10 * time.Second, Progress: progress})
|
|
30
|
+
if err != nil {
|
|
31
|
+
t.Fatal(err)
|
|
32
|
+
}
|
|
33
|
+
t.Cleanup(renderer.Close)
|
|
34
|
+
request := fixtureRequest()
|
|
35
|
+
request.URL = server.URL + "/scene.html"
|
|
36
|
+
request.Seed = "native-test"
|
|
37
|
+
request.Config.FPS = 12
|
|
38
|
+
request.Config.DurationInFrames = 24
|
|
39
|
+
return renderer, request
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestBrowserDeterministicFrames(t *testing.T) {
|
|
43
|
+
renderer, request := browserFixture(t, nil)
|
|
44
|
+
worker, err := renderer.newWorker(context.Background(), request)
|
|
45
|
+
if err != nil {
|
|
46
|
+
t.Fatal(err)
|
|
47
|
+
}
|
|
48
|
+
defer worker.close()
|
|
49
|
+
first, err := worker.capture(context.Background(), 18)
|
|
50
|
+
if err != nil {
|
|
51
|
+
t.Fatal(err)
|
|
52
|
+
}
|
|
53
|
+
if _, err := worker.capture(context.Background(), 3); err != nil {
|
|
54
|
+
t.Fatal(err)
|
|
55
|
+
}
|
|
56
|
+
repeated, err := worker.capture(context.Background(), 18)
|
|
57
|
+
if err != nil {
|
|
58
|
+
t.Fatal(err)
|
|
59
|
+
}
|
|
60
|
+
assertCapturePixelsEqual(t, first, repeated)
|
|
61
|
+
second, err := renderer.newWorker(context.Background(), request)
|
|
62
|
+
if err != nil {
|
|
63
|
+
t.Fatal(err)
|
|
64
|
+
}
|
|
65
|
+
defer second.close()
|
|
66
|
+
parallel, err := second.capture(context.Background(), 18)
|
|
67
|
+
if err != nil {
|
|
68
|
+
t.Fatal(err)
|
|
69
|
+
}
|
|
70
|
+
assertCapturePixelsEqual(t, first, parallel)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func assertCapturePixelsEqual(t *testing.T, first, second []byte) {
|
|
74
|
+
t.Helper()
|
|
75
|
+
a, err := png.Decode(bytes.NewReader(first))
|
|
76
|
+
if err != nil {
|
|
77
|
+
t.Fatal(err)
|
|
78
|
+
}
|
|
79
|
+
b, err := png.Decode(bytes.NewReader(second))
|
|
80
|
+
if err != nil {
|
|
81
|
+
t.Fatal(err)
|
|
82
|
+
}
|
|
83
|
+
if a.Bounds() != b.Bounds() {
|
|
84
|
+
t.Fatal("capture dimensions differ")
|
|
85
|
+
}
|
|
86
|
+
for y := a.Bounds().Min.Y; y < a.Bounds().Max.Y; y++ {
|
|
87
|
+
for x := a.Bounds().Min.X; x < a.Bounds().Max.X; x++ {
|
|
88
|
+
ar, ag, ab, aa := a.At(x, y).RGBA()
|
|
89
|
+
br, bg, bb, ba := b.At(x, y).RGBA()
|
|
90
|
+
if ar != br || ag != bg || ab != bb || aa != ba {
|
|
91
|
+
t.Fatalf("capture pixels differ at %d,%d: %d,%d,%d,%d vs %d,%d,%d,%d", x, y, ar, ag, ab, aa, br, bg, bb, ba)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func TestBrowserRenderVideoAndAudio(t *testing.T) {
|
|
98
|
+
renderer, request := browserFixture(t, nil)
|
|
99
|
+
root := t.TempDir()
|
|
100
|
+
audio := filepath.Join(root, "tone.wav")
|
|
101
|
+
generate := exec.Command(renderer.options.FFmpegExecutable, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "sine=frequency=440:duration=2", "-y", audio)
|
|
102
|
+
if data, err := generate.CombinedOutput(); err != nil {
|
|
103
|
+
t.Fatalf("create audio fixture: %v: %s", err, data)
|
|
104
|
+
}
|
|
105
|
+
request.Audio = []AudioTrack{{File: audio, Start: 0.5, Duration: 1}}
|
|
106
|
+
output := filepath.Join(root, "scene.mp4")
|
|
107
|
+
result, err := renderer.Render(context.Background(), request, output)
|
|
108
|
+
if err != nil {
|
|
109
|
+
t.Fatal(err)
|
|
110
|
+
}
|
|
111
|
+
if !result.HasAudio || result.Frames != 24 || result.Duration != 2 {
|
|
112
|
+
t.Fatalf("invalid result: %+v", result)
|
|
113
|
+
}
|
|
114
|
+
hash, size, err := fingerprint(output)
|
|
115
|
+
if err != nil {
|
|
116
|
+
t.Fatal(err)
|
|
117
|
+
}
|
|
118
|
+
if hash != result.SHA256 || size != result.Size {
|
|
119
|
+
t.Fatal("manifest fingerprint does not match encoded bytes")
|
|
120
|
+
}
|
|
121
|
+
if _, err := os.Stat(output + ".scene.json"); err != nil {
|
|
122
|
+
t.Fatal(err)
|
|
123
|
+
}
|
|
124
|
+
streams := probeStreams(t, output)
|
|
125
|
+
if len(streams) != 2 || streams[0].CodecName != "h264" || streams[0].Width != 640 || streams[0].Height != 360 || streams[0].Frames != "24" || streams[1].CodecName != "aac" {
|
|
126
|
+
t.Fatalf("unexpected encoded streams: %+v", streams)
|
|
127
|
+
}
|
|
128
|
+
if _, err := renderer.Render(context.Background(), request, output); err == nil {
|
|
129
|
+
t.Fatal("overwrote an existing output")
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func TestBrowserLosslessRGBMatchesCapturedPixels(t *testing.T) {
|
|
134
|
+
renderer, request := browserFixture(t, nil)
|
|
135
|
+
renderer.options.Lossless = true
|
|
136
|
+
renderer.options.X264Preset = "ultrafast"
|
|
137
|
+
renderer.options.EncoderThreads = 2
|
|
138
|
+
request.Config.DurationInFrames = 6
|
|
139
|
+
worker, err := renderer.newWorker(context.Background(), request)
|
|
140
|
+
if err != nil {
|
|
141
|
+
t.Fatal(err)
|
|
142
|
+
}
|
|
143
|
+
capture, err := worker.capture(context.Background(), 3)
|
|
144
|
+
worker.close()
|
|
145
|
+
if err != nil {
|
|
146
|
+
t.Fatal(err)
|
|
147
|
+
}
|
|
148
|
+
image, err := png.Decode(bytes.NewReader(capture))
|
|
149
|
+
if err != nil {
|
|
150
|
+
t.Fatal(err)
|
|
151
|
+
}
|
|
152
|
+
want := make([]byte, 0, request.Config.Width*request.Config.Height*3)
|
|
153
|
+
for y := 0; y < request.Config.Height; y++ {
|
|
154
|
+
for x := 0; x < request.Config.Width; x++ {
|
|
155
|
+
r, g, b, _ := image.At(x, y).RGBA()
|
|
156
|
+
want = append(want, byte(r>>8), byte(g>>8), byte(b>>8))
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
output := filepath.Join(t.TempDir(), "lossless.mp4")
|
|
160
|
+
result, err := renderer.Render(context.Background(), request, output)
|
|
161
|
+
if err != nil {
|
|
162
|
+
t.Fatal(err)
|
|
163
|
+
}
|
|
164
|
+
got, err := exec.Command(renderer.options.FFmpegExecutable, "-v", "error", "-i", output, "-vf", `select=eq(n\,3)`, "-frames:v", "1", "-pix_fmt", "rgb24", "-f", "rawvideo", "pipe:1").Output()
|
|
165
|
+
if err != nil {
|
|
166
|
+
t.Fatal(err)
|
|
167
|
+
}
|
|
168
|
+
if !bytes.Equal(want, got) {
|
|
169
|
+
if len(want) != len(got) {
|
|
170
|
+
t.Fatalf("lossless RGB byte count: want %d, got %d", len(want), len(got))
|
|
171
|
+
}
|
|
172
|
+
for i := range want {
|
|
173
|
+
if want[i] != got[i] {
|
|
174
|
+
t.Fatalf("lossless scene RGB differs at pixel %d,%d channel %d: want %d, got %d", i/3%request.Config.Width, i/3/request.Config.Width, i%3, want[i], got[i])
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if result.Metrics.Workers != 2 || result.Metrics.CaptureAndEncode <= 0 || result.Metrics.SeekWorkerTime <= 0 {
|
|
179
|
+
t.Fatalf("missing stage measurements: %+v", result.Metrics)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func TestBrowserAlpha(t *testing.T) {
|
|
184
|
+
renderer, request := browserFixture(t, nil)
|
|
185
|
+
request.Config.Transparent = true
|
|
186
|
+
request.Config.DurationInFrames = 6
|
|
187
|
+
worker, err := renderer.newWorker(context.Background(), request)
|
|
188
|
+
if err != nil {
|
|
189
|
+
t.Fatal(err)
|
|
190
|
+
}
|
|
191
|
+
buffer, err := worker.capture(context.Background(), 3)
|
|
192
|
+
worker.close()
|
|
193
|
+
if err != nil {
|
|
194
|
+
t.Fatal(err)
|
|
195
|
+
}
|
|
196
|
+
assertTransparentCorner(t, buffer)
|
|
197
|
+
output := filepath.Join(t.TempDir(), "overlay.webm")
|
|
198
|
+
result, err := renderer.Render(context.Background(), request, output)
|
|
199
|
+
if err != nil {
|
|
200
|
+
t.Fatal(err)
|
|
201
|
+
}
|
|
202
|
+
if !result.Transparent || result.Codec != VP9 {
|
|
203
|
+
t.Fatalf("invalid transparent result: %+v", result)
|
|
204
|
+
}
|
|
205
|
+
decoded, err := exec.Command(renderer.options.FFmpegExecutable, "-hide_banner", "-loglevel", "error", "-c:v", "libvpx-vp9", "-i", output, "-frames:v", "1", "-f", "image2pipe", "-vcodec", "png", "pipe:1").Output()
|
|
206
|
+
if err != nil {
|
|
207
|
+
t.Fatalf("decode transparent video: %v", err)
|
|
208
|
+
}
|
|
209
|
+
assertTransparentCorner(t, decoded)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func TestBrowserCancellationAndReuse(t *testing.T) {
|
|
213
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
214
|
+
cancelled := false
|
|
215
|
+
renderer, request := browserFixture(t, func(progress Progress) {
|
|
216
|
+
if progress.WrittenFrames == 2 && !cancelled {
|
|
217
|
+
cancelled = true
|
|
218
|
+
cancel()
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
output := filepath.Join(t.TempDir(), "cancelled.mp4")
|
|
222
|
+
_, err := renderer.Render(ctx, request, output)
|
|
223
|
+
if !errors.Is(err, context.Canceled) {
|
|
224
|
+
t.Fatalf("expected cancellation, got %v", err)
|
|
225
|
+
}
|
|
226
|
+
for _, path := range []string{output, output + ".scene.json"} {
|
|
227
|
+
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
228
|
+
t.Fatalf("cancelled job published %s", path)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
request.Config.DurationInFrames = 6
|
|
232
|
+
if _, err := renderer.Render(context.Background(), request, output); err != nil {
|
|
233
|
+
t.Fatalf("browser could not be reused after cancellation: %v", err)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func TestBrowserReportsSceneFailure(t *testing.T) {
|
|
238
|
+
renderer, request := browserFixture(t, nil)
|
|
239
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
240
|
+
w.Header().Set("Content-Type", "text/html")
|
|
241
|
+
w.Write([]byte(`<script>window.__scene = { version: 1, ready: async () => { throw new Error('missing scene resource'); } };</script>`))
|
|
242
|
+
}))
|
|
243
|
+
defer server.Close()
|
|
244
|
+
request.URL = server.URL
|
|
245
|
+
_, err := renderer.Render(context.Background(), request, filepath.Join(t.TempDir(), "failed.mp4"))
|
|
246
|
+
if err == nil || !strings.Contains(err.Error(), "missing scene resource") {
|
|
247
|
+
t.Fatalf("lost scene error: %v", err)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func TestBrowserFrameTimeoutDoesNotPublishOutput(t *testing.T) {
|
|
252
|
+
renderer, request := browserFixture(t, nil)
|
|
253
|
+
renderer.options.FrameTimeout = time.Second
|
|
254
|
+
request.Config.DurationInFrames = 1
|
|
255
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
256
|
+
w.Header().Set("Content-Type", "text/html")
|
|
257
|
+
w.Write([]byte(`<script>window.__scene = { version: 1, manifest: window.__sceneRequest, ready: async () => {}, seek: () => new Promise(() => {}) };</script>`))
|
|
258
|
+
}))
|
|
259
|
+
defer server.Close()
|
|
260
|
+
request.URL = server.URL
|
|
261
|
+
output := filepath.Join(t.TempDir(), "timed-out.mp4")
|
|
262
|
+
_, err := renderer.Render(context.Background(), request, output)
|
|
263
|
+
if !errors.Is(err, context.DeadlineExceeded) {
|
|
264
|
+
t.Fatalf("expected frame deadline, got %v", err)
|
|
265
|
+
}
|
|
266
|
+
if _, err := os.Stat(output); !os.IsNotExist(err) {
|
|
267
|
+
t.Fatal("timed-out frame published an asset")
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func assertTransparentCorner(t *testing.T, data []byte) {
|
|
272
|
+
t.Helper()
|
|
273
|
+
image, err := png.Decode(bytes.NewReader(data))
|
|
274
|
+
if err != nil {
|
|
275
|
+
t.Fatal(err)
|
|
276
|
+
}
|
|
277
|
+
_, _, _, alpha := image.At(0, 0).RGBA()
|
|
278
|
+
if alpha != 0 {
|
|
279
|
+
t.Fatalf("expected alpha zero at empty corner, got %d", alpha)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
type probeStream struct {
|
|
284
|
+
CodecName string `json:"codec_name"`
|
|
285
|
+
Width int `json:"width"`
|
|
286
|
+
Height int `json:"height"`
|
|
287
|
+
Frames string `json:"nb_frames"`
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
func probeStreams(t *testing.T, path string) []probeStream {
|
|
291
|
+
t.Helper()
|
|
292
|
+
executable := os.Getenv("SCENE_FFPROBE")
|
|
293
|
+
if executable == "" {
|
|
294
|
+
t.Fatal("SCENE_FFPROBE is required for native verification")
|
|
295
|
+
}
|
|
296
|
+
data, err := exec.Command(executable, "-v", "error", "-show_streams", "-of", "json", path).Output()
|
|
297
|
+
if err != nil {
|
|
298
|
+
t.Fatalf("probe rendered output: %v", err)
|
|
299
|
+
}
|
|
300
|
+
var result struct {
|
|
301
|
+
Streams []probeStream `json:"streams"`
|
|
302
|
+
}
|
|
303
|
+
if err := json.Unmarshal(data, &result); err != nil {
|
|
304
|
+
t.Fatal(err)
|
|
305
|
+
}
|
|
306
|
+
return result.Streams
|
|
307
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"flag"
|
|
7
|
+
"fmt"
|
|
8
|
+
"io"
|
|
9
|
+
"os"
|
|
10
|
+
"os/signal"
|
|
11
|
+
"path/filepath"
|
|
12
|
+
"strings"
|
|
13
|
+
"syscall"
|
|
14
|
+
"time"
|
|
15
|
+
|
|
16
|
+
sceneengine "github.com/catalinfl/machinal-scenes/renderer"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
var version = "dev"
|
|
20
|
+
|
|
21
|
+
type cliOptions struct {
|
|
22
|
+
printVersion bool
|
|
23
|
+
requestFile string
|
|
24
|
+
output string
|
|
25
|
+
serveDirectory string
|
|
26
|
+
renderer sceneengine.Options
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func main() {
|
|
30
|
+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
31
|
+
defer stop()
|
|
32
|
+
options := parseFlags()
|
|
33
|
+
if err := run(ctx, options, os.Stdout, os.Stderr); err != nil {
|
|
34
|
+
fmt.Fprintln(os.Stderr, err)
|
|
35
|
+
os.Exit(1)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func parseFlags() cliOptions {
|
|
40
|
+
var options cliOptions
|
|
41
|
+
var allowedOrigins string
|
|
42
|
+
flag.BoolVar(&options.printVersion, "version", false, "print the renderer version")
|
|
43
|
+
flag.StringVar(&options.requestFile, "request", "", "JSON/YAML job file (required)")
|
|
44
|
+
flag.StringVar(&options.output, "output", "", "new .mp4 or .webm output file (required)")
|
|
45
|
+
flag.StringVar(&options.serveDirectory, "serve", "", "serve a built scene directory on an ephemeral loopback port")
|
|
46
|
+
flag.StringVar(&options.renderer.BrowserExecutable, "browser", os.Getenv("SCENE_BROWSER"), "pinned Chrome Headless Shell executable")
|
|
47
|
+
flag.StringVar(&options.renderer.FFmpegExecutable, "ffmpeg", "ffmpeg", "FFmpeg executable")
|
|
48
|
+
flag.IntVar(&options.renderer.Concurrency, "workers", 2, "concurrent pages (1–16)")
|
|
49
|
+
flag.IntVar(&options.renderer.EncoderThreads, "encoder-threads", 0, "encoder threads (0 = automatic, at most 32)")
|
|
50
|
+
flag.StringVar(&options.renderer.X264Preset, "x264-preset", "medium", "x264 speed/compression preset (does not select quality)")
|
|
51
|
+
flag.BoolVar(&options.renderer.Lossless, "lossless", false, "lossless intermediate encoding; MP4 retains RGB, WebM retains alpha")
|
|
52
|
+
flag.BoolVar(&options.renderer.EnableGPU, "gpu", false, "allow hardware GPU rendering (requires working device access)")
|
|
53
|
+
flag.DurationVar(&options.renderer.FrameTimeout, "frame-timeout", 30*time.Second, "timeout for page preparation or one frame")
|
|
54
|
+
flag.BoolVar(&options.renderer.NoSandbox, "no-sandbox", false, "disable Chromium sandbox for an externally isolated container")
|
|
55
|
+
flag.StringVar(&allowedOrigins, "allow-origins", "", "comma-separated additional resource origins")
|
|
56
|
+
flag.Parse()
|
|
57
|
+
if allowedOrigins != "" {
|
|
58
|
+
for _, origin := range strings.Split(allowedOrigins, ",") {
|
|
59
|
+
options.renderer.AllowedOrigins = append(options.renderer.AllowedOrigins, strings.TrimSpace(origin))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return options
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func run(ctx context.Context, options cliOptions, stdout, stderr io.Writer) error {
|
|
66
|
+
if options.printVersion {
|
|
67
|
+
if _, err := fmt.Fprintf(stdout, "machinal-scene-render %s\n", version); err != nil {
|
|
68
|
+
return fmt.Errorf("write renderer version: %w", err)
|
|
69
|
+
}
|
|
70
|
+
return nil
|
|
71
|
+
}
|
|
72
|
+
if options.requestFile == "" || options.output == "" {
|
|
73
|
+
return fmt.Errorf("-request and -output are required")
|
|
74
|
+
}
|
|
75
|
+
request, err := loadRequest(options.requestFile)
|
|
76
|
+
if err != nil {
|
|
77
|
+
return err
|
|
78
|
+
}
|
|
79
|
+
if options.serveDirectory != "" {
|
|
80
|
+
resolvedURL, closeServer, err := serveScene(options.serveDirectory, request.URL)
|
|
81
|
+
if err != nil {
|
|
82
|
+
return err
|
|
83
|
+
}
|
|
84
|
+
defer closeServer()
|
|
85
|
+
request.URL = resolvedURL
|
|
86
|
+
}
|
|
87
|
+
options.renderer.Progress = func(progress sceneengine.Progress) {
|
|
88
|
+
if progress.WrittenFrames == 1 || progress.WrittenFrames%30 == 0 || progress.WrittenFrames == progress.TotalFrames {
|
|
89
|
+
fmt.Fprintf(stderr, "frames %d/%d · %s\n", progress.WrittenFrames, progress.TotalFrames, progress.Elapsed.Round(time.Millisecond))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
renderer, err := sceneengine.New(ctx, options.renderer)
|
|
93
|
+
if err != nil {
|
|
94
|
+
return err
|
|
95
|
+
}
|
|
96
|
+
defer renderer.Close()
|
|
97
|
+
result, err := renderer.Render(ctx, request, options.output)
|
|
98
|
+
if err != nil {
|
|
99
|
+
return err
|
|
100
|
+
}
|
|
101
|
+
if err := json.NewEncoder(stdout).Encode(result); err != nil {
|
|
102
|
+
return fmt.Errorf("write result JSON: %w", err)
|
|
103
|
+
}
|
|
104
|
+
return nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func loadRequest(path string) (sceneengine.Request, error) {
|
|
108
|
+
file, err := os.Open(path)
|
|
109
|
+
if err != nil {
|
|
110
|
+
return sceneengine.Request{}, fmt.Errorf("open request: %w", err)
|
|
111
|
+
}
|
|
112
|
+
defer file.Close()
|
|
113
|
+
request, err := sceneengine.DecodeRequest(file)
|
|
114
|
+
if err != nil {
|
|
115
|
+
return request, err
|
|
116
|
+
}
|
|
117
|
+
for index := range request.Audio {
|
|
118
|
+
source := &request.Audio[index]
|
|
119
|
+
if !filepath.IsAbs(source.File) {
|
|
120
|
+
source.File = filepath.Join(filepath.Dir(path), source.File)
|
|
121
|
+
}
|
|
122
|
+
source.File, err = filepath.Abs(source.File)
|
|
123
|
+
if err != nil {
|
|
124
|
+
return request, fmt.Errorf("resolve audio[%d]: %w", index, err)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return request, nil
|
|
128
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"errors"
|
|
7
|
+
"io"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestVersionDoesNotRequireRenderingTools(t *testing.T) {
|
|
13
|
+
var stdout, stderr bytes.Buffer
|
|
14
|
+
err := run(context.Background(), cliOptions{printVersion: true}, &stdout, &stderr)
|
|
15
|
+
if err != nil {
|
|
16
|
+
t.Fatal(err)
|
|
17
|
+
}
|
|
18
|
+
if got, want := stdout.String(), "machinal-scene-render "+version+"\n"; got != want {
|
|
19
|
+
t.Fatalf("version output = %q, want %q", got, want)
|
|
20
|
+
}
|
|
21
|
+
if stderr.Len() != 0 {
|
|
22
|
+
t.Fatalf("unexpected diagnostics: %s", stderr.String())
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func TestVersionReportsWriteFailure(t *testing.T) {
|
|
27
|
+
err := run(context.Background(), cliOptions{printVersion: true}, failingWriter{}, io.Discard)
|
|
28
|
+
if !errors.Is(err, io.ErrClosedPipe) || !strings.Contains(err.Error(), "write renderer version") {
|
|
29
|
+
t.Fatalf("expected a wrapped write error, got %v", err)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type failingWriter struct{}
|
|
34
|
+
|
|
35
|
+
func (failingWriter) Write([]byte) (int, error) {
|
|
36
|
+
return 0, io.ErrClosedPipe
|
|
37
|
+
}
|