@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,93 @@
1
+ package sceneengine
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "os"
10
+ "path/filepath"
11
+ "time"
12
+ )
13
+
14
+ func stageOutput(output string) (string, error) {
15
+ if _, err := os.Stat(output); err == nil {
16
+ return "", fmt.Errorf("output already exists: %s", output)
17
+ } else if !os.IsNotExist(err) {
18
+ return "", fmt.Errorf("inspect output: %w", err)
19
+ }
20
+ if _, err := os.Stat(output + ".scene.json"); err == nil {
21
+ return "", fmt.Errorf("asset manifest already exists: %s.scene.json", output)
22
+ } else if !os.IsNotExist(err) {
23
+ return "", fmt.Errorf("inspect asset manifest: %w", err)
24
+ }
25
+ staged, err := os.CreateTemp(filepath.Dir(output), ".scene-*"+filepath.Ext(output))
26
+ if err != nil {
27
+ return "", fmt.Errorf("stage video output: %w", err)
28
+ }
29
+ if err := staged.Close(); err != nil {
30
+ os.Remove(staged.Name())
31
+ return "", fmt.Errorf("close staged output: %w", err)
32
+ }
33
+ return staged.Name(), nil
34
+ }
35
+
36
+ func publishOutput(staged, output string, request Request, manifest Manifest, codec Codec, elapsed time.Duration, metrics RenderMetrics) (Result, error) {
37
+ hash, size, err := fingerprint(staged)
38
+ if err != nil {
39
+ return Result{}, err
40
+ }
41
+ absolute, err := filepath.Abs(output)
42
+ if err != nil {
43
+ return Result{}, fmt.Errorf("resolve output path: %w", err)
44
+ }
45
+ result := Result{Version: ProtocolVersion, File: absolute, SHA256: hash, Size: size, Duration: request.Config.Duration(), Width: request.Config.Width, Height: request.Config.Height, FPS: request.Config.FPS, Frames: request.Config.DurationInFrames, HasAudio: len(request.Audio) > 0, Transparent: request.Config.Transparent, Codec: codec, Scene: manifest, Elapsed: elapsed}
46
+ result.Metrics = metrics
47
+ data, err := json.MarshalIndent(result, "", " ")
48
+ if err != nil {
49
+ return Result{}, fmt.Errorf("encode asset manifest: %w", err)
50
+ }
51
+ // A same-directory hard link publishes complete bytes without replacing an
52
+ // existing asset if another process claimed the destination during rendering.
53
+ if err := os.Link(staged, output); err != nil {
54
+ return Result{}, fmt.Errorf("publish video asset: %w", err)
55
+ }
56
+ if err := writeExclusive(output+".scene.json", data); err != nil {
57
+ os.Remove(output)
58
+ return Result{}, err
59
+ }
60
+ return result, nil
61
+ }
62
+
63
+ func fingerprint(path string) (string, int64, error) {
64
+ file, err := os.Open(path)
65
+ if err != nil {
66
+ return "", 0, fmt.Errorf("open rendered asset: %w", err)
67
+ }
68
+ defer file.Close()
69
+ hash := sha256.New()
70
+ size, err := io.Copy(hash, file)
71
+ if err != nil {
72
+ return "", 0, fmt.Errorf("fingerprint rendered asset: %w", err)
73
+ }
74
+ return hex.EncodeToString(hash.Sum(nil)), size, nil
75
+ }
76
+
77
+ func writeExclusive(path string, data []byte) error {
78
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
79
+ if err != nil {
80
+ return fmt.Errorf("create asset manifest: %w", err)
81
+ }
82
+ _, writeErr := file.Write(data)
83
+ closeErr := file.Close()
84
+ if writeErr != nil {
85
+ os.Remove(path)
86
+ return fmt.Errorf("write asset manifest: %w", writeErr)
87
+ }
88
+ if closeErr != nil {
89
+ os.Remove(path)
90
+ return fmt.Errorf("close asset manifest: %w", closeErr)
91
+ }
92
+ return nil
93
+ }
@@ -0,0 +1,108 @@
1
+ package sceneengine
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "io"
7
+ "sync"
8
+ )
9
+
10
+ type captureFunc func(ctx context.Context, frame int) ([]byte, error)
11
+
12
+ type capturedFrame struct {
13
+ index int
14
+ bytes []byte
15
+ err error
16
+ }
17
+
18
+ // Credits are returned only after ordered writes, bounding out-of-order buffers
19
+ // even when an early frame is substantially slower than every later frame.
20
+ func streamFrames(ctx context.Context, count int, captures []captureFunc, writer io.Writer, progress func(int)) error {
21
+ ctx, cancel := context.WithCancelCause(ctx)
22
+ defer cancel(nil)
23
+ window := len(captures) * 2
24
+ credits := make(chan struct{}, window)
25
+ jobs := make(chan int)
26
+ results := make(chan capturedFrame, len(captures))
27
+ var workers sync.WaitGroup
28
+ workers.Add(len(captures) + 1)
29
+ go scheduleFrames(ctx, count, credits, jobs, &workers)
30
+ for _, capture := range captures {
31
+ go captureFrames(ctx, capture, jobs, results, &workers)
32
+ }
33
+ defer workers.Wait()
34
+ defer cancel(context.Canceled)
35
+ pending := make(map[int][]byte)
36
+ next := 0
37
+ for next < count {
38
+ select {
39
+ case <-ctx.Done():
40
+ return context.Cause(ctx)
41
+ case result := <-results:
42
+ if result.err != nil {
43
+ return result.err
44
+ }
45
+ pending[result.index] = result.bytes
46
+ for {
47
+ frame, available := pending[next]
48
+ if !available {
49
+ break
50
+ }
51
+ written, err := writer.Write(frame)
52
+ if err != nil {
53
+ return fmt.Errorf("write frame %d to encoder: %w", next, err)
54
+ }
55
+ if written != len(frame) {
56
+ return fmt.Errorf("write frame %d to encoder: %w", next, io.ErrShortWrite)
57
+ }
58
+ delete(pending, next)
59
+ <-credits
60
+ next++
61
+ if progress != nil {
62
+ progress(next)
63
+ }
64
+ }
65
+ }
66
+ }
67
+ return nil
68
+ }
69
+
70
+ func scheduleFrames(ctx context.Context, count int, credits chan<- struct{}, jobs chan<- int, workers *sync.WaitGroup) {
71
+ defer workers.Done()
72
+ defer close(jobs)
73
+ for index := range count {
74
+ select {
75
+ case <-ctx.Done():
76
+ return
77
+ case credits <- struct{}{}:
78
+ }
79
+ select {
80
+ case <-ctx.Done():
81
+ return
82
+ case jobs <- index:
83
+ }
84
+ }
85
+ }
86
+
87
+ func captureFrames(ctx context.Context, capture captureFunc, jobs <-chan int, results chan<- capturedFrame, workers *sync.WaitGroup) {
88
+ defer workers.Done()
89
+ for {
90
+ select {
91
+ case <-ctx.Done():
92
+ return
93
+ case index, available := <-jobs:
94
+ if !available {
95
+ return
96
+ }
97
+ buffer, err := capture(ctx, index)
98
+ select {
99
+ case <-ctx.Done():
100
+ return
101
+ case results <- capturedFrame{index: index, bytes: buffer, err: err}:
102
+ }
103
+ if err != nil {
104
+ return
105
+ }
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,108 @@
1
+ package sceneengine
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "errors"
7
+ "fmt"
8
+ "io"
9
+ "sync/atomic"
10
+ "testing"
11
+ "time"
12
+ )
13
+
14
+ func TestStreamFramesOrdersConcurrentResults(t *testing.T) {
15
+ capture := func(ctx context.Context, index int) ([]byte, error) {
16
+ if index%3 == 0 {
17
+ time.Sleep(5 * time.Millisecond)
18
+ }
19
+ return []byte(fmt.Sprintf("%02d|", index)), nil
20
+ }
21
+ var output bytes.Buffer
22
+ var progress []int
23
+ err := streamFrames(context.Background(), 30, []captureFunc{capture, capture, capture}, &output, func(written int) { progress = append(progress, written) })
24
+ if err != nil {
25
+ t.Fatal(err)
26
+ }
27
+ var expected bytes.Buffer
28
+ for index := 0; index < 30; index++ {
29
+ fmt.Fprintf(&expected, "%02d|", index)
30
+ }
31
+ if output.String() != expected.String() {
32
+ t.Fatalf("frames are out of order: %s", output.String())
33
+ }
34
+ if len(progress) != 30 || progress[29] != 30 {
35
+ t.Fatalf("invalid progress: %v", progress)
36
+ }
37
+ }
38
+
39
+ func TestStreamFramesBoundsReorderingWindow(t *testing.T) {
40
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
41
+ defer cancel()
42
+ release := make(chan struct{})
43
+ var started atomic.Int64
44
+ capture := func(ctx context.Context, index int) ([]byte, error) {
45
+ started.Add(1)
46
+ if index == 0 {
47
+ select {
48
+ case <-release:
49
+ case <-ctx.Done():
50
+ return nil, ctx.Err()
51
+ }
52
+ }
53
+ return []byte{byte(index)}, nil
54
+ }
55
+ done := make(chan error, 1)
56
+ go func() { done <- streamFrames(ctx, 100, []captureFunc{capture, capture}, io.Discard, nil) }()
57
+ deadline := time.Now().Add(time.Second)
58
+ for started.Load() < 4 && time.Now().Before(deadline) {
59
+ time.Sleep(time.Millisecond)
60
+ }
61
+ time.Sleep(15 * time.Millisecond)
62
+ if value := started.Load(); value != 4 {
63
+ t.Errorf("expected window of four frames, got %d", value)
64
+ }
65
+ close(release)
66
+ if err := <-done; err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ }
70
+
71
+ func TestStreamFramesCancelsOtherWorkersOnFailure(t *testing.T) {
72
+ expected := errors.New("broken frame")
73
+ blocked := make(chan struct{})
74
+ capture := func(ctx context.Context, index int) ([]byte, error) {
75
+ if index == 0 {
76
+ <-blocked
77
+ return nil, expected
78
+ }
79
+ close(blocked)
80
+ <-ctx.Done()
81
+ return nil, ctx.Err()
82
+ }
83
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
84
+ defer cancel()
85
+ if err := streamFrames(ctx, 10, []captureFunc{capture, capture}, io.Discard, nil); !errors.Is(err, expected) {
86
+ t.Fatalf("expected capture failure, got %v", err)
87
+ }
88
+ }
89
+
90
+ func TestStreamFramesHonorsCancellation(t *testing.T) {
91
+ ctx, cancel := context.WithCancel(context.Background())
92
+ capture := func(ctx context.Context, _ int) ([]byte, error) { cancel(); <-ctx.Done(); return nil, ctx.Err() }
93
+ if err := streamFrames(ctx, 10, []captureFunc{capture}, io.Discard, nil); !errors.Is(err, context.Canceled) {
94
+ t.Fatalf("expected cancellation, got %v", err)
95
+ }
96
+ }
97
+
98
+ type shortWriter struct{}
99
+
100
+ func (shortWriter) Write(buffer []byte) (int, error) { return len(buffer) - 1, nil }
101
+
102
+ func TestStreamFramesRejectsTruncatedWrites(t *testing.T) {
103
+ capture := func(context.Context, int) ([]byte, error) { return []byte("frame"), nil }
104
+ err := streamFrames(context.Background(), 2, []captureFunc{capture}, shortWriter{}, nil)
105
+ if !errors.Is(err, io.ErrShortWrite) {
106
+ t.Fatalf("expected a truncated-write error, got %v", err)
107
+ }
108
+ }
@@ -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
+ }