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