@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,42 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net"
7
+ "net/http"
8
+ "net/url"
9
+ "os"
10
+ "time"
11
+ )
12
+
13
+ func serveScene(directory, sceneURL string) (string, func(), error) {
14
+ info, err := os.Stat(directory)
15
+ if err != nil || !info.IsDir() {
16
+ return "", nil, fmt.Errorf("scene build directory is unavailable: %s", directory)
17
+ }
18
+ relative, err := url.Parse(sceneURL)
19
+ if err != nil || relative.IsAbs() || relative.Host != "" {
20
+ return "", nil, fmt.Errorf("with -serve, request URL must be a relative scene path")
21
+ }
22
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
23
+ if err != nil {
24
+ return "", nil, fmt.Errorf("listen for scene files: %w", err)
25
+ }
26
+ server := &http.Server{Handler: http.FileServer(http.Dir(directory)), ReadHeaderTimeout: 5 * time.Second}
27
+ go func() {
28
+ // Shutdown below is the expected termination path for this private file server.
29
+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
30
+ server.Close()
31
+ }
32
+ }()
33
+ base := &url.URL{Scheme: "http", Host: listener.Addr().String(), Path: "/"}
34
+ closeServer := func() {
35
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
36
+ defer cancel()
37
+ if err := server.Shutdown(ctx); err != nil {
38
+ server.Close()
39
+ }
40
+ }
41
+ return base.ResolveReference(relative).String(), closeServer, nil
42
+ }
@@ -0,0 +1,116 @@
1
+ package sceneengine
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "fmt"
7
+ "io"
8
+ "os/exec"
9
+ "strconv"
10
+ "strings"
11
+ )
12
+
13
+ const maxEncoderLogBytes = 32 << 10
14
+
15
+ type encoder struct {
16
+ command *exec.Cmd
17
+ input io.WriteCloser
18
+ log tailLog
19
+ }
20
+
21
+ type tailLog struct{ buffer []byte }
22
+
23
+ func (l *tailLog) Write(data []byte) (int, error) {
24
+ count := len(data)
25
+ l.buffer = append(l.buffer, data...)
26
+ if len(l.buffer) > maxEncoderLogBytes {
27
+ l.buffer = bytes.Clone(l.buffer[len(l.buffer)-maxEncoderLogBytes:])
28
+ }
29
+ return count, nil
30
+ }
31
+
32
+ func startEncoder(ctx context.Context, options Options, request Request, codec Codec, output string) (*encoder, error) {
33
+ args := encoderArgs(request, codec, output, options)
34
+ process := &encoder{command: exec.CommandContext(ctx, options.FFmpegExecutable, args...)}
35
+ process.command.Stderr = &process.log
36
+ input, err := process.command.StdinPipe()
37
+ if err != nil {
38
+ return nil, fmt.Errorf("open encoder stdin: %w", err)
39
+ }
40
+ process.input = input
41
+ if err := process.command.Start(); err != nil {
42
+ input.Close()
43
+ return nil, fmt.Errorf("start FFmpeg: %w", err)
44
+ }
45
+ return process, nil
46
+ }
47
+
48
+ func (e *encoder) finish() error {
49
+ closeErr := e.input.Close()
50
+ if err := e.command.Wait(); err != nil {
51
+ return fmt.Errorf("FFmpeg encoding failed: %w\n%s", err, string(e.log.buffer))
52
+ }
53
+ if closeErr != nil {
54
+ return fmt.Errorf("close encoder input: %w", closeErr)
55
+ }
56
+ return nil
57
+ }
58
+
59
+ func encoderArgs(request Request, codec Codec, output string, options Options) []string {
60
+ args := []string{"-hide_banner", "-loglevel", "error", "-y", "-f", "image2pipe", "-framerate", decimal(request.Config.FPS), "-vcodec", "png", "-i", "pipe:0"}
61
+ for _, audio := range request.Audio {
62
+ args = append(args, "-i", audio.File)
63
+ }
64
+ if len(request.Audio) > 0 {
65
+ args = append(args, "-filter_complex", audioFilter(request), "-map", "0:v:0", "-map", "[audio]")
66
+ if codec == H264 {
67
+ args = append(args, "-c:a", "aac", "-b:a", "192k")
68
+ } else {
69
+ args = append(args, "-c:a", "libopus", "-b:a", "192k")
70
+ }
71
+ } else {
72
+ args = append(args, "-map", "0:v:0", "-an")
73
+ }
74
+ if codec == H264 {
75
+ encoder, crf, pixelFormat := "libx264", "18", "yuv420p"
76
+ if options.Lossless {
77
+ encoder, crf, pixelFormat = "libx264rgb", "0", "rgb24"
78
+ }
79
+ args = append(args, "-c:v", encoder, "-preset", options.X264Preset, "-crf", crf, "-pix_fmt", pixelFormat, "-movflags", "+faststart")
80
+ } else {
81
+ pixelFormat := "yuv420p"
82
+ if request.Config.Transparent {
83
+ pixelFormat = "yuva420p"
84
+ }
85
+ crf := "30"
86
+ if options.Lossless {
87
+ crf = "0"
88
+ }
89
+ args = append(args, "-c:v", "libvpx-vp9", "-crf", crf, "-b:v", "0", "-pix_fmt", pixelFormat, "-auto-alt-ref", "0")
90
+ if options.Lossless {
91
+ args = append(args, "-lossless", "1", "-deadline", "good", "-cpu-used", "4", "-row-mt", "1")
92
+ }
93
+ }
94
+ if options.EncoderThreads > 0 {
95
+ args = append(args, "-threads", strconv.Itoa(options.EncoderThreads))
96
+ }
97
+ return append(args, "-t", decimal(request.Config.Duration()), output)
98
+ }
99
+
100
+ func audioFilter(request Request) string {
101
+ filters := make([]string, 0, len(request.Audio)+1)
102
+ labels := make([]string, 0, len(request.Audio))
103
+ for index, track := range request.Audio {
104
+ volume := 1.0
105
+ if track.Volume != nil {
106
+ volume = *track.Volume
107
+ }
108
+ label := fmt.Sprintf("[a%d]", index)
109
+ filters = append(filters, fmt.Sprintf("[%d:a:0]atrim=start=%s:duration=%s,asetpts=PTS-STARTPTS,volume=%s,adelay=%d:all=1%s", index+1, decimal(track.From), decimal(track.Duration), decimal(volume), int64(track.Start*1000+0.5), label))
110
+ labels = append(labels, label)
111
+ }
112
+ filters = append(filters, fmt.Sprintf("%samix=inputs=%d:normalize=0,apad,atrim=duration=%s[audio]", strings.Join(labels, ""), len(labels), decimal(request.Config.Duration())))
113
+ return strings.Join(filters, ";")
114
+ }
115
+
116
+ func decimal(value float64) string { return strconv.FormatFloat(value, 'f', -1, 64) }
@@ -0,0 +1,22 @@
1
+ package sceneengine
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+ )
7
+
8
+ func TestLosslessEncodingRetainsCaptureDetail(t *testing.T) {
9
+ request := fixtureRequest()
10
+ options := Options{Lossless: true, X264Preset: "ultrafast", EncoderThreads: 2}
11
+ args := strings.Join(encoderArgs(request, H264, "scene.mp4", options), " ")
12
+ for _, want := range []string{"-vcodec png", "-c:v libx264rgb", "-preset ultrafast", "-crf 0", "-pix_fmt rgb24", "-threads 2"} {
13
+ if !strings.Contains(args, want) {
14
+ t.Fatalf("missing %s in %s", want, args)
15
+ }
16
+ }
17
+ request.Config.Transparent = true
18
+ args = strings.Join(encoderArgs(request, VP9, "scene.webm", options), " ")
19
+ if !strings.Contains(args, "-lossless 1") || !strings.Contains(args, "-pix_fmt yuva420p") {
20
+ t.Fatal(args)
21
+ }
22
+ }
@@ -0,0 +1,11 @@
1
+ module github.com/catalinfl/machinal-scenes/renderer
2
+
3
+ go 1.27.2
4
+
5
+ require (
6
+ github.com/chromedp/cdproto v0.157.8
7
+ github.com/chromedp/chromedp v0.20.1
8
+ gopkg.in/yaml.v3 v3.0.1
9
+ )
10
+
11
+ require github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875 // indirect
@@ -0,0 +1,10 @@
1
+ github.com/chromedp/cdproto v0.157.8 h1:Tkq0AjsUlC3Z4sJfHBzV/uAAxnTF/AZyuSTjfV/1gvk=
2
+ github.com/chromedp/cdproto v0.157.8/go.mod h1:h68JaIuFr9o3yiayxEYhBdvaxgYO3shDrPSYhNlyfu0=
3
+ github.com/chromedp/chromedp v0.20.1 h1:983T9wmyM09JtROWlxhLQwljKOQovqt8CVE89W3U0NA=
4
+ github.com/chromedp/chromedp v0.20.1/go.mod h1:6z1Y5FdKUxRzxx2qcC5h1xjaBJrSWrZp1mjgITfNZig=
5
+ github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875 h1:XGPtjxqp0FS8jj1Z7QGcSOSgyq1fUPjNyJXvi3KfI4A=
6
+ github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
7
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
8
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
9
+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
10
+ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,50 @@
1
+ package sceneengine
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+
7
+ "github.com/chromedp/cdproto/cdp"
8
+ "github.com/chromedp/cdproto/fetch"
9
+ "github.com/chromedp/cdproto/network"
10
+ "github.com/chromedp/chromedp"
11
+ )
12
+
13
+ func allowRequest(raw string, allowed map[string]bool) bool {
14
+ if strings.HasPrefix(raw, "data:") || strings.HasPrefix(raw, "blob:") || raw == "about:blank" {
15
+ return true
16
+ }
17
+ origin, err := parseOrigin(raw)
18
+ return err == nil && allowed[origin]
19
+ }
20
+
21
+ func restrictNetwork(owner context.Context, requestURL string, extraOrigins []string, cancel context.CancelFunc) chromedp.Action[chromedp.Void] {
22
+ allowed := make(map[string]bool)
23
+ origin, _ := parseOrigin(requestURL)
24
+ allowed[origin] = true
25
+ for _, raw := range extraOrigins {
26
+ origin, _ := parseOrigin(raw)
27
+ allowed[origin] = true
28
+ }
29
+ return chromedp.Func(func(ctx context.Context, target *chromedp.Target) error {
30
+ events := cdp.Events(owner, target, fetch.RequestPaused)
31
+ go func() {
32
+ for request, err := range events {
33
+ if err != nil {
34
+ return
35
+ }
36
+ if allowRequest(request.Request.URL, allowed) {
37
+ _, err = cdp.Call(owner, target, fetch.ContinueRequest, fetch.ContinueRequestParams{RequestID: request.RequestID})
38
+ } else {
39
+ _, err = cdp.Call(owner, target, fetch.FailRequest, fetch.FailRequestParams{RequestID: request.RequestID, ErrorReason: network.ErrorReasonBlockedByClient})
40
+ }
41
+ if err != nil {
42
+ cancel()
43
+ return
44
+ }
45
+ }
46
+ }()
47
+ _, err := cdp.Call(ctx, target, fetch.Enable, fetch.EnableParams{})
48
+ return err
49
+ })
50
+ }
@@ -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
+ }