@machinal/scenes 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/LICENSE +201 -0
- package/LICENSING.md +13 -0
- package/NOTICE +8 -0
- package/README.md +183 -2
- package/bin/machinal-scenes.mjs +89 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/runtime/artifact.d.ts +18 -0
- package/dist/runtime/artifact.js +26 -0
- package/dist/runtime/bridge.d.ts +8 -0
- package/dist/runtime/bridge.js +76 -0
- package/dist/runtime/choreography.d.ts +32 -0
- package/dist/runtime/choreography.js +81 -0
- package/dist/runtime/controller.d.ts +5 -0
- package/dist/runtime/controller.js +93 -0
- package/dist/runtime/geometry.d.ts +20 -0
- package/dist/runtime/geometry.js +34 -0
- package/dist/runtime/index.d.ts +14 -0
- package/dist/runtime/index.js +14 -0
- package/dist/runtime/layout.d.ts +17 -0
- package/dist/runtime/layout.js +48 -0
- package/dist/runtime/math.d.ts +4 -0
- package/dist/runtime/math.js +23 -0
- package/dist/runtime/media.d.ts +2 -0
- package/dist/runtime/media.js +69 -0
- package/dist/runtime/models.d.ts +14 -0
- package/dist/runtime/models.js +370 -0
- package/dist/runtime/objects.d.ts +8 -0
- package/dist/runtime/objects.js +24 -0
- package/dist/runtime/parameters.d.ts +17 -0
- package/dist/runtime/parameters.js +35 -0
- package/dist/runtime/resources.d.ts +5 -0
- package/dist/runtime/resources.js +63 -0
- package/dist/runtime/types.d.ts +76 -0
- package/dist/runtime/types.js +1 -0
- package/dist/runtime/validation.d.ts +4 -0
- package/dist/runtime/validation.js +37 -0
- package/dist/runtime/video.d.ts +90 -0
- package/dist/runtime/video.js +46 -0
- package/dist/svelte/SceneCanvas.svelte +15 -0
- package/dist/svelte/SceneCanvas.svelte.d.ts +8 -0
- package/dist/svelte/SceneThree.svelte +61 -0
- package/dist/svelte/SceneThree.svelte.d.ts +10 -0
- package/dist/svelte/SceneVideo.svelte +38 -0
- package/dist/svelte/SceneVideo.svelte.d.ts +18 -0
- package/dist/svelte/context.d.ts +12 -0
- package/dist/svelte/context.js +8 -0
- package/dist/svelte/index.d.ts +9 -0
- package/dist/svelte/index.js +8 -0
- package/dist/svelte/model-assets.d.ts +4 -0
- package/dist/svelte/model-assets.js +42 -0
- package/dist/svelte/mount.svelte.d.ts +3 -0
- package/dist/svelte/mount.svelte.js +42 -0
- package/dist/svelte/three-rigs.d.ts +20 -0
- package/dist/svelte/three-rigs.js +22 -0
- package/dist/svelte/three-session.d.ts +6 -0
- package/dist/svelte/three-session.js +66 -0
- package/dist/svelte/three.d.ts +15 -0
- package/dist/svelte/three.js +33 -0
- package/examples/quickstart/gitignore +6 -0
- package/examples/quickstart/index.html +48 -0
- package/examples/quickstart/package.json +27 -0
- package/examples/quickstart/scene.config.json +11 -0
- package/examples/quickstart/scene.html +13 -0
- package/examples/quickstart/src/ExportPanel.svelte +74 -0
- package/examples/quickstart/src/Title.svelte +22 -0
- package/examples/quickstart/src/export-api.ts +30 -0
- package/examples/quickstart/src/main.ts +114 -0
- package/examples/quickstart/src/playback.ts +6 -0
- package/examples/quickstart/src/request.ts +5 -0
- package/examples/quickstart/src/scene.ts +12 -0
- package/examples/quickstart/src/useLocalExport.svelte.ts +46 -0
- package/examples/quickstart/src/workspace.css +79 -0
- package/examples/quickstart/svelte.config.js +1 -0
- package/examples/quickstart/tools/local-export.mjs +120 -0
- package/examples/quickstart/tsconfig.json +15 -0
- package/examples/quickstart/vite.config.js +17 -0
- package/native/THIRD_PARTY_NOTICES.txt +158 -0
- package/native/darwin-arm64/scene-render +0 -0
- package/native/darwin-x64/scene-render +0 -0
- package/native/linux-x64/scene-render +0 -0
- package/native/manifest.json +33 -0
- package/native/win32-x64/scene-render.exe +0 -0
- package/node/cli-output.mjs +10 -0
- package/node/download.mjs +37 -0
- package/node/ffmpeg-releases.mjs +24 -0
- package/node/index.d.ts +50 -0
- package/node/index.mjs +2 -0
- package/node/platforms.mjs +14 -0
- package/node/render.mjs +59 -0
- package/node/toolchain.mjs +118 -0
- package/package.json +89 -4
- package/renderer/browser.go +116 -0
- package/renderer/browser_log.go +36 -0
- package/renderer/browser_log_test.go +48 -0
- package/renderer/browser_test.go +307 -0
- package/renderer/cmd/scene-render/main.go +128 -0
- package/renderer/cmd/scene-render/main_test.go +37 -0
- package/renderer/cmd/scene-render/server.go +42 -0
- package/renderer/encoder.go +116 -0
- package/renderer/encoder_test.go +22 -0
- package/renderer/go.mod +11 -0
- package/renderer/go.sum +10 -0
- package/renderer/network.go +50 -0
- package/renderer/output.go +93 -0
- package/renderer/pipeline.go +108 -0
- package/renderer/pipeline_test.go +108 -0
- package/renderer/render.go +141 -0
- package/renderer/request.go +140 -0
- package/renderer/request_test.go +94 -0
- package/renderer/types.go +113 -0
- package/renderer/worker.go +110 -0
|
@@ -0,0 +1,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
|
+
}
|