@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,141 @@
1
+ package sceneengine
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "reflect"
9
+ "strings"
10
+ "sync"
11
+ "time"
12
+ )
13
+
14
+ // Render publishes an asset only after its complete video/audio encoding succeeds.
15
+ func (r *Renderer) Render(ctx context.Context, request Request, output string) (result Result, renderErr error) {
16
+ if err := request.Validate(); err != nil {
17
+ return Result{}, err
18
+ }
19
+ codec, err := outputCodec(output, request.Config.Transparent)
20
+ if err != nil {
21
+ return Result{}, err
22
+ }
23
+ select {
24
+ case r.lease <- struct{}{}:
25
+ defer func() { <-r.lease }()
26
+ default:
27
+ return Result{}, fmt.Errorf("renderer is busy")
28
+ }
29
+ defer func() { renderErr = r.browserLog.wrap(renderErr) }()
30
+ ctx, cancel := context.WithCancel(ctx)
31
+ defer cancel()
32
+ stop := context.AfterFunc(r.browser, cancel)
33
+ defer stop()
34
+ started := time.Now()
35
+ staged, err := stageOutput(output)
36
+ if err != nil {
37
+ return Result{}, err
38
+ }
39
+ defer os.Remove(staged)
40
+ workers, err := r.loadWorkers(ctx, request)
41
+ if err != nil {
42
+ return Result{}, err
43
+ }
44
+ defer closeWorkers(workers)
45
+ metrics := RenderMetrics{Workers: len(workers), Load: time.Since(started)}
46
+ process, err := startEncoder(ctx, r.options, request, codec, staged)
47
+ if err != nil {
48
+ return Result{}, err
49
+ }
50
+ captures := make([]captureFunc, len(workers))
51
+ for index, worker := range workers {
52
+ captures[index] = worker.capture
53
+ }
54
+ streamStarted := time.Now()
55
+ err = streamFrames(ctx, request.Config.DurationInFrames, captures, process.input, func(written int) {
56
+ if r.options.Progress != nil {
57
+ r.options.Progress(Progress{WrittenFrames: written, TotalFrames: request.Config.DurationInFrames, Elapsed: time.Since(started)})
58
+ }
59
+ })
60
+ metrics.CaptureAndEncode = time.Since(streamStarted)
61
+ if err != nil {
62
+ cancel()
63
+ encodingErr := process.finish()
64
+ if encodingErr != nil {
65
+ return Result{}, fmt.Errorf("%w; encoder: %v", err, encodingErr)
66
+ }
67
+ return Result{}, err
68
+ }
69
+ drainStarted := time.Now()
70
+ if err := process.finish(); err != nil {
71
+ return Result{}, err
72
+ }
73
+ metrics.EncoderDrain = time.Since(drainStarted)
74
+ for _, worker := range workers {
75
+ metrics.SeekWorkerTime += worker.seekTime
76
+ metrics.CaptureWorkerTime += worker.captureTime
77
+ }
78
+ if err := ctx.Err(); err != nil {
79
+ return Result{}, err
80
+ }
81
+ return publishOutput(staged, output, request, workers[0].manifest, codec, time.Since(started), metrics)
82
+ }
83
+
84
+ func outputCodec(output string, transparent bool) (Codec, error) {
85
+ switch strings.ToLower(filepath.Ext(output)) {
86
+ case ".mp4":
87
+ if transparent {
88
+ return "", fmt.Errorf("transparent output requires .webm (VP9), not MP4")
89
+ }
90
+ return H264, nil
91
+ case ".webm":
92
+ return VP9, nil
93
+ default:
94
+ return "", fmt.Errorf("output extension must be .mp4 or .webm")
95
+ }
96
+ }
97
+
98
+ func (r *Renderer) loadWorkers(ctx context.Context, request Request) ([]*frameWorker, error) {
99
+ count := min(r.options.Concurrency, request.Config.DurationInFrames)
100
+ workers := make([]*frameWorker, count)
101
+ loadCtx, cancel := context.WithCancelCause(ctx)
102
+ var loading sync.WaitGroup
103
+ for index := range count {
104
+ loading.Go(func() {
105
+ worker, err := r.newWorker(loadCtx, request)
106
+ if err != nil {
107
+ cancel(fmt.Errorf("initialize worker %d: %w", index, err))
108
+ return
109
+ }
110
+ workers[index] = worker
111
+ })
112
+ }
113
+ loading.Wait()
114
+ if err := context.Cause(loadCtx); err != nil {
115
+ closeWorkers(workers)
116
+ cancel(err)
117
+ return nil, err
118
+ }
119
+ for _, worker := range workers[1:] {
120
+ if !reflect.DeepEqual(worker.manifest, workers[0].manifest) {
121
+ closeWorkers(workers)
122
+ cancel(context.Canceled)
123
+ return nil, fmt.Errorf("scene manifests differ between workers")
124
+ }
125
+ }
126
+ // Keep successful tabs alive until the job releases its workers.
127
+ closeFirst := workers[0].close
128
+ workers[0].close = func() {
129
+ cancel(context.Canceled)
130
+ closeFirst()
131
+ }
132
+ return workers, nil
133
+ }
134
+
135
+ func closeWorkers(workers []*frameWorker) {
136
+ for _, worker := range workers {
137
+ if worker != nil {
138
+ worker.close()
139
+ }
140
+ }
141
+ }
@@ -0,0 +1,140 @@
1
+ package sceneengine
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "math"
9
+ "net/url"
10
+ "strings"
11
+
12
+ "gopkg.in/yaml.v3"
13
+ )
14
+
15
+ const (
16
+ // Embedded originals: four 5 MiB images and two 20 MiB videos plus base64 expansion.
17
+ MaxRequestBytes = 96 << 20
18
+ maxDimension = 8192
19
+ maxFPS = 240
20
+ maxFrames = 1_000_000
21
+ maxAudioTracks = 64
22
+ maxVolume = 4
23
+ maxDurationSeconds = 24 * 60 * 60
24
+ )
25
+
26
+ // DecodeRequest rejects unknown fields, trailing documents and oversized input.
27
+ func DecodeRequest(reader io.Reader) (Request, error) {
28
+ data, err := io.ReadAll(io.LimitReader(reader, MaxRequestBytes+1))
29
+ if err != nil {
30
+ return Request{}, fmt.Errorf("read scene request: %w", err)
31
+ }
32
+ if len(data) > MaxRequestBytes {
33
+ return Request{}, fmt.Errorf("scene request exceeds %d bytes", MaxRequestBytes)
34
+ }
35
+ var request Request
36
+ if bytes.HasPrefix(bytes.TrimSpace(data), []byte("{")) {
37
+ decoder := json.NewDecoder(bytes.NewReader(data))
38
+ decoder.DisallowUnknownFields()
39
+ if err := decoder.Decode(&request); err != nil {
40
+ return request, fmt.Errorf("decode scene JSON: %w", err)
41
+ }
42
+ if err := decoder.Decode(new(any)); err != io.EOF {
43
+ return request, fmt.Errorf("scene JSON must contain one document")
44
+ }
45
+ } else {
46
+ decoder := yaml.NewDecoder(bytes.NewReader(data))
47
+ decoder.KnownFields(true)
48
+ if err := decoder.Decode(&request); err != nil {
49
+ return request, fmt.Errorf("decode scene YAML: %w", err)
50
+ }
51
+ if err := decoder.Decode(new(any)); err != io.EOF {
52
+ return request, fmt.Errorf("scene YAML must contain one document")
53
+ }
54
+ }
55
+ if _, err := request.browserPayload(); err != nil {
56
+ return request, fmt.Errorf("props must contain JSON-compatible values: %w", err)
57
+ }
58
+ return request, nil
59
+ }
60
+
61
+ func (r Request) Validate() error {
62
+ if r.Version != ProtocolVersion {
63
+ return fmt.Errorf("unsupported scene protocol version %d", r.Version)
64
+ }
65
+ if strings.TrimSpace(r.SceneID) == "" {
66
+ return fmt.Errorf("sceneId is required")
67
+ }
68
+ if _, err := parseOrigin(r.URL); err != nil {
69
+ return fmt.Errorf("scene URL: %w", err)
70
+ }
71
+ if err := r.Config.validate(); err != nil {
72
+ return err
73
+ }
74
+ if len(r.Audio) > maxAudioTracks {
75
+ return fmt.Errorf("at most %d audio tracks are supported", maxAudioTracks)
76
+ }
77
+ for index, track := range r.Audio {
78
+ if err := track.validate(r.Config.Duration()); err != nil {
79
+ return fmt.Errorf("audio[%d]: %w", index, err)
80
+ }
81
+ }
82
+ if _, err := r.browserPayload(); err != nil {
83
+ return fmt.Errorf("serialize scene props: %w", err)
84
+ }
85
+ return nil
86
+ }
87
+
88
+ func (c Config) validate() error {
89
+ if c.Width <= 0 || c.Height <= 0 || c.Width > maxDimension || c.Height > maxDimension {
90
+ return fmt.Errorf("width and height must be between 1 and %d", maxDimension)
91
+ }
92
+ if c.Width%2 != 0 || c.Height%2 != 0 {
93
+ return fmt.Errorf("video width and height must be even")
94
+ }
95
+ if !finite(c.FPS) || c.FPS <= 0 || c.FPS > maxFPS {
96
+ return fmt.Errorf("fps must be between 0 and %d", maxFPS)
97
+ }
98
+ if c.DurationInFrames <= 0 || c.DurationInFrames > maxFrames {
99
+ return fmt.Errorf("durationInFrames must be between 1 and %d", maxFrames)
100
+ }
101
+ if !finite(c.Duration()) || c.Duration() > maxDurationSeconds {
102
+ return fmt.Errorf("scene duration must not exceed %d seconds", maxDurationSeconds)
103
+ }
104
+ if !finite(c.CaptionSafeBottom) || c.CaptionSafeBottom < 0 || c.CaptionSafeBottom > 0.5 {
105
+ return fmt.Errorf("captionSafeBottom must be between 0 and 0.5")
106
+ }
107
+ return nil
108
+ }
109
+
110
+ func (a AudioTrack) validate(totalDuration float64) error {
111
+ if strings.TrimSpace(a.File) == "" {
112
+ return fmt.Errorf("file is required")
113
+ }
114
+ if !finite(a.Start) || a.Start < 0 || a.Start >= totalDuration {
115
+ return fmt.Errorf("start must fall inside the composition")
116
+ }
117
+ if !finite(a.From) || a.From < 0 {
118
+ return fmt.Errorf("from must be nonnegative")
119
+ }
120
+ if !finite(a.Duration) || a.Duration <= 0 {
121
+ return fmt.Errorf("duration must be positive")
122
+ }
123
+ if a.Volume != nil && (!finite(*a.Volume) || *a.Volume < 0 || *a.Volume > maxVolume) {
124
+ return fmt.Errorf("volume must be between 0 and %d", maxVolume)
125
+ }
126
+ return nil
127
+ }
128
+
129
+ func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
130
+
131
+ func parseOrigin(raw string) (string, error) {
132
+ parsed, err := url.Parse(raw)
133
+ if err != nil {
134
+ return "", err
135
+ }
136
+ if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil {
137
+ return "", fmt.Errorf("expected an absolute HTTP(S) URL without credentials")
138
+ }
139
+ return strings.ToLower(parsed.Scheme + "://" + parsed.Host), nil
140
+ }
@@ -0,0 +1,94 @@
1
+ package sceneengine
2
+
3
+ import (
4
+ "math"
5
+ "strings"
6
+ "testing"
7
+ )
8
+
9
+ func fixtureRequest() Request {
10
+ return Request{Version: 1, URL: "http://127.0.0.1:5178/scene.html", SceneID: "intro", Config: Config{Width: 640, Height: 360, FPS: 30, DurationInFrames: 90}}
11
+ }
12
+
13
+ func TestDecodeRequestRejectsAmbiguousInput(t *testing.T) {
14
+ for _, input := range []string{
15
+ `{"version":1,"unexpected":true}`,
16
+ `{"version":1} {"version":1}`,
17
+ "version: 1\n---\nversion: 1",
18
+ "version: 1\nversion: 2",
19
+ "version: 1\nunexpected: true",
20
+ } {
21
+ if _, err := DecodeRequest(strings.NewReader(input)); err == nil {
22
+ t.Errorf("accepted ambiguous request %q", input)
23
+ }
24
+ }
25
+ }
26
+
27
+ func TestDecodeYAMLPropsAreJSONCompatible(t *testing.T) {
28
+ request, err := DecodeRequest(strings.NewReader("version: 1\nprops:\n title: Example\n items: [1, two]\n"))
29
+ if err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ if _, err := request.browserPayload(); err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ }
36
+
37
+ func TestRequestValidation(t *testing.T) {
38
+ cases := map[string]func(*Request){
39
+ "odd dimensions": func(r *Request) { r.Config.Width = 641 },
40
+ "invalid fps": func(r *Request) { r.Config.FPS = math.NaN() },
41
+ "overflow duration": func(r *Request) { r.Config.FPS = math.SmallestNonzeroFloat64 },
42
+ "empty duration": func(r *Request) { r.Config.DurationInFrames = 0 },
43
+ "file URL": func(r *Request) { r.URL = "file:///secret" },
44
+ "credentials": func(r *Request) { r.URL = "https://user:pass@example.com" },
45
+ "invalid audio": func(r *Request) { r.Audio = []AudioTrack{{File: "a.wav", Start: -1, Duration: 1}} },
46
+ "invalid caption band": func(r *Request) { r.Config.CaptionSafeBottom = 0.7 },
47
+ }
48
+ for name, mutate := range cases {
49
+ t.Run(name, func(t *testing.T) {
50
+ request := fixtureRequest()
51
+ mutate(&request)
52
+ if err := request.Validate(); err == nil {
53
+ t.Fatal("accepted invalid request")
54
+ }
55
+ })
56
+ }
57
+ if err := fixtureRequest().Validate(); err != nil {
58
+ t.Fatal(err)
59
+ }
60
+ }
61
+
62
+ func TestCaptionSafeBandSurvivesYAMLAndBrowserPayload(t *testing.T) {
63
+ request, err := DecodeRequest(strings.NewReader("version: 1\nconfig:\n width: 1920\n height: 1080\n fps: 30\n durationInFrames: 30\n captionSafeBottom: 0.24\n"))
64
+ if err != nil || request.Config.CaptionSafeBottom != 0.24 {
65
+ t.Fatal(request, err)
66
+ }
67
+ payload, err := request.browserPayload()
68
+ if err != nil || !strings.Contains(string(payload), `"captionSafeBottom":0.24`) {
69
+ t.Fatal(string(payload), err)
70
+ }
71
+ }
72
+
73
+ func TestResourceOrigins(t *testing.T) {
74
+ allowed := map[string]bool{"http://127.0.0.1:5178": true}
75
+ for _, request := range []string{"http://127.0.0.1:5178/font.woff2", "data:image/png;base64,AA==", "blob:http://127.0.0.1:5178/id"} {
76
+ if !allowRequest(request, allowed) {
77
+ t.Errorf("blocked %s", request)
78
+ }
79
+ }
80
+ for _, request := range []string{"http://127.0.0.1:8080/private", "file:///secret", "https://example.com/font.woff2", "http://127.0.0.1:5178@evil.test"} {
81
+ if allowRequest(request, allowed) {
82
+ t.Errorf("allowed %s", request)
83
+ }
84
+ }
85
+ }
86
+
87
+ func TestAlphaRequiresWebM(t *testing.T) {
88
+ if _, err := outputCodec("scene.mp4", true); err == nil {
89
+ t.Fatal("accepted alpha MP4")
90
+ }
91
+ if codec, err := outputCodec("scene.webm", true); err != nil || codec != VP9 {
92
+ t.Fatalf("expected VP9, got %s, %v", codec, err)
93
+ }
94
+ }
@@ -0,0 +1,113 @@
1
+ package sceneengine
2
+
3
+ import (
4
+ "encoding/json"
5
+ "time"
6
+ )
7
+
8
+ const ProtocolVersion = 1
9
+
10
+ // Config uses frame indexes for duration and seconds only for FPS conversion.
11
+ type Config struct {
12
+ Width int `json:"width" yaml:"width"`
13
+ Height int `json:"height" yaml:"height"`
14
+ FPS float64 `json:"fps" yaml:"fps"`
15
+ DurationInFrames int `json:"durationInFrames" yaml:"durationInFrames"`
16
+ Transparent bool `json:"transparent,omitempty" yaml:"transparent,omitempty"`
17
+ CaptionSafeBottom float64 `json:"captionSafeBottom,omitempty" yaml:"captionSafeBottom,omitempty"`
18
+ }
19
+
20
+ func (c Config) Duration() float64 { return float64(c.DurationInFrames) / c.FPS }
21
+
22
+ // AudioTrack describes a local source; the scene's browser never plays this audio.
23
+ type AudioTrack struct {
24
+ File string `json:"file" yaml:"file"`
25
+ Start float64 `json:"start" yaml:"start"`
26
+ From float64 `json:"from,omitempty" yaml:"from,omitempty"`
27
+ Duration float64 `json:"duration" yaml:"duration"`
28
+ Volume *float64 `json:"volume,omitempty" yaml:"volume,omitempty"`
29
+ }
30
+
31
+ // Request is a portable JSON/YAML render job. Relative audio paths are resolved by the CLI.
32
+ type Request struct {
33
+ Version int `json:"version" yaml:"version"`
34
+ URL string `json:"url" yaml:"url"`
35
+ SceneID string `json:"sceneId" yaml:"sceneId"`
36
+ Seed string `json:"seed,omitempty" yaml:"seed,omitempty"`
37
+ Config Config `json:"config" yaml:"config"`
38
+ Props map[string]any `json:"props,omitempty" yaml:"props,omitempty"`
39
+ Audio []AudioTrack `json:"audio,omitempty" yaml:"audio,omitempty"`
40
+ }
41
+
42
+ // Manifest records the resolved scene parameters reported by the browser runtime.
43
+ type Manifest struct {
44
+ Version int `json:"version"`
45
+ SceneID string `json:"sceneId"`
46
+ Seed string `json:"seed"`
47
+ Config Config `json:"config"`
48
+ Props map[string]any `json:"props"`
49
+ }
50
+
51
+ type Codec string
52
+
53
+ const (
54
+ H264 Codec = "h264"
55
+ VP9 Codec = "vp9"
56
+ )
57
+
58
+ // Result is the asset boundary between this module and an application.
59
+ type Result struct {
60
+ Version int `json:"version"`
61
+ File string `json:"file"`
62
+ SHA256 string `json:"sha256"`
63
+ Size int64 `json:"size"`
64
+ Duration float64 `json:"duration"`
65
+ Width int `json:"width"`
66
+ Height int `json:"height"`
67
+ FPS float64 `json:"fps"`
68
+ Frames int `json:"frames"`
69
+ HasAudio bool `json:"hasAudio"`
70
+ Transparent bool `json:"transparent"`
71
+ Codec Codec `json:"codec"`
72
+ Scene Manifest `json:"scene"`
73
+ Elapsed time.Duration `json:"elapsedNanoseconds"`
74
+ Metrics RenderMetrics `json:"metrics"`
75
+ }
76
+
77
+ type RenderMetrics struct {
78
+ Workers int `json:"workers"`
79
+ Load time.Duration `json:"loadNanoseconds"`
80
+ CaptureAndEncode time.Duration `json:"captureAndEncodeNanoseconds"`
81
+ EncoderDrain time.Duration `json:"encoderDrainNanoseconds"`
82
+ SeekWorkerTime time.Duration `json:"seekWorkerNanoseconds"`
83
+ CaptureWorkerTime time.Duration `json:"captureWorkerNanoseconds"`
84
+ }
85
+
86
+ type Progress struct {
87
+ WrittenFrames int `json:"writtenFrames"`
88
+ TotalFrames int `json:"totalFrames"`
89
+ Elapsed time.Duration `json:"elapsedNanoseconds"`
90
+ }
91
+
92
+ // Options configures browser ownership and the bounded frame pipeline.
93
+ type Options struct {
94
+ BrowserExecutable string
95
+ FFmpegExecutable string
96
+ Concurrency int
97
+ FrameTimeout time.Duration
98
+ AllowedOrigins []string
99
+ NoSandbox bool
100
+ EnableGPU bool
101
+ Lossless bool
102
+ X264Preset string
103
+ EncoderThreads int
104
+ Progress func(Progress)
105
+ }
106
+
107
+ func (r Request) browserPayload() ([]byte, error) {
108
+ props := r.Props
109
+ if props == nil {
110
+ props = map[string]any{}
111
+ }
112
+ return json.Marshal(Manifest{Version: r.Version, SceneID: r.SceneID, Seed: r.Seed, Config: r.Config, Props: props})
113
+ }
@@ -0,0 +1,110 @@
1
+ package sceneengine
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "time"
7
+
8
+ "github.com/chromedp/cdproto/cdp"
9
+ "github.com/chromedp/cdproto/emulation"
10
+ "github.com/chromedp/cdproto/page"
11
+ "github.com/chromedp/chromedp"
12
+ )
13
+
14
+ type frameWorker struct {
15
+ ctx context.Context
16
+ close func()
17
+ request Request
18
+ manifest Manifest
19
+ options Options
20
+ cancel context.CancelFunc
21
+ seekTime time.Duration
22
+ captureTime time.Duration
23
+ }
24
+
25
+ func (r *Renderer) newWorker(job context.Context, request Request) (*frameWorker, error) {
26
+ tab, cancel := chromedp.NewContext(r.browser)
27
+ stop := context.AfterFunc(job, cancel)
28
+ worker := &frameWorker{ctx: tab, close: func() { stop(); cancel() }, cancel: cancel, request: request, options: r.options}
29
+ // The first action owns the target's event loop, so it must use the tab's
30
+ // lifetime context rather than the short-lived page preparation context.
31
+ timeout := time.AfterFunc(r.options.FrameTimeout, cancel)
32
+ err := chromedp.Do(tab)
33
+ timeout.Stop()
34
+ if err != nil {
35
+ worker.close()
36
+ return nil, fmt.Errorf("create worker tab: %w", err)
37
+ }
38
+ if err := worker.load(); err != nil {
39
+ worker.close()
40
+ return nil, err
41
+ }
42
+ return worker, nil
43
+ }
44
+
45
+ func (w *frameWorker) load() error {
46
+ payload, err := w.request.browserPayload()
47
+ if err != nil {
48
+ return fmt.Errorf("serialize browser request: %w", err)
49
+ }
50
+ ctx, cancel := context.WithTimeout(w.ctx, w.options.FrameTimeout)
51
+ defer cancel()
52
+ err = chromedp.Do(ctx,
53
+ chromedp.EmulateViewport(int64(w.request.Config.Width), int64(w.request.Config.Height)),
54
+ chromedp.Func(func(ctx context.Context, target *chromedp.Target) error {
55
+ if !w.request.Config.Transparent {
56
+ return nil
57
+ }
58
+ // Explicit zero alpha is omitted by the generated protocol type.
59
+ return target.Call(ctx, emulation.SetDefaultBackgroundColorOverride.Method, map[string]any{"color": map[string]any{"r": 0, "g": 0, "b": 0, "a": 0}}, nil)
60
+ }),
61
+ restrictNetwork(w.ctx, w.request.URL, w.options.AllowedOrigins, w.cancel),
62
+ chromedp.Func(func(ctx context.Context, target *chromedp.Target) error {
63
+ _, err := cdp.Call(ctx, target, page.AddScriptToEvaluateOnNewDocument, page.AddScriptToEvaluateOnNewDocumentParams{Source: "window.__sceneRequest = " + string(payload) + ";"})
64
+ return err
65
+ }),
66
+ chromedp.Navigate(w.request.URL),
67
+ chromedp.Poll[chromedp.Void]("Boolean(window.__scene || window.__sceneError)", chromedp.WithPollingTimeout(w.options.FrameTimeout)),
68
+ )
69
+ if err != nil {
70
+ return fmt.Errorf("prepare scene page: %w", err)
71
+ }
72
+ w.manifest, err = chromedp.Run(ctx, chromedp.Evaluate[Manifest](`(async () => {
73
+ if (window.__sceneError) throw new Error(window.__sceneError);
74
+ if (window.__scene.version !== 1) throw new Error('Unsupported scene protocol');
75
+ await window.__scene.ready();
76
+ return window.__scene.manifest;
77
+ })()`, chromedp.EvalAwaitPromise))
78
+ if err != nil {
79
+ return fmt.Errorf("load scene %q: %w", w.request.SceneID, err)
80
+ }
81
+ if w.manifest.Version != ProtocolVersion || w.manifest.SceneID != w.request.SceneID || w.manifest.Config != w.request.Config {
82
+ return fmt.Errorf("scene manifest does not match the render request")
83
+ }
84
+ return nil
85
+ }
86
+
87
+ func (w *frameWorker) capture(job context.Context, frame int) ([]byte, error) {
88
+ ctx, cancel := context.WithTimeout(w.ctx, w.options.FrameTimeout)
89
+ defer cancel()
90
+ stop := context.AfterFunc(job, cancel)
91
+ defer stop()
92
+ started := time.Now()
93
+ err := chromedp.Do(ctx, chromedp.Evaluate[chromedp.Void](fmt.Sprintf("window.__scene.seek(%d)", frame), chromedp.EvalAwaitPromise))
94
+ w.seekTime += time.Since(started)
95
+ if err != nil {
96
+ return nil, fmt.Errorf("seek frame %d: %w", frame, err)
97
+ }
98
+ started = time.Now()
99
+ buffer, err := chromedp.Run(ctx, func(ctx context.Context, target *chromedp.Target) ([]byte, error) {
100
+ beyondViewport, optimize := false, true
101
+ params := page.CaptureScreenshotParams{Format: page.CaptureScreenshotFormatPng, CaptureBeyondViewport: &beyondViewport, OptimizeForSpeed: &optimize}
102
+ result, err := cdp.Call(ctx, target, page.CaptureScreenshot, params)
103
+ return result.Data, err
104
+ })
105
+ w.captureTime += time.Since(started)
106
+ if err != nil {
107
+ return nil, fmt.Errorf("capture frame %d: %w", frame, err)
108
+ }
109
+ return buffer, nil
110
+ }