@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,113 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
3
|
+
import { access, chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { createGunzip } from 'node:zlib';
|
|
10
|
+
import { Browser, computeExecutablePath, install } from '@puppeteer/browsers';
|
|
11
|
+
import { downloadVerified, sha256 } from './download.mjs';
|
|
12
|
+
import { ffmpegRelease, ffmpegReleases } from './ffmpeg-releases.mjs';
|
|
13
|
+
import { platformTarget } from './platforms.mjs';
|
|
14
|
+
|
|
15
|
+
export const browserBuild = '151.0.7922.34';
|
|
16
|
+
const execute = promisify(execFile);
|
|
17
|
+
const packageRoot = fileURLToPath(new URL('../', import.meta.url));
|
|
18
|
+
|
|
19
|
+
export function cacheDirectory(options = {}) {
|
|
20
|
+
if (options.cacheDirectory || process.env.MACHINAL_SCENES_CACHE) return resolve(options.cacheDirectory || process.env.MACHINAL_SCENES_CACHE);
|
|
21
|
+
const base = process.platform === 'win32' ? process.env.LOCALAPPDATA ?? join(homedir(), 'AppData/Local')
|
|
22
|
+
: process.platform === 'darwin' ? join(homedir(), 'Library/Caches')
|
|
23
|
+
: process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache');
|
|
24
|
+
return join(base, 'machinal-scenes');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toolPaths(options = {}) {
|
|
28
|
+
platformTarget();
|
|
29
|
+
const cache = cacheDirectory(options);
|
|
30
|
+
return {
|
|
31
|
+
cache,
|
|
32
|
+
browser: resolve(options.browserExecutable || process.env.SCENE_BROWSER || computeExecutablePath({ browser: Browser.CHROMEHEADLESSSHELL, buildId: browserBuild, cacheDir: join(cache, 'browser') })),
|
|
33
|
+
ffmpeg: resolve(options.ffmpegExecutable || process.env.SCENE_FFMPEG || join(cache, 'ffmpeg', ffmpegRelease, `${process.platform}-${process.arch}`, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function rendererPath() {
|
|
38
|
+
const target = platformTarget();
|
|
39
|
+
const manifest = JSON.parse(await readFile(join(packageRoot, 'native/manifest.json'), 'utf8').catch(error => {
|
|
40
|
+
throw new Error(`This installation is missing the precompiled renderer manifest. Install a complete @machinal/scenes release. (${error.code})`);
|
|
41
|
+
}));
|
|
42
|
+
const entry = manifest.targets.find(entry => entry.platform === target.platform && entry.arch === target.arch);
|
|
43
|
+
if (!entry || entry.file !== target.file) throw new Error('Missing native renderer for this platform');
|
|
44
|
+
const executable = join(packageRoot, 'native', entry.file);
|
|
45
|
+
if (await sha256(executable) !== entry.sha256) throw new Error('Native renderer checksum mismatch; reinstall the package');
|
|
46
|
+
return executable;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function probe(path, args) {
|
|
50
|
+
try {
|
|
51
|
+
await access(path);
|
|
52
|
+
const { stdout, stderr } = await execute(path, args, { timeout: 15_000, maxBuffer: 128 * 1024, windowsHide: true });
|
|
53
|
+
return { path, available: true, version: (stdout || stderr).split(/\r?\n/)[0] };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return { path, available: false, error: error.code === 'ENOENT' ? 'Not installed. Run machinal-scenes setup.' : `Cannot execute tool: ${error.message}` };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function doctor(options = {}) {
|
|
60
|
+
const paths = toolPaths(options);
|
|
61
|
+
let renderer;
|
|
62
|
+
try { renderer = await probe(await rendererPath(), ['-h']); }
|
|
63
|
+
catch (error) { renderer = { available: false, error: error.message }; }
|
|
64
|
+
const [browser, ffmpeg] = await Promise.all([probe(paths.browser, ['--version']), probe(paths.ffmpeg, ['-version'])]);
|
|
65
|
+
return { ready: renderer.available && browser.available && ffmpeg.available, cacheDirectory: paths.cache, renderer, browser, ffmpeg };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function setup(options = {}) {
|
|
69
|
+
const paths = toolPaths(options);
|
|
70
|
+
await rendererPath();
|
|
71
|
+
const report = options.onProgress ?? (() => {});
|
|
72
|
+
if (!options.browserExecutable && !process.env.SCENE_BROWSER) {
|
|
73
|
+
report(`Installing Chrome Headless Shell ${browserBuild} (official Chrome for Testing download)...`);
|
|
74
|
+
await install({ browser: Browser.CHROMEHEADLESSSHELL, buildId: browserBuild, cacheDir: join(paths.cache, 'browser'), installDeps: false });
|
|
75
|
+
}
|
|
76
|
+
if (!options.ffmpegExecutable && !process.env.SCENE_FFMPEG) {
|
|
77
|
+
report(`Installing FFmpeg ${ffmpegRelease} (checksum-verified release, including license and build information)...`);
|
|
78
|
+
await installFFmpeg(paths.ffmpeg);
|
|
79
|
+
}
|
|
80
|
+
const status = await doctor(options);
|
|
81
|
+
if (!status.ready) throw new Error(`Rendering tools are not ready:\n${[status.renderer, status.browser, status.ffmpeg].filter(tool => !tool.available).map(tool => `${tool.path ?? 'renderer'}: ${tool.error}`).join('\n')}\nOn Linux, Chromium also requires system shared libraries; see the README.`);
|
|
82
|
+
return status;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function installFFmpeg(executable) {
|
|
86
|
+
const target = `${process.platform}-${process.arch}`;
|
|
87
|
+
const hashes = ffmpegReleases[target];
|
|
88
|
+
const directory = dirname(executable);
|
|
89
|
+
try {
|
|
90
|
+
const installed = JSON.parse(await readFile(join(directory, 'installation.json'), 'utf8'));
|
|
91
|
+
if (installed.archiveSHA256 !== hashes.archive || await sha256(executable) !== installed.executableSHA256) throw new Error(`Cached FFmpeg checksum mismatch: ${directory}`);
|
|
92
|
+
if (await sha256(join(directory, 'LICENSE')) !== hashes.license || await sha256(join(directory, 'README')) !== hashes.readme) throw new Error(`Cached FFmpeg license/build information is damaged: ${directory}`);
|
|
93
|
+
return;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error.code !== 'ENOENT') throw error;
|
|
96
|
+
}
|
|
97
|
+
await mkdir(dirname(directory), { recursive: true });
|
|
98
|
+
const temporary = await mkdtemp(join(dirname(directory), '.install-'));
|
|
99
|
+
const base = `https://github.com/eugeneware/ffmpeg-static/releases/download/${ffmpegRelease}`;
|
|
100
|
+
try {
|
|
101
|
+
await downloadVerified(`${base}/ffmpeg-${target}.gz`, join(temporary, 'ffmpeg.gz'), hashes.archive);
|
|
102
|
+
await downloadVerified(`${base}/${target}.LICENSE`, join(temporary, 'LICENSE'), hashes.license);
|
|
103
|
+
await downloadVerified(`${base}/${target}.README`, join(temporary, 'README'), hashes.readme);
|
|
104
|
+
const binary = join(temporary, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
|
|
105
|
+
await pipeline(createReadStream(join(temporary, 'ffmpeg.gz')), createGunzip(), createWriteStream(binary, { flags: 'wx' }));
|
|
106
|
+
await chmod(binary, 0o755);
|
|
107
|
+
await writeFile(join(temporary, 'installation.json'), JSON.stringify({ archiveSHA256: hashes.archive, executableSHA256: await sha256(binary) }), 'utf8');
|
|
108
|
+
await rm(join(temporary, 'ffmpeg.gz'));
|
|
109
|
+
await rename(temporary, directory);
|
|
110
|
+
} finally {
|
|
111
|
+
await rm(temporary, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,91 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@machinal/scenes",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Author, preview and render frame-driven Svelte, HTML and Three.js scenes to MP4 and WebM",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"svelte": "./dist/index.js",
|
|
9
|
+
"bin": { "machinal-scenes": "bin/machinal-scenes.mjs" },
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"svelte": "./dist/index.js",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./runtime": {
|
|
17
|
+
"types": "./dist/runtime/index.d.ts",
|
|
18
|
+
"import": "./dist/runtime/index.js",
|
|
19
|
+
"default": "./dist/runtime/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./svelte": {
|
|
22
|
+
"types": "./dist/svelte/index.d.ts",
|
|
23
|
+
"svelte": "./dist/svelte/index.js",
|
|
24
|
+
"default": "./dist/svelte/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./node": {
|
|
27
|
+
"types": "./node/index.d.ts",
|
|
28
|
+
"import": "./node/index.mjs"
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"bin/",
|
|
34
|
+
"node/",
|
|
35
|
+
"native/",
|
|
36
|
+
"renderer/*.go",
|
|
37
|
+
"renderer/cmd/**/*.go",
|
|
38
|
+
"renderer/go.mod",
|
|
39
|
+
"renderer/go.sum",
|
|
40
|
+
"dist/",
|
|
41
|
+
"examples/quickstart/src/",
|
|
42
|
+
"examples/quickstart/index.html",
|
|
43
|
+
"examples/quickstart/scene.html",
|
|
44
|
+
"examples/quickstart/scene.config.json",
|
|
45
|
+
"examples/quickstart/package.json",
|
|
46
|
+
"examples/quickstart/tsconfig.json",
|
|
47
|
+
"examples/quickstart/vite.config.js",
|
|
48
|
+
"examples/quickstart/svelte.config.js",
|
|
49
|
+
"LICENSE",
|
|
50
|
+
"NOTICE",
|
|
51
|
+
"LICENSING.md",
|
|
52
|
+
"CHANGELOG.md"
|
|
53
|
+
],
|
|
54
|
+
"sideEffects": ["**/*.css"],
|
|
55
|
+
"engines": { "node": ">=22.12.0" },
|
|
56
|
+
"keywords": ["svelte", "svelte5", "animation", "motion-graphics", "video", "threejs", "scenes", "frame-driven"],
|
|
57
|
+
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
58
|
+
"scripts": {
|
|
59
|
+
"dev": "pnpm --filter @direction/scene-studio dev",
|
|
60
|
+
"build": "pnpm run build:package && pnpm --filter @direction/scene-studio build",
|
|
61
|
+
"build:package": "pnpm --filter @direction/scene-runtime build && pnpm --filter @direction/scene-svelte build && node scripts/build-package.mjs",
|
|
62
|
+
"build:renderer": "node scripts/build-renderer.mjs",
|
|
63
|
+
"check": "pnpm --filter @direction/scene-runtime check && pnpm --filter @direction/scene-svelte check && pnpm --filter @direction/scene-studio check",
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"test:package": "pnpm run build:package && pnpm run build:renderer && node --test tests/package.test.mjs",
|
|
66
|
+
"test:cli": "node --test tests/cli.test.mjs",
|
|
67
|
+
"test:evaluation": "node --test scripts/evaluate-production.test.mjs",
|
|
68
|
+
"test:browser": "playwright test",
|
|
69
|
+
"test:go": "go -C renderer test ./...",
|
|
70
|
+
"test:native": "node scripts/test-native.mjs",
|
|
71
|
+
"prepack": "pnpm run build:package && pnpm run build:renderer",
|
|
72
|
+
"prepublishOnly": "pnpm check && pnpm test && pnpm test:cli && pnpm test:package"
|
|
73
|
+
},
|
|
74
|
+
"dependencies": {
|
|
75
|
+
"@puppeteer/browsers": "3.2.6",
|
|
76
|
+
"@types/three": "^0.180.0",
|
|
77
|
+
"@webgpu/types": "^0.1.74",
|
|
78
|
+
"three": "^0.180.0"
|
|
79
|
+
},
|
|
80
|
+
"peerDependencies": { "svelte": "^5.15.0" },
|
|
81
|
+
"devDependencies": {
|
|
82
|
+
"@playwright/test": "^1.62.0",
|
|
83
|
+
"@types/node": "^26.1.2",
|
|
84
|
+
"cross-spawn": "^7.0.6",
|
|
85
|
+
"ffmpeg-static": "^5.3.0",
|
|
86
|
+
"ffprobe-static": "^3.1.0",
|
|
87
|
+
"svelte": "^5.15.0",
|
|
88
|
+
"typescript": "^5.9.3",
|
|
89
|
+
"vitest": "^4.1.12"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
package sceneengine
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os/exec"
|
|
7
|
+
"sync"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/chromedp/chromedp"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
const (
|
|
14
|
+
defaultConcurrency = 2
|
|
15
|
+
maxConcurrency = 16
|
|
16
|
+
defaultFrameTimeout = 30 * time.Second
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Renderer owns one Chromium process reused across sequential render jobs.
|
|
20
|
+
// Each active frame has an exclusive tab; concurrent Render calls are rejected.
|
|
21
|
+
type Renderer struct {
|
|
22
|
+
browser context.Context
|
|
23
|
+
cancelBrowser context.CancelFunc
|
|
24
|
+
cancelAllocator context.CancelFunc
|
|
25
|
+
options Options
|
|
26
|
+
lease chan struct{}
|
|
27
|
+
closeOnce sync.Once
|
|
28
|
+
browserLog *browserLog
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// New starts a pinned browser whose lifetime is owned by ctx or Close.
|
|
32
|
+
func New(ctx context.Context, options Options) (*Renderer, error) {
|
|
33
|
+
options, err := normalizeOptions(options)
|
|
34
|
+
if err != nil {
|
|
35
|
+
return nil, err
|
|
36
|
+
}
|
|
37
|
+
diagnostics := &browserLog{}
|
|
38
|
+
flags := append([]chromedp.ExecAllocatorOption{}, chromedp.DefaultExecAllocatorOptions[:]...)
|
|
39
|
+
flags = append(flags,
|
|
40
|
+
chromedp.CombinedOutput(diagnostics),
|
|
41
|
+
chromedp.ExecPath(options.BrowserExecutable),
|
|
42
|
+
chromedp.Flag("disable-background-timer-throttling", true),
|
|
43
|
+
chromedp.Flag("disable-renderer-backgrounding", true),
|
|
44
|
+
chromedp.Flag("disable-backgrounding-occluded-windows", true),
|
|
45
|
+
chromedp.Flag("force-color-profile", "srgb"),
|
|
46
|
+
chromedp.Flag("hide-scrollbars", true),
|
|
47
|
+
chromedp.Flag("mute-audio", true),
|
|
48
|
+
)
|
|
49
|
+
if options.NoSandbox {
|
|
50
|
+
flags = append(flags, chromedp.NoSandbox)
|
|
51
|
+
}
|
|
52
|
+
if options.EnableGPU {
|
|
53
|
+
flags = append(flags, chromedp.Flag("disable-gpu", false), chromedp.Flag("enable-gpu", true))
|
|
54
|
+
}
|
|
55
|
+
allocator, cancelAllocator := chromedp.NewExecAllocator(ctx, flags...)
|
|
56
|
+
browser, cancelBrowser := chromedp.NewContext(allocator)
|
|
57
|
+
renderer := &Renderer{browser: browser, cancelBrowser: cancelBrowser, cancelAllocator: cancelAllocator, options: options, lease: make(chan struct{}, 1), browserLog: diagnostics}
|
|
58
|
+
startupTimeout := time.AfterFunc(options.FrameTimeout, cancelBrowser)
|
|
59
|
+
defer startupTimeout.Stop()
|
|
60
|
+
if err := chromedp.Do(browser); err != nil {
|
|
61
|
+
renderer.Close()
|
|
62
|
+
return nil, diagnostics.wrap(fmt.Errorf("start Chromium: %w", err))
|
|
63
|
+
}
|
|
64
|
+
return renderer, nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func normalizeOptions(options Options) (Options, error) {
|
|
68
|
+
if options.BrowserExecutable == "" {
|
|
69
|
+
return options, fmt.Errorf("BrowserExecutable is required; use a pinned Chrome Headless Shell binary")
|
|
70
|
+
}
|
|
71
|
+
if options.FFmpegExecutable == "" {
|
|
72
|
+
options.FFmpegExecutable = "ffmpeg"
|
|
73
|
+
}
|
|
74
|
+
ffmpeg, err := exec.LookPath(options.FFmpegExecutable)
|
|
75
|
+
if err != nil {
|
|
76
|
+
return options, fmt.Errorf("locate FFmpeg: %w", err)
|
|
77
|
+
}
|
|
78
|
+
options.FFmpegExecutable = ffmpeg
|
|
79
|
+
if options.X264Preset == "" {
|
|
80
|
+
options.X264Preset = "medium"
|
|
81
|
+
}
|
|
82
|
+
switch options.X264Preset {
|
|
83
|
+
case "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow":
|
|
84
|
+
default:
|
|
85
|
+
return options, fmt.Errorf("invalid x264 preset")
|
|
86
|
+
}
|
|
87
|
+
if options.EncoderThreads < 0 || options.EncoderThreads > 32 {
|
|
88
|
+
return options, fmt.Errorf("encoder threads must be between 0 (automatic) and 32")
|
|
89
|
+
}
|
|
90
|
+
if options.Concurrency == 0 {
|
|
91
|
+
options.Concurrency = defaultConcurrency
|
|
92
|
+
}
|
|
93
|
+
if options.Concurrency < 1 || options.Concurrency > maxConcurrency {
|
|
94
|
+
return options, fmt.Errorf("concurrency must be between 1 and %d", maxConcurrency)
|
|
95
|
+
}
|
|
96
|
+
if options.FrameTimeout == 0 {
|
|
97
|
+
options.FrameTimeout = defaultFrameTimeout
|
|
98
|
+
}
|
|
99
|
+
if options.FrameTimeout < 0 {
|
|
100
|
+
return options, fmt.Errorf("frame timeout must be positive")
|
|
101
|
+
}
|
|
102
|
+
for _, origin := range options.AllowedOrigins {
|
|
103
|
+
if _, err := parseOrigin(origin); err != nil {
|
|
104
|
+
return options, fmt.Errorf("allowed origin %q: %w", origin, err)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return options, nil
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Close cancels active jobs and releases the browser process; it is idempotent.
|
|
111
|
+
func (r *Renderer) Close() {
|
|
112
|
+
r.closeOnce.Do(func() {
|
|
113
|
+
r.cancelBrowser()
|
|
114
|
+
r.cancelAllocator()
|
|
115
|
+
})
|
|
116
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package sceneengine
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"fmt"
|
|
6
|
+
"sync"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
const maxBrowserLogBytes = 8 << 10
|
|
10
|
+
|
|
11
|
+
type browserLog struct {
|
|
12
|
+
mu sync.Mutex
|
|
13
|
+
buffer []byte
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func (l *browserLog) Write(data []byte) (int, error) {
|
|
17
|
+
l.mu.Lock()
|
|
18
|
+
defer l.mu.Unlock()
|
|
19
|
+
l.buffer = append(l.buffer, data...)
|
|
20
|
+
if len(l.buffer) > maxBrowserLogBytes {
|
|
21
|
+
l.buffer = bytes.Clone(l.buffer[len(l.buffer)-maxBrowserLogBytes:])
|
|
22
|
+
}
|
|
23
|
+
return len(data), nil
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func (l *browserLog) wrap(err error) error {
|
|
27
|
+
if l == nil || err == nil {
|
|
28
|
+
return err
|
|
29
|
+
}
|
|
30
|
+
l.mu.Lock()
|
|
31
|
+
defer l.mu.Unlock()
|
|
32
|
+
if len(l.buffer) == 0 {
|
|
33
|
+
return err
|
|
34
|
+
}
|
|
35
|
+
return fmt.Errorf("%w\nChromium diagnostics:\n%s", err, l.buffer)
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package sceneengine
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"strings"
|
|
6
|
+
"sync"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestBrowserLogRetainsBoundedFailureDetails(t *testing.T) {
|
|
11
|
+
var log browserLog
|
|
12
|
+
data := []byte(strings.Repeat("x", maxBrowserLogBytes) + "\nNo space left on device")
|
|
13
|
+
written, err := log.Write(data)
|
|
14
|
+
if err != nil || written != len(data) {
|
|
15
|
+
t.Fatalf("write browser output: %d, %v", written, err)
|
|
16
|
+
}
|
|
17
|
+
if len(log.buffer) != maxBrowserLogBytes {
|
|
18
|
+
t.Fatalf("browser log has %d bytes", len(log.buffer))
|
|
19
|
+
}
|
|
20
|
+
cause := errors.New("capture frame 1405 failed")
|
|
21
|
+
failure := log.wrap(cause)
|
|
22
|
+
if !errors.Is(failure, cause) || !strings.HasSuffix(failure.Error(), "No space left on device") {
|
|
23
|
+
t.Fatalf("lost capture failure or browser diagnostic: %v", failure)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestBrowserLogWithoutOutputPreservesOriginalError(t *testing.T) {
|
|
28
|
+
cause := errors.New("capture failed")
|
|
29
|
+
for _, log := range []*browserLog{nil, {}} {
|
|
30
|
+
if log.wrap(cause) != cause {
|
|
31
|
+
t.Fatal("empty browser output changed the original error")
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func TestBrowserLogSupportsConcurrentOutputAndFailures(t *testing.T) {
|
|
37
|
+
var log browserLog
|
|
38
|
+
var writers sync.WaitGroup
|
|
39
|
+
for range 4 {
|
|
40
|
+
writers.Go(func() {
|
|
41
|
+
for range 100 {
|
|
42
|
+
log.Write([]byte("browser diagnostic\n"))
|
|
43
|
+
log.wrap(errors.New("capture failed"))
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
writers.Wait()
|
|
48
|
+
}
|