@machinal/scenes 0.0.0-stage → 0.1.0
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 +16 -0
- package/LICENSE +201 -0
- package/LICENSING.md +13 -0
- package/NOTICE +8 -0
- package/README.md +152 -2
- package/bin/machinal-scenes.mjs +86 -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/index.html +20 -0
- package/examples/quickstart/package.json +26 -0
- package/examples/quickstart/scene.config.json +11 -0
- package/examples/quickstart/scene.html +13 -0
- package/examples/quickstart/src/Title.svelte +22 -0
- package/examples/quickstart/src/main.ts +86 -0
- package/examples/quickstart/src/request.ts +5 -0
- package/examples/quickstart/src/scene.ts +12 -0
- package/examples/quickstart/svelte.config.js +1 -0
- package/examples/quickstart/tsconfig.json +15 -0
- package/examples/quickstart/vite.config.js +15 -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/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 +113 -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 +118 -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,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,118 @@
|
|
|
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/viral-server/scene-engine/renderer"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
type cliOptions struct {
|
|
20
|
+
requestFile string
|
|
21
|
+
output string
|
|
22
|
+
serveDirectory string
|
|
23
|
+
renderer sceneengine.Options
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func main() {
|
|
27
|
+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
28
|
+
defer stop()
|
|
29
|
+
options := parseFlags()
|
|
30
|
+
if err := run(ctx, options, os.Stdout, os.Stderr); err != nil {
|
|
31
|
+
fmt.Fprintln(os.Stderr, err)
|
|
32
|
+
os.Exit(1)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func parseFlags() cliOptions {
|
|
37
|
+
var options cliOptions
|
|
38
|
+
var allowedOrigins string
|
|
39
|
+
flag.StringVar(&options.requestFile, "request", "", "JSON/YAML job file (required)")
|
|
40
|
+
flag.StringVar(&options.output, "output", "", "new .mp4 or .webm output file (required)")
|
|
41
|
+
flag.StringVar(&options.serveDirectory, "serve", "", "serve a built scene directory on an ephemeral loopback port")
|
|
42
|
+
flag.StringVar(&options.renderer.BrowserExecutable, "browser", os.Getenv("SCENE_BROWSER"), "pinned Chrome Headless Shell executable")
|
|
43
|
+
flag.StringVar(&options.renderer.FFmpegExecutable, "ffmpeg", "ffmpeg", "FFmpeg executable")
|
|
44
|
+
flag.IntVar(&options.renderer.Concurrency, "workers", 2, "concurrent pages (1–16)")
|
|
45
|
+
flag.IntVar(&options.renderer.EncoderThreads, "encoder-threads", 0, "encoder threads (0 = automatic, at most 32)")
|
|
46
|
+
flag.StringVar(&options.renderer.X264Preset, "x264-preset", "medium", "x264 speed/compression preset (does not select quality)")
|
|
47
|
+
flag.BoolVar(&options.renderer.Lossless, "lossless", false, "lossless intermediate encoding; MP4 retains RGB, WebM retains alpha")
|
|
48
|
+
flag.BoolVar(&options.renderer.EnableGPU, "gpu", false, "allow hardware GPU rendering (requires working device access)")
|
|
49
|
+
flag.DurationVar(&options.renderer.FrameTimeout, "frame-timeout", 30*time.Second, "timeout for page preparation or one frame")
|
|
50
|
+
flag.BoolVar(&options.renderer.NoSandbox, "no-sandbox", false, "disable Chromium sandbox for an externally isolated container")
|
|
51
|
+
flag.StringVar(&allowedOrigins, "allow-origins", "", "comma-separated additional resource origins")
|
|
52
|
+
flag.Parse()
|
|
53
|
+
if allowedOrigins != "" {
|
|
54
|
+
for _, origin := range strings.Split(allowedOrigins, ",") {
|
|
55
|
+
options.renderer.AllowedOrigins = append(options.renderer.AllowedOrigins, strings.TrimSpace(origin))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return options
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func run(ctx context.Context, options cliOptions, stdout, stderr io.Writer) error {
|
|
62
|
+
if options.requestFile == "" || options.output == "" {
|
|
63
|
+
return fmt.Errorf("-request and -output are required")
|
|
64
|
+
}
|
|
65
|
+
request, err := loadRequest(options.requestFile)
|
|
66
|
+
if err != nil {
|
|
67
|
+
return err
|
|
68
|
+
}
|
|
69
|
+
if options.serveDirectory != "" {
|
|
70
|
+
resolvedURL, closeServer, err := serveScene(options.serveDirectory, request.URL)
|
|
71
|
+
if err != nil {
|
|
72
|
+
return err
|
|
73
|
+
}
|
|
74
|
+
defer closeServer()
|
|
75
|
+
request.URL = resolvedURL
|
|
76
|
+
}
|
|
77
|
+
options.renderer.Progress = func(progress sceneengine.Progress) {
|
|
78
|
+
if progress.WrittenFrames == 1 || progress.WrittenFrames%30 == 0 || progress.WrittenFrames == progress.TotalFrames {
|
|
79
|
+
fmt.Fprintf(stderr, "frames %d/%d · %s\n", progress.WrittenFrames, progress.TotalFrames, progress.Elapsed.Round(time.Millisecond))
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
renderer, err := sceneengine.New(ctx, options.renderer)
|
|
83
|
+
if err != nil {
|
|
84
|
+
return err
|
|
85
|
+
}
|
|
86
|
+
defer renderer.Close()
|
|
87
|
+
result, err := renderer.Render(ctx, request, options.output)
|
|
88
|
+
if err != nil {
|
|
89
|
+
return err
|
|
90
|
+
}
|
|
91
|
+
if err := json.NewEncoder(stdout).Encode(result); err != nil {
|
|
92
|
+
return fmt.Errorf("write result JSON: %w", err)
|
|
93
|
+
}
|
|
94
|
+
return nil
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func loadRequest(path string) (sceneengine.Request, error) {
|
|
98
|
+
file, err := os.Open(path)
|
|
99
|
+
if err != nil {
|
|
100
|
+
return sceneengine.Request{}, fmt.Errorf("open request: %w", err)
|
|
101
|
+
}
|
|
102
|
+
defer file.Close()
|
|
103
|
+
request, err := sceneengine.DecodeRequest(file)
|
|
104
|
+
if err != nil {
|
|
105
|
+
return request, err
|
|
106
|
+
}
|
|
107
|
+
for index := range request.Audio {
|
|
108
|
+
source := &request.Audio[index]
|
|
109
|
+
if !filepath.IsAbs(source.File) {
|
|
110
|
+
source.File = filepath.Join(filepath.Dir(path), source.File)
|
|
111
|
+
}
|
|
112
|
+
source.File, err = filepath.Abs(source.File)
|
|
113
|
+
if err != nil {
|
|
114
|
+
return request, fmt.Errorf("resolve audio[%d]: %w", index, err)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return request, nil
|
|
118
|
+
}
|
|
@@ -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/viral-server/scene-engine/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
|
+
}
|