@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +201 -0
  3. package/LICENSING.md +13 -0
  4. package/NOTICE +8 -0
  5. package/README.md +152 -2
  6. package/bin/machinal-scenes.mjs +86 -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/index.html +20 -0
  62. package/examples/quickstart/package.json +26 -0
  63. package/examples/quickstart/scene.config.json +11 -0
  64. package/examples/quickstart/scene.html +13 -0
  65. package/examples/quickstart/src/Title.svelte +22 -0
  66. package/examples/quickstart/src/main.ts +86 -0
  67. package/examples/quickstart/src/request.ts +5 -0
  68. package/examples/quickstart/src/scene.ts +12 -0
  69. package/examples/quickstart/svelte.config.js +1 -0
  70. package/examples/quickstart/tsconfig.json +15 -0
  71. package/examples/quickstart/vite.config.js +15 -0
  72. package/native/THIRD_PARTY_NOTICES.txt +158 -0
  73. package/native/darwin-arm64/scene-render +0 -0
  74. package/native/darwin-x64/scene-render +0 -0
  75. package/native/linux-x64/scene-render +0 -0
  76. package/native/manifest.json +33 -0
  77. package/native/win32-x64/scene-render.exe +0 -0
  78. package/node/download.mjs +37 -0
  79. package/node/ffmpeg-releases.mjs +24 -0
  80. package/node/index.d.ts +50 -0
  81. package/node/index.mjs +2 -0
  82. package/node/platforms.mjs +14 -0
  83. package/node/render.mjs +59 -0
  84. package/node/toolchain.mjs +113 -0
  85. package/package.json +89 -4
  86. package/renderer/browser.go +116 -0
  87. package/renderer/browser_log.go +36 -0
  88. package/renderer/browser_log_test.go +48 -0
  89. package/renderer/browser_test.go +307 -0
  90. package/renderer/cmd/scene-render/main.go +118 -0
  91. package/renderer/cmd/scene-render/server.go +42 -0
  92. package/renderer/encoder.go +116 -0
  93. package/renderer/encoder_test.go +22 -0
  94. package/renderer/go.mod +11 -0
  95. package/renderer/go.sum +10 -0
  96. package/renderer/network.go +50 -0
  97. package/renderer/output.go +93 -0
  98. package/renderer/pipeline.go +108 -0
  99. package/renderer/pipeline_test.go +108 -0
  100. package/renderer/render.go +141 -0
  101. package/renderer/request.go +140 -0
  102. package/renderer/request_test.go +94 -0
  103. package/renderer/types.go +113 -0
  104. 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
+ }
@@ -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
@@ -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
+ }