@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.
Files changed (113) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +201 -0
  3. package/LICENSING.md +13 -0
  4. package/NOTICE +8 -0
  5. package/README.md +183 -2
  6. package/bin/machinal-scenes.mjs +89 -0
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.js +2 -0
  9. package/dist/runtime/artifact.d.ts +18 -0
  10. package/dist/runtime/artifact.js +26 -0
  11. package/dist/runtime/bridge.d.ts +8 -0
  12. package/dist/runtime/bridge.js +76 -0
  13. package/dist/runtime/choreography.d.ts +32 -0
  14. package/dist/runtime/choreography.js +81 -0
  15. package/dist/runtime/controller.d.ts +5 -0
  16. package/dist/runtime/controller.js +93 -0
  17. package/dist/runtime/geometry.d.ts +20 -0
  18. package/dist/runtime/geometry.js +34 -0
  19. package/dist/runtime/index.d.ts +14 -0
  20. package/dist/runtime/index.js +14 -0
  21. package/dist/runtime/layout.d.ts +17 -0
  22. package/dist/runtime/layout.js +48 -0
  23. package/dist/runtime/math.d.ts +4 -0
  24. package/dist/runtime/math.js +23 -0
  25. package/dist/runtime/media.d.ts +2 -0
  26. package/dist/runtime/media.js +69 -0
  27. package/dist/runtime/models.d.ts +14 -0
  28. package/dist/runtime/models.js +370 -0
  29. package/dist/runtime/objects.d.ts +8 -0
  30. package/dist/runtime/objects.js +24 -0
  31. package/dist/runtime/parameters.d.ts +17 -0
  32. package/dist/runtime/parameters.js +35 -0
  33. package/dist/runtime/resources.d.ts +5 -0
  34. package/dist/runtime/resources.js +63 -0
  35. package/dist/runtime/types.d.ts +76 -0
  36. package/dist/runtime/types.js +1 -0
  37. package/dist/runtime/validation.d.ts +4 -0
  38. package/dist/runtime/validation.js +37 -0
  39. package/dist/runtime/video.d.ts +90 -0
  40. package/dist/runtime/video.js +46 -0
  41. package/dist/svelte/SceneCanvas.svelte +15 -0
  42. package/dist/svelte/SceneCanvas.svelte.d.ts +8 -0
  43. package/dist/svelte/SceneThree.svelte +61 -0
  44. package/dist/svelte/SceneThree.svelte.d.ts +10 -0
  45. package/dist/svelte/SceneVideo.svelte +38 -0
  46. package/dist/svelte/SceneVideo.svelte.d.ts +18 -0
  47. package/dist/svelte/context.d.ts +12 -0
  48. package/dist/svelte/context.js +8 -0
  49. package/dist/svelte/index.d.ts +9 -0
  50. package/dist/svelte/index.js +8 -0
  51. package/dist/svelte/model-assets.d.ts +4 -0
  52. package/dist/svelte/model-assets.js +42 -0
  53. package/dist/svelte/mount.svelte.d.ts +3 -0
  54. package/dist/svelte/mount.svelte.js +42 -0
  55. package/dist/svelte/three-rigs.d.ts +20 -0
  56. package/dist/svelte/three-rigs.js +22 -0
  57. package/dist/svelte/three-session.d.ts +6 -0
  58. package/dist/svelte/three-session.js +66 -0
  59. package/dist/svelte/three.d.ts +15 -0
  60. package/dist/svelte/three.js +33 -0
  61. package/examples/quickstart/gitignore +6 -0
  62. package/examples/quickstart/index.html +48 -0
  63. package/examples/quickstart/package.json +27 -0
  64. package/examples/quickstart/scene.config.json +11 -0
  65. package/examples/quickstart/scene.html +13 -0
  66. package/examples/quickstart/src/ExportPanel.svelte +74 -0
  67. package/examples/quickstart/src/Title.svelte +22 -0
  68. package/examples/quickstart/src/export-api.ts +30 -0
  69. package/examples/quickstart/src/main.ts +114 -0
  70. package/examples/quickstart/src/playback.ts +6 -0
  71. package/examples/quickstart/src/request.ts +5 -0
  72. package/examples/quickstart/src/scene.ts +12 -0
  73. package/examples/quickstart/src/useLocalExport.svelte.ts +46 -0
  74. package/examples/quickstart/src/workspace.css +79 -0
  75. package/examples/quickstart/svelte.config.js +1 -0
  76. package/examples/quickstart/tools/local-export.mjs +120 -0
  77. package/examples/quickstart/tsconfig.json +15 -0
  78. package/examples/quickstart/vite.config.js +17 -0
  79. package/native/THIRD_PARTY_NOTICES.txt +158 -0
  80. package/native/darwin-arm64/scene-render +0 -0
  81. package/native/darwin-x64/scene-render +0 -0
  82. package/native/linux-x64/scene-render +0 -0
  83. package/native/manifest.json +33 -0
  84. package/native/win32-x64/scene-render.exe +0 -0
  85. package/node/cli-output.mjs +10 -0
  86. package/node/download.mjs +37 -0
  87. package/node/ffmpeg-releases.mjs +24 -0
  88. package/node/index.d.ts +50 -0
  89. package/node/index.mjs +2 -0
  90. package/node/platforms.mjs +14 -0
  91. package/node/render.mjs +59 -0
  92. package/node/toolchain.mjs +118 -0
  93. package/package.json +89 -4
  94. package/renderer/browser.go +116 -0
  95. package/renderer/browser_log.go +36 -0
  96. package/renderer/browser_log_test.go +48 -0
  97. package/renderer/browser_test.go +307 -0
  98. package/renderer/cmd/scene-render/main.go +128 -0
  99. package/renderer/cmd/scene-render/main_test.go +37 -0
  100. package/renderer/cmd/scene-render/server.go +42 -0
  101. package/renderer/encoder.go +116 -0
  102. package/renderer/encoder_test.go +22 -0
  103. package/renderer/go.mod +11 -0
  104. package/renderer/go.sum +10 -0
  105. package/renderer/network.go +50 -0
  106. package/renderer/output.go +93 -0
  107. package/renderer/pipeline.go +108 -0
  108. package/renderer/pipeline_test.go +108 -0
  109. package/renderer/render.go +141 -0
  110. package/renderer/request.go +140 -0
  111. package/renderer/request_test.go +94 -0
  112. package/renderer/types.go +113 -0
  113. 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
+ }