Commit c5b18b81 authored by AI-甘富林's avatar AI-甘富林

feat(runtime): bundle ffmpeg for client runtime

parent 8acca1d6
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import type { RuntimeManager } from "@qjclaw/runtime-manager";
......@@ -80,6 +81,60 @@ function buildWorkspaceExecutionError(message: string, errorCategory?: string):
return error;
}
function resolveExecutableOnPath(commandNames: readonly string[]): string | undefined {
const pathEntries = (process.env.PATH ?? "")
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
const windowsExtensions = process.platform === "win32"
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
.split(";")
.map((entry) => entry.trim())
.filter(Boolean)
: [""];
for (const commandName of commandNames) {
const trimmed = commandName.trim();
if (!trimmed) {
continue;
}
if (path.isAbsolute(trimmed) && existsSync(trimmed)) {
return trimmed;
}
const extensionCandidates = path.extname(trimmed)
? [""]
: windowsExtensions;
for (const directory of pathEntries) {
for (const extension of extensionCandidates) {
const candidate = path.join(directory, `${trimmed}${extension}`);
if (existsSync(candidate)) {
return candidate;
}
}
}
}
return undefined;
}
function resolveInjectedBinaryPath(
envName: string,
bundledPath: string | undefined,
commandNames: readonly string[]
): string | undefined {
const configured = process.env[envName]?.trim();
if (configured) {
return configured;
}
if (bundledPath && existsSync(bundledPath)) {
return bundledPath;
}
return resolveExecutableOnPath(commandNames);
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
......@@ -267,12 +322,15 @@ export class ProjectWorkspaceExecutorService {
let stderr = "";
let stdoutBuffer = "";
let activeRunId = runId;
const resolvedFfmpeg = resolveInjectedBinaryPath("FFMPEG_BIN", paths.ffmpegExecutable, ["ffmpeg.exe", "ffmpeg"]);
const resolvedFfprobe = resolveInjectedBinaryPath("FFPROBE_BIN", paths.ffprobeExecutable, ["ffprobe.exe", "ffprobe"]);
const childEnv = {
...process.env,
OPENCLAW_HOME: paths.runtimeDataDir,
OPENCLAW_STATE_DIR: paths.runtimeStateDir,
OPENCLAW_CONFIG_PATH: paths.managedConfigPath,
QJCLAW_BUNDLED_RUNTIME_DIR: paths.runtimeDir,
PLAYWRIGHT_BROWSERS_PATH: paths.playwrightBrowsersPath,
OPENCLAW_HIDE_BANNER: "1",
OPENCLAW_SUPPRESS_NOTES: "1",
......@@ -280,12 +338,16 @@ export class ProjectWorkspaceExecutorService {
PYTHONUTF8: "1",
PYTHONIOENCODING: "utf-8",
PATH: [
paths.ffmpegExecutable ? path.dirname(paths.ffmpegExecutable) : null,
paths.ffprobeExecutable ? path.dirname(paths.ffprobeExecutable) : null,
path.join(paths.runtimeDir, "python", "Scripts"),
path.dirname(paths.pythonExecutable),
process.env.PATH ?? ""
].filter(Boolean).join(path.delimiter),
QJC_PROJECT_ATTACHMENTS_JSON: JSON.stringify(input.attachments ?? []),
QJC_PROJECT_MAIN_IMAGE: input.attachments?.find((attachment) => attachment.kind === "image")?.projectPath ?? "",
...(resolvedFfmpeg ? { FFMPEG_BIN: resolvedFfmpeg } : {}),
...(resolvedFfprobe ? { FFPROBE_BIN: resolvedFfprobe } : {}),
...(automationCommand?.env ?? {}),
...(input.extraEnv ?? {})
};
......
param(
[string]$ResultPath
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$desktopAppRoot = Join-Path $repoRoot 'apps\desktop'
$sourcePath = Join-Path $repoRoot 'build\scripts\ffmpeg-runtime-smoke.ts'
$tempRoot = Join-Path $repoRoot '.tmp\ffmpeg-runtime-smoke'
$compileRoot = Join-Path $tempRoot 'compiled'
$entryPath = Join-Path $compileRoot 'build\scripts\ffmpeg-runtime-smoke.js'
$compilePackagePath = Join-Path $compileRoot 'package.json'
$resolvedResultPath = if ($ResultPath) { $ResultPath } else { Join-Path $tempRoot 'result.json' }
function Write-Utf8File {
param([string]$FilePath, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($FilePath)) | Out-Null
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
}
if (-not (Test-Path $sourcePath)) {
throw "ffmpeg runtime smoke source was not found: $sourcePath"
}
if (Test-Path $compileRoot) {
Remove-Item $compileRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $compileRoot -Force | Out-Null
$compileArgs = @(
'pnpm',
'--dir', $desktopAppRoot,
'exec',
'tsc',
'--module', 'ES2022',
'--moduleResolution', 'node',
'--target', 'ES2022',
'--lib', 'ES2022',
'--types', 'node',
'--esModuleInterop',
'--allowSyntheticDefaultImports',
'--skipLibCheck',
'--outDir', $compileRoot,
$sourcePath
)
Write-Host 'Compiling ffmpeg runtime smoke with local TypeScript'
corepack @compileArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $entryPath)) {
throw "ffmpeg runtime smoke entry was not emitted: $entryPath"
}
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
Write-Host 'Running ffmpeg runtime smoke'
node $entryPath $resolvedResultPath
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $resolvedResultPath)) {
throw "ffmpeg runtime smoke did not produce a result file: $resolvedResultPath"
}
import { existsSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { ProjectWorkspaceExecutorService } from "../../apps/desktop/src/main/services/project-workspace-executor.js";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
async function writeUtf8(filePath: string, content: string): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, content, "utf8");
}
async function main(): Promise<void> {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = process.cwd();
const resultPath = path.resolve(process.argv[2] ?? path.join(repoRoot, ".tmp", "ffmpeg-runtime-smoke", "result.json"));
const tempRoot = path.dirname(resultPath);
const runtimeManagerModule = await import(pathToFileURL(path.join(repoRoot, "packages", "runtime-manager", "dist", "index.js")).href);
const powerShellPath = path.join(
process.env.SYSTEMROOT ?? process.env.WINDIR ?? "C:\\Windows",
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe"
);
const RuntimeManager = runtimeManagerModule.RuntimeManager as {
new (options: { vendorRuntimeDir: string; runtimeDataDir: string; logFilePath: string }): {
resolveBundledPaths(): {
ffmpegExecutable?: string;
ffprobeExecutable?: string;
};
};
};
await rm(tempRoot, { recursive: true, force: true });
await mkdir(tempRoot, { recursive: true });
assert(existsSync(powerShellPath), "Windows PowerShell was not found for workspace automation smoke.");
const stableRuntimeDir = path.join(tempRoot, "stable-runtime");
const stableFfmpegPath = path.join(stableRuntimeDir, "ffmpeg", "bin", "ffmpeg.exe");
const stableFfprobePath = path.join(stableRuntimeDir, "ffmpeg", "bin", "ffprobe.exe");
await writeUtf8(stableFfmpegPath, "stable-ffmpeg");
await writeUtf8(stableFfprobePath, "stable-ffprobe");
const stableRuntimeManager = new RuntimeManager({
vendorRuntimeDir: stableRuntimeDir,
runtimeDataDir: path.join(tempRoot, "stable-runtime-data"),
logFilePath: path.join(tempRoot, "stable-runtime-data", "logs", "runtime-manager.log")
});
const stablePaths = stableRuntimeManager.resolveBundledPaths();
assert(stablePaths.ffmpegExecutable === stableFfmpegPath, "Stable bundled runtime did not resolve ffmpeg/bin/ffmpeg.exe.");
assert(stablePaths.ffprobeExecutable === stableFfprobePath, "Stable bundled runtime did not resolve ffmpeg/bin/ffprobe.exe.");
const legacyRuntimeDir = path.join(tempRoot, "legacy-runtime");
const legacyFfmpegPath = path.join(
legacyRuntimeDir,
"python",
"Lib",
"site-packages",
"imageio_ffmpeg",
"binaries",
"ffmpeg-win-x86_64-v7.1.exe"
);
await writeUtf8(legacyFfmpegPath, "legacy-ffmpeg");
const legacyRuntimeManager = new RuntimeManager({
vendorRuntimeDir: legacyRuntimeDir,
runtimeDataDir: path.join(tempRoot, "legacy-runtime-data"),
logFilePath: path.join(tempRoot, "legacy-runtime-data", "logs", "runtime-manager.log")
});
const legacyPaths = legacyRuntimeManager.resolveBundledPaths();
assert(legacyPaths.ffmpegExecutable === legacyFfmpegPath, "Legacy imageio ffmpeg fallback was not resolved.");
assert(!legacyPaths.ffprobeExecutable, "Legacy runtime should not invent an ffprobe path when none exists.");
const projectRoot = path.join(tempRoot, "workspace-project");
await writeUtf8(path.join(projectRoot, "project.json"), JSON.stringify({
workspaceAutomation: {
runtime: "python",
script: "scripts/echo_env.ps1"
}
}, null, 2));
await writeUtf8(path.join(projectRoot, "scripts", "echo_env.ps1"), [
"$payload = @{",
" FFMPEG_BIN = [string]$env:FFMPEG_BIN",
" FFPROBE_BIN = [string]$env:FFPROBE_BIN",
"} | ConvertTo-Json -Compress",
"Write-Output 'QJC_WORKSPACE_EVENT\t{\"type\":\"started\",\"runId\":\"ffmpeg-runtime-smoke-run\"}'",
"Write-Output ('QJC_WORKSPACE_EVENT\t{\"type\":\"completed\",\"content\":' + (ConvertTo-Json $payload -Compress) + '}')",
].join("\r\n"));
const runtimeManagerStub = {
async status() {
return {
payloadState: "ready"
};
},
async syncManagedConfig() {
return {
payloadState: "ready"
};
},
resolveBundledPaths() {
return {
runtimeDir: stableRuntimeDir,
nodeExecutable: path.join(stableRuntimeDir, "node", "node.exe"),
openClawEntry: path.join(stableRuntimeDir, "openclaw", "index.js"),
packagedOpenClawEntry: path.join(stableRuntimeDir, "openclaw", "package", "openclaw.mjs"),
runtimeManifestPath: path.join(stableRuntimeDir, "runtime-manifest.json"),
defaultConfigPath: path.join(stableRuntimeDir, "config", "openclaw.json"),
pythonExecutable: powerShellPath,
pythonManifestPath: path.join(stableRuntimeDir, "python", "python-manifest.json"),
ffmpegExecutable: stableFfmpegPath,
ffprobeExecutable: stableFfprobePath,
playwrightBrowsersPath: path.join(stableRuntimeDir, "playwright-browsers"),
managedConfigPath: path.join(tempRoot, "stable-runtime-data", "state", "openclaw.runtime.json"),
readmePath: path.join(stableRuntimeDir, "README.md"),
runtimeDataDir: path.join(tempRoot, "stable-runtime-data"),
runtimeStateDir: path.join(tempRoot, "stable-runtime-data", "state"),
runtimeLogsDir: path.join(tempRoot, "stable-runtime-data", "logs"),
logFilePath: path.join(tempRoot, "stable-runtime-data", "logs", "runtime-manager.log")
};
}
};
const executor = new ProjectWorkspaceExecutorService(
runtimeManagerStub as unknown as ConstructorParameters<typeof ProjectWorkspaceExecutorService>[0]
);
const execution = await executor.execute({
sessionId: "ffmpeg-runtime-smoke-session",
projectRoot,
prompt: "verify ffmpeg injection"
});
const injectedPayload = JSON.parse(execution.reply.content || "{}") as {
FFMPEG_BIN?: string;
FFPROBE_BIN?: string;
};
assert(injectedPayload.FFMPEG_BIN === stableFfmpegPath, "Workspace automation did not receive the bundled FFMPEG_BIN path.");
assert(injectedPayload.FFPROBE_BIN === stableFfprobePath, "Workspace automation did not receive the bundled FFPROBE_BIN path.");
const summary = {
ok: true,
stable: {
ffmpegExecutable: stablePaths.ffmpegExecutable,
ffprobeExecutable: stablePaths.ffprobeExecutable
},
legacy: {
ffmpegExecutable: legacyPaths.ffmpegExecutable,
ffprobeExecutable: legacyPaths.ffprobeExecutable ?? null
},
injectedPayload
};
await writeUtf8(resultPath, JSON.stringify(summary, null, 2));
console.log(JSON.stringify(summary, null, 2));
}
main().catch(async (error) => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = process.cwd();
const resultPath = path.resolve(process.argv[2] ?? path.join(repoRoot, ".tmp", "ffmpeg-runtime-smoke", "result.json"));
const failure = {
ok: false,
error: error instanceof Error ? error.stack ?? error.message : String(error)
};
await writeUtf8(resultPath, JSON.stringify(failure, null, 2));
console.error(failure.error);
process.exitCode = 1;
});
......@@ -4,6 +4,7 @@ param(
[string]$SourceOpenClawEntry,
[string]$SourceNodeExe,
[string]$SourcePythonExe,
[string]$SourceFfprobeExe,
[string]$RequirementsPath,
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token'
......@@ -65,6 +66,8 @@ function Test-RuntimePayloadReady {
(Join-Path $RuntimeDir 'python\python.exe'),
(Join-Path $RuntimeDir 'python\python-manifest.json'),
(Join-Path $RuntimeDir 'python\runtime-requirements.lock.txt'),
(Join-Path $RuntimeDir 'ffmpeg\bin\ffmpeg.exe'),
(Join-Path $RuntimeDir 'ffmpeg\bin\ffprobe.exe'),
(Join-Path $RuntimeDir 'playwright-browsers'),
(Join-Path $RuntimeDir 'runtime-manifest.json'),
(Join-Path $RuntimeDir 'README.md')
......@@ -163,6 +166,62 @@ function Get-RuntimePayloadStats {
}
}
function Resolve-OptionalBinarySource {
param(
[string]$ExplicitPath,
[string[]]$CandidatePaths = @(),
[string[]]$CommandNames = @()
)
if ($ExplicitPath) {
$resolvedExplicit = [System.IO.Path]::GetFullPath($ExplicitPath)
if (-not (Test-Path $resolvedExplicit)) {
throw "Optional binary source was not found at $resolvedExplicit"
}
return $resolvedExplicit
}
foreach ($candidatePath in $CandidatePaths) {
if (-not $candidatePath) {
continue
}
$resolvedCandidate = [System.IO.Path]::GetFullPath($candidatePath)
if (Test-Path $resolvedCandidate) {
return $resolvedCandidate
}
}
foreach ($commandName in $CommandNames) {
$command = Get-Command $commandName -ErrorAction SilentlyContinue
if ($command -and $command.Source -and (Test-Path $command.Source)) {
return [System.IO.Path]::GetFullPath($command.Source)
}
}
return $null
}
function Get-ImageioFfmpegBinaryPath {
param(
[Parameter(Mandatory = $true)]
[string]$PythonDir
)
$binariesDir = Join-Path $PythonDir 'Lib\site-packages\imageio_ffmpeg\binaries'
if (-not (Test-Path $binariesDir)) {
throw "imageio-ffmpeg binaries directory was not found at $binariesDir"
}
$candidate = Get-ChildItem -LiteralPath $binariesDir -File -Filter 'ffmpeg-*.exe' -ErrorAction SilentlyContinue |
Sort-Object Name |
Select-Object -First 1
if (-not $candidate) {
throw "imageio-ffmpeg did not materialize a bundled ffmpeg executable under $binariesDir"
}
return $candidate.FullName
}
function Remove-OptionalLiteralPath {
param(
[Parameter(Mandatory = $true)]
......@@ -420,6 +479,8 @@ function New-RuntimeSummary {
pythonExecutable = (Join-Path $RuntimeDir 'python\python.exe')
pythonManifestPath = (Join-Path $RuntimeDir 'python\python-manifest.json')
requirementsPath = (Join-Path $RuntimeDir 'python\runtime-requirements.lock.txt')
ffmpegExecutable = (Join-Path $RuntimeDir 'ffmpeg\bin\ffmpeg.exe')
ffprobeExecutable = (Join-Path $RuntimeDir 'ffmpeg\bin\ffprobe.exe')
playwrightBrowsersPath = (Join-Path $RuntimeDir 'playwright-browsers')
gatewayPort = $Manifest.gatewayPort
gatewayToken = $Manifest.gatewayToken
......@@ -476,6 +537,20 @@ $RequirementsPath = [System.IO.Path]::GetFullPath($RequirementsPath)
$SourceOpenClawDir = Split-Path $SourceOpenClawEntry -Parent
$SourcePythonDir = Split-Path $SourcePythonExe -Parent
$SourceOpenClawPackageJsonPath = Join-Path $SourceOpenClawDir 'package.json'
$ResolvedSourceFfprobeExe = Resolve-OptionalBinarySource `
-ExplicitPath $SourceFfprobeExe `
-CandidatePaths @(
(Join-Path $env:USERPROFILE 'ffmpeg\current\bin\ffprobe.exe'),
(Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg\bin\ffprobe.exe'),
(Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\BtbN.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg\bin\ffprobe.exe'),
(Join-Path $env:ProgramFiles 'ffmpeg\bin\ffprobe.exe'),
'C:\ffmpeg\bin\ffprobe.exe'
) `
-CommandNames @('ffprobe')
if (-not $ResolvedSourceFfprobeExe) {
throw "ffprobe.exe is required for bundled Douyin/video analysis workflows. Install FFmpeg with ffprobe, place it at $env:USERPROFILE\ffmpeg\current\bin\ffprobe.exe, or pass -SourceFfprobeExe C:\path\to\ffprobe.exe."
}
if (-not (Test-Path $SourceConfigPath)) {
throw "OpenClaw config not found at $SourceConfigPath"
......@@ -503,7 +578,7 @@ if (-not (Test-Path $SourcePythonDir)) {
}
$materializationInputs = [ordered]@{
schemaVersion = 2
schemaVersion = 4
gatewayPort = $GatewayPort
gatewayToken = $GatewayToken
sourceConfig = Get-FileFingerprint -Path $SourceConfigPath -IncludeHash
......@@ -512,6 +587,7 @@ $materializationInputs = [ordered]@{
sourceOpenClawEntry = Get-FileFingerprint -Path $SourceOpenClawEntry
sourceOpenClawPackageJson = Get-FileFingerprint -Path $SourceOpenClawPackageJsonPath -IncludeHash
requirements = Get-FileFingerprint -Path $RequirementsPath -IncludeHash
sourceFfprobeExe = if ($ResolvedSourceFfprobeExe) { Get-FileFingerprint -Path $ResolvedSourceFfprobeExe } else { $null }
}
$materializationKey = Get-MaterializationKey -Inputs $materializationInputs
$existingManifestPath = Join-Path $RuntimeDir 'runtime-manifest.json'
......@@ -534,12 +610,16 @@ $openclawDir = Join-Path $stagingDir 'openclaw'
$openclawPackageDir = Join-Path $openclawDir 'package'
$configDir = Join-Path $stagingDir 'config'
$pythonDir = Join-Path $stagingDir 'python'
$ffmpegDir = Join-Path $stagingDir 'ffmpeg'
$ffmpegBinDir = Join-Path $ffmpegDir 'bin'
$playwrightBrowsersDir = Join-Path $stagingDir 'playwright-browsers'
$manifestPath = Join-Path $stagingDir 'runtime-manifest.json'
$wrapperPath = Join-Path $openclawDir 'index.js'
$configPath = Join-Path $configDir 'openclaw.json'
$pythonManifestPath = Join-Path $pythonDir 'python-manifest.json'
$pythonRequirementsCopy = Join-Path $pythonDir 'runtime-requirements.lock.txt'
$payloadFfmpegExe = Join-Path $ffmpegBinDir 'ffmpeg.exe'
$payloadFfprobeExe = Join-Path $ffmpegBinDir 'ffprobe.exe'
$payloadReadmePath = Join-Path $stagingDir 'README.md'
$payloadPythonExe = Join-Path $pythonDir 'python.exe'
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
......@@ -549,7 +629,7 @@ if (Test-Path $stagingDir) {
}
try {
New-Item -ItemType Directory -Force -Path $nodeDir, $openclawDir, $configDir, $playwrightBrowsersDir | Out-Null
New-Item -ItemType Directory -Force -Path $nodeDir, $openclawDir, $configDir, $ffmpegBinDir, $playwrightBrowsersDir | Out-Null
Copy-Item -Path $SourceNodeExe -Destination (Join-Path $nodeDir 'node.exe') -Force
Write-Host "Copying OpenClaw package from $SourceOpenClawDir"
......@@ -645,6 +725,15 @@ print(json.dumps(payload))
}
[System.IO.File]::WriteAllText($pythonManifestPath, ($pythonManifest | ConvertTo-Json -Depth 100), $utf8NoBom)
$imageioFfmpegSource = Get-ImageioFfmpegBinaryPath -PythonDir $pythonDir
Copy-Item -LiteralPath $imageioFfmpegSource -Destination $payloadFfmpegExe -Force
if (-not (Test-Path $payloadFfmpegExe)) {
throw "Bundled ffmpeg executable was not materialized at $payloadFfmpegExe"
}
if ($ResolvedSourceFfprobeExe) {
Copy-Item -LiteralPath $ResolvedSourceFfprobeExe -Destination $payloadFfprobeExe -Force
}
$openClawPackageJsonPath = Join-Path $openclawPackageDir 'package.json'
$openClawPackage = if (Test-Path $openClawPackageJsonPath) {
Get-Content $openClawPackageJsonPath -Raw | ConvertFrom-Json
......@@ -660,6 +749,8 @@ print(json.dumps(payload))
pythonExecutable = 'python/python.exe'
pythonManifestPath = 'python/python-manifest.json'
requirementsPath = 'python/runtime-requirements.lock.txt'
ffmpegExecutable = 'ffmpeg/bin/ffmpeg.exe'
ffprobeExecutable = 'ffmpeg/bin/ffprobe.exe'
playwrightBrowsersPath = 'playwright-browsers'
gatewayPort = $GatewayPort
gatewayToken = $GatewayToken
......@@ -711,11 +802,13 @@ Immutable packaged payload under `vendor/openclaw-runtime/` includes:
- `python/python.exe`
- `python/python-manifest.json`
- `python/runtime-requirements.lock.txt`
- `ffmpeg/bin/ffmpeg.exe`
- `ffmpeg/bin/ffprobe.exe`
- `playwright-browsers/`
Mutable runtime data lives outside the installer payload and should be created under Electron `userData/runtime/`.
The payload is considered ready only when the Node entry, OpenClaw package, Python executable, Python manifest, and locked Python imports all validate successfully on the target machine.
The payload is considered ready only when the Node entry, OpenClaw package, Python executable, Python manifest, FFmpeg/FFprobe tools, and locked Python imports all validate successfully on the target machine.
"@
[System.IO.File]::WriteAllText($payloadReadmePath, $readme.TrimStart(), $utf8NoBom)
......@@ -746,6 +839,8 @@ The payload is considered ready only when the Node entry, OpenClaw package, Pyth
$configPath,
$payloadPythonExe,
$pythonManifestPath,
$payloadFfmpegExe,
$payloadFfprobeExe,
$manifestPath,
$payloadReadmePath
)
......
......@@ -32,6 +32,7 @@
"smoke:project-package-orchestrator": "powershell -ExecutionPolicy Bypass -File build/scripts/project-package-orchestrator-smoke.ps1",
"smoke:project-isolation": "powershell -ExecutionPolicy Bypass -File build/scripts/project-isolation-smoke.ps1",
"smoke:materialize-cache": "powershell -ExecutionPolicy Bypass -File build/scripts/materialize-runtime-cache-smoke.ps1",
"smoke:ffmpeg-runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/ffmpeg-runtime-smoke.ps1",
"smoke:project-context-refresh": "powershell -ExecutionPolicy Bypass -File build/scripts/project-context-refresh-smoke.ps1",
"smoke:empty-project-inventory": "powershell -ExecutionPolicy Bypass -File build/scripts/project-empty-inventory-smoke.ps1",
"smoke:bundle-reconcile": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-reconcile-smoke.ps1",
......
import { EventEmitter } from "node:events";
import { execFile, spawn, type ChildProcess } from "node:child_process";
import { appendFileSync, mkdirSync } from "node:fs";
import { appendFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
......@@ -33,6 +33,8 @@ export interface RuntimeResolvedPaths {
defaultConfigPath: string;
pythonExecutable: string;
pythonManifestPath: string;
ffmpegExecutable?: string;
ffprobeExecutable?: string;
playwrightBrowsersPath: string;
managedConfigPath: string;
readmePath: string;
......@@ -446,6 +448,121 @@ async function probePythonPayload(pythonExecutable: string): Promise<PythonPaylo
}
}
function resolveExecutableOnPath(commandNames: readonly string[]): string | undefined {
const pathEntries = (process.env.PATH ?? "")
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
const windowsExtensions = process.platform === "win32"
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
.split(";")
.map((entry) => entry.trim())
.filter(Boolean)
: [""];
for (const commandName of commandNames) {
const trimmed = commandName.trim();
if (!trimmed) {
continue;
}
if (path.isAbsolute(trimmed) && existsSync(trimmed)) {
return trimmed;
}
const extensionCandidates = path.extname(trimmed)
? [""]
: windowsExtensions;
for (const directory of pathEntries) {
for (const extension of extensionCandidates) {
const candidate = path.join(directory, `${trimmed}${extension}`);
if (existsSync(candidate)) {
return candidate;
}
}
}
}
return undefined;
}
function resolveBundledFfmpegExecutable(runtimeDir: string): string | undefined {
const stablePath = path.join(runtimeDir, "ffmpeg", "bin", "ffmpeg.exe");
if (existsSync(stablePath)) {
return stablePath;
}
const imageioBinariesDir = path.join(runtimeDir, "python", "Lib", "site-packages", "imageio_ffmpeg", "binaries");
if (existsSync(imageioBinariesDir)) {
try {
const imageioCandidate = readdirSync(imageioBinariesDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && /^ffmpeg-.*\.exe$/iu.test(entry.name))
.map((entry) => path.join(imageioBinariesDir, entry.name))
.sort((left, right) => left.localeCompare(right, "en"))[0];
if (imageioCandidate && existsSync(imageioCandidate)) {
return imageioCandidate;
}
} catch {
// Ignore bundled imageio lookup failures and continue to other fallback locations.
}
}
const playwrightBrowsersDir = path.join(runtimeDir, "playwright-browsers");
if (existsSync(playwrightBrowsersDir)) {
try {
for (const entry of readdirSync(playwrightBrowsersDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !/^ffmpeg-/iu.test(entry.name)) {
continue;
}
const candidate = path.join(playwrightBrowsersDir, entry.name, "ffmpeg-win64.exe");
if (existsSync(candidate)) {
return candidate;
}
}
} catch {
// Ignore Playwright fallback lookup failures and continue with PATH fallback at injection time.
}
}
return undefined;
}
function resolveBundledFfprobeExecutable(runtimeDir: string, ffmpegExecutable?: string): string | undefined {
const stablePath = path.join(runtimeDir, "ffmpeg", "bin", "ffprobe.exe");
if (existsSync(stablePath)) {
return stablePath;
}
if (ffmpegExecutable) {
for (const candidate of [
path.join(path.dirname(ffmpegExecutable), "ffprobe.exe"),
path.join(path.dirname(ffmpegExecutable), "ffprobe")
]) {
if (existsSync(candidate)) {
return candidate;
}
}
}
return undefined;
}
function resolveInjectedBinaryPath(
envName: string,
bundledPath: string | undefined,
commandNames: readonly string[]
): string | undefined {
const configured = process.env[envName]?.trim();
if (configured) {
return configured;
}
if (bundledPath && existsSync(bundledPath)) {
return bundledPath;
}
return resolveExecutableOnPath(commandNames);
}
interface PythonManifestPackageShape {
name?: unknown;
}
......@@ -648,6 +765,8 @@ export class RuntimeManager extends EventEmitter {
}
resolveBundledPaths(): RuntimeResolvedPaths {
const ffmpegExecutable = resolveBundledFfmpegExecutable(this.vendorRuntimeDir);
const ffprobeExecutable = resolveBundledFfprobeExecutable(this.vendorRuntimeDir, ffmpegExecutable);
return {
runtimeDir: this.vendorRuntimeDir,
nodeExecutable: path.join(this.vendorRuntimeDir, "node", "node.exe"),
......@@ -657,6 +776,8 @@ export class RuntimeManager extends EventEmitter {
defaultConfigPath: path.join(this.vendorRuntimeDir, "config", "openclaw.json"),
pythonExecutable: path.join(this.vendorRuntimeDir, "python", "python.exe"),
pythonManifestPath: path.join(this.vendorRuntimeDir, "python", "python-manifest.json"),
ffmpegExecutable,
ffprobeExecutable,
playwrightBrowsersPath: path.join(this.vendorRuntimeDir, "playwright-browsers"),
managedConfigPath: path.join(this.runtimeDataDir, "state", "openclaw.runtime.json"),
readmePath: path.join(this.vendorRuntimeDir, "README.md"),
......@@ -729,6 +850,8 @@ export class RuntimeManager extends EventEmitter {
configExists ? paths.defaultConfigPath : null,
pythonExists ? paths.pythonExecutable : null,
pythonManifestExists ? paths.pythonManifestPath : null,
paths.ffmpegExecutable ?? null,
paths.ffprobeExecutable ?? null,
readmeExists ? paths.readmePath : null
].filter((value): value is string => Boolean(value));
......@@ -1330,10 +1453,21 @@ export class RuntimeManager extends EventEmitter {
childEnv.OPENCLAW_HOME = this.runtimeDataDir;
childEnv.OPENCLAW_STATE_DIR = paths.runtimeStateDir;
childEnv.OPENCLAW_CONFIG_PATH = managedConfigPath;
childEnv.QJCLAW_BUNDLED_RUNTIME_DIR = paths.runtimeDir;
childEnv.PLAYWRIGHT_BROWSERS_PATH = paths.playwrightBrowsersPath;
childEnv.PYTHONUTF8 = "1";
childEnv.PYTHONIOENCODING = "utf-8";
const resolvedFfmpeg = resolveInjectedBinaryPath("FFMPEG_BIN", paths.ffmpegExecutable, ["ffmpeg.exe", "ffmpeg"]);
const resolvedFfprobe = resolveInjectedBinaryPath("FFPROBE_BIN", paths.ffprobeExecutable, ["ffprobe.exe", "ffprobe"]);
if (resolvedFfmpeg) {
childEnv.FFMPEG_BIN = resolvedFfmpeg;
}
if (resolvedFfprobe) {
childEnv.FFPROBE_BIN = resolvedFfprobe;
}
childEnv.PATH = [
paths.ffmpegExecutable ? path.dirname(paths.ffmpegExecutable) : null,
paths.ffprobeExecutable ? path.dirname(paths.ffprobeExecutable) : null,
path.join(paths.runtimeDir, "python", "Scripts"),
path.dirname(paths.pythonExecutable),
process.env.PATH ?? ""
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment