diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db71337..d3acb5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,44 @@ jobs: run: node scripts/check-release-notes.mjs - name: One version across core, app and desktop run: node scripts/version.mjs + desktop: + # Builds the self-contained desktop app for each platform and checks it runs with nothing else installed. + # Pull requests only: it takes several minutes per platform. + name: desktop app (${{ matrix.os }}) + if: github.event_name == 'pull_request' + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + app/package-lock.json + desktop/package-lock.json + - uses: astral-sh/setup-uv@v6 + - name: Build web app + working-directory: app + run: npm ci && npm run build + - name: Bundle and package + working-directory: desktop + shell: bash + env: + CSC_IDENTITY_AUTO_DISCOVERY: false + run: | + npm ci + npm test + npm run bundle + npx electron-builder --dir ${{ runner.os == 'macOS' && '--arm64' || '' }} + - name: Check the packaged app works on its own + working-directory: desktop + shell: bash + run: ${{ runner.os == 'Linux' && 'xvfb-run -a --server-args="-screen 0 1600x1000x24"' || '' }} node scripts/check-package.mjs core: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 412763c..18c313e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,9 +64,14 @@ jobs: echo "Tag $GITHUB_REF_NAME does not match version $version"; exit 1 fi node scripts/changelog-section.mjs "$version" --require > /dev/null + - uses: astral-sh/setup-uv@v6 - name: Build web app working-directory: app run: npm ci && npm run build + # Installers carry their own Python, core, web app and FFmpeg so they work with nothing else installed. + - name: Bundle Python, core, web app and FFmpeg + working-directory: desktop + run: npm ci && npm test && npm run bundle - name: Build installers (unsigned until certificates are configured, see OPEN_QUESTIONS Q3) working-directory: desktop shell: bash @@ -75,10 +80,11 @@ jobs: # APPLE_TEAM_ID (notarisation) as repository secrets, then remove "identity": null from package.json. # Until then macOS builds are ad-hoc signed by desktop/scripts/adhoc-sign-mac.mjs. CSC_IDENTITY_AUTO_DISCOVERY: false - run: | - npm ci - npm test - npx electron-builder --publish never ${{ runner.os == 'macOS' && '--arm64' || '' }} + run: npx electron-builder --publish never ${{ runner.os == 'macOS' && '--arm64' || '' }} + - name: Check the packaged app works on its own + working-directory: desktop + shell: bash + run: ${{ runner.os == 'Linux' && 'xvfb-run -a --server-args="-screen 0 1600x1000x24"' || '' }} node scripts/check-package.mjs - uses: actions/upload-artifact@v4 with: name: installers-${{ runner.os }} diff --git a/.gitignore b/.gitignore index bf6f5a0..5c7be04 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ app/playwright-report/ .coverage coverage.xml ci-gates.json + +# Desktop bundle (desktop/scripts/bundle.mjs) and its download cache +desktop/bundle/ +desktop/.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a3fd6e..294e5a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ still pre-1.0, minor bumps carry new features and patch bumps carry fixes. ## [Unreleased] +### Added + +- **The desktop app installs everything it needs.** The Windows installer, the macOS disk image and the Linux AppImage now include their own Python, the Metachlorian engine, FFmpeg and the web app, so nothing else has to be installed first. +- The first time it opens, the desktop app downloads the analysis models (about 1.7 GB, once) and shows its progress. If the download fails it says so and offers **Try again**, keeping anything already downloaded. + ### Fixed +- Metachlorian now runs on Windows. It previously stopped at start-up there, because it used a file lock that only exists on macOS and Linux. +- Semantic search and zero-shot labels now work on Apple Silicon Macs. Embedding shots failed when analysis used Apple's Core ML accelerator; it now runs on the CPU unless `METACHLORIAN_COREML=1` is set. + - The macOS desktop download no longer opens with "Metachlorian is damaged and can't be opened". The app is now ad-hoc signed, so macOS shows its usual unidentified-developer prompt and **System Settings → Privacy & Security → Open Anyway** works. It is still not signed with an Apple Developer ID or notarised. diff --git a/README.md b/README.md index 5f37cd5..81cefa7 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,12 @@ cd core && uv venv -p 3.11 && uv pip install -e ".[analysis]" cd ../app && npm ci && npm run build # the web app the core serves ``` -**Desktop app:** `cd desktop && npm ci && npm start` runs the web app in Electron. In solo mode it starts a local core; in team -mode it connects to a shared one. It adds native drag-out of real clip files and one-click hand-off to Cutawan. Installers are -built with `npm run package` (unsigned until signing certificates are provided, see [OPEN_QUESTIONS.md](OPEN_QUESTIONS.md)). +**Desktop app (Windows, macOS, Linux):** download the installer from the [releases page](https://github.com/JeremySNR/Metachlorian/releases) +and open it. It includes everything (its own Python, the core, FFmpeg and the web app), so nothing else needs installing. The +first launch downloads the analysis models (about 1.7 GB, once). In solo mode it runs the library on your computer; in team +mode it connects to a shared server. It adds native drag-out of real clip files and one-click hand-off to Cutawan. Installers +are not yet signed (see [OPEN_QUESTIONS.md](OPEN_QUESTIONS.md)): on macOS use **System Settings → Privacy & Security → Open +Anyway** the first time; on Windows choose **More info → Run anyway**. From source: `cd desktop && npm ci && npm start`. ## Community sharing diff --git a/core/metachlorian/__main__.py b/core/metachlorian/__main__.py new file mode 100644 index 0000000..021a5cb --- /dev/null +++ b/core/metachlorian/__main__.py @@ -0,0 +1,4 @@ +"""`python -m metachlorian` runs the CLI (the desktop app starts its bundled core this way).""" +from .cli import main + +main() diff --git a/core/metachlorian/cli.py b/core/metachlorian/cli.py index 1075e41..6669253 100644 --- a/core/metachlorian/cli.py +++ b/core/metachlorian/cli.py @@ -181,6 +181,28 @@ def cmd_models(args) -> None: print(f"{'✓' if m['installed'] else '·'} {m['name']:28s} {m['licence']:12s} {m['size_mb']:>6} MB {m['purpose']}") elif args.action == "fetch": names = args.names or [n for n, sp in models.SPECS.items() if sp.default] + if args.json: + # One JSON object per line, for the desktop app's first-run setup screen. + def emit(**event): + print(json.dumps(event), flush=True) + missing = [n for n in names if not models.installed(root, n)] + emit(event="plan", models=[{"name": n, "size_mb": models.SPECS[n].size_mb} for n in missing]) + for n in missing: + last = [0.0] + + def on_bytes(done, total, n=n, last=last): + if done - last[0] >= 4 << 20 or done == total: + last[0] = done + emit(event="progress", model=n, done=done, total=total) + emit(event="start", model=n) + try: + models.fetch(root, n, progress=lambda _m: None, on_bytes=on_bytes) + except Exception as e: # noqa: BLE001 - reported to the caller, which offers a retry + emit(event="error", model=n, message=str(e)[:300]) + sys.exit(1) + emit(event="done", model=n) + emit(event="complete") + return for n in names: print(f"fetching {n} …", flush=True) models.fetch(root, n) @@ -247,6 +269,7 @@ def main(argv: list[str] | None = None) -> None: sp = sub.add_parser("models") sp.add_argument("action", choices=["list", "fetch", "install-from"]) sp.add_argument("names", nargs="*") + sp.add_argument("--json", action="store_true", help="fetch: report progress as JSON lines") sp.set_defaults(fn=cmd_models) args = ap.parse_args(argv) logging.basicConfig(level=os.environ.get("METACHLORIAN_LOG", "WARNING")) diff --git a/core/metachlorian/llm.py b/core/metachlorian/llm.py index 36e0d27..8031701 100644 --- a/core/metachlorian/llm.py +++ b/core/metachlorian/llm.py @@ -15,7 +15,6 @@ import base64 import copy import datetime as dt -import fcntl import hashlib import json import os @@ -29,6 +28,26 @@ from pathlib import Path from typing import Any +try: + import fcntl +except ImportError: # Windows + fcntl = None + import msvcrt + + +def _lock_exclusive(f) -> None: + """Block until this process holds an exclusive lock on the open file (released when it is closed).""" + if fcntl: + fcntl.flock(f, fcntl.LOCK_EX) + else: + f.seek(0) + while True: + try: + msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1) # retries for ~10 s, then raises + return + except OSError: + continue + import httpx import jsonschema @@ -276,7 +295,7 @@ def __init__(self, settings: Settings): def _locked(self, fn): self.path.parent.mkdir(parents=True, exist_ok=True) with open(self.path.with_suffix(".lock"), "w") as lk: - fcntl.flock(lk, fcntl.LOCK_EX) + _lock_exclusive(lk) try: data = json.loads(self.path.read_text()) if self.path.exists() else {} except json.JSONDecodeError: diff --git a/core/metachlorian/models.py b/core/metachlorian/models.py index 63f2375..fbdb546 100644 --- a/core/metachlorian/models.py +++ b/core/metachlorian/models.py @@ -101,7 +101,8 @@ def require(models_root: Path, name: str) -> Path: return model_dir(models_root, name) -def fetch(models_root: Path, name: str, progress=print) -> Path: +def fetch(models_root: Path, name: str, progress=print, on_bytes=None) -> Path: + """Download a model. ``on_bytes(done, total)`` is called as each archive downloads (total is 0 if unknown).""" spec = SPECS[name] d = model_dir(models_root, name) if installed(models_root, name): @@ -111,7 +112,12 @@ def fetch(models_root: Path, name: str, progress=print) -> Path: tmp = d / (".download-" + hashlib.sha1(url.encode()).hexdigest()[:8]) progress(f"downloading {url}") with urllib.request.urlopen(url) as r, open(tmp, "wb") as f: - shutil.copyfileobj(r, f, 1 << 20) + total, done = int(r.headers.get("Content-Length") or 0), 0 + while chunk := r.read(1 << 20): + f.write(chunk) + done += len(chunk) + if on_bytes: + on_bytes(done, total) if url.endswith((".tar.bz2", ".tar.gz", ".tgz")): with tarfile.open(tmp) as tf: for m in tf.getmembers(): @@ -156,7 +162,12 @@ def ort_session(path: Path, threads: int | None = None): so.intra_op_num_threads = threads or int(os.environ.get("METACHLORIAN_THREADS", "0") or 0) or max(1, (os.cpu_count() or 2) // 2) so.inter_op_num_threads = 1 so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL - providers = [p for p in ("CUDAExecutionProvider", "CoreMLExecutionProvider", "DmlExecutionProvider") if p in ort.get_available_providers()] + # CoreML is opt-in (METACHLORIAN_COREML=1): it fails at inference on SigLIP's vision head on Apple Silicon. + wanted = ("CUDAExecutionProvider", "DmlExecutionProvider") + (("CoreMLExecutionProvider",) if os.environ.get("METACHLORIAN_COREML") == "1" else ()) + providers = [p for p in wanted if p in ort.get_available_providers()] + # METACHLORIAN_ACCELERATORS=false forces CPU everywhere. + if os.environ.get("METACHLORIAN_ACCELERATORS", "").lower() in ("0", "false", "no", "off"): + providers = [] return ort.InferenceSession(str(path), sess_options=so, providers=providers + ["CPUExecutionProvider"]) diff --git a/desktop/package.json b/desktop/package.json index 1112a73..7d83985 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -9,7 +9,9 @@ "start": "electron .", "test": "node --test src/*.test.js", "package": "electron-builder", - "package:dir": "electron-builder --dir" + "package:dir": "electron-builder --dir", + "bundle": "node scripts/bundle.mjs", + "check-package": "node scripts/check-package.mjs" }, "devDependencies": { "electron": "^39.0.0", @@ -28,13 +30,16 @@ ], "extraResources": [ { - "from": "../core", - "to": "core", - "filter": [ - "metachlorian/**", - "pyproject.toml", - "README.md" - ] + "from": "bundle/python", + "to": "python" + }, + { + "from": "bundle/app", + "to": "app" + }, + { + "from": "bundle/ffmpeg", + "to": "ffmpeg" } ], "mac": { diff --git a/desktop/scripts/bundle.mjs b/desktop/scripts/bundle.mjs new file mode 100644 index 0000000..7a451bd --- /dev/null +++ b/desktop/scripts/bundle.mjs @@ -0,0 +1,83 @@ +/** + * Builds everything the desktop app ships alongside Electron, for the platform + * it runs on, into desktop/bundle/ (packaged as Resources/{python,app,ffmpeg}): + * + * python/ a relocatable CPython 3.11 (python-build-standalone, via uv) with + * the core and its analysis dependencies installed into it + * app/ the built web app (app/dist), which the core serves + * ffmpeg/ static ffmpeg + ffprobe builds (ffmpeg-static's release assets) + * + * Needs uv on PATH and app/dist built (`cd app && npm run build`). + * Usage: node scripts/bundle.mjs (from desktop/; `npm run bundle`) + */ +import { execFileSync, spawnSync } from 'node:child_process' +import { chmodSync, cpSync, createWriteStream, existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { Readable } from 'node:stream' +import { createGunzip } from 'node:zlib' + +const desktop = resolve(import.meta.dirname, '..') +const repo = resolve(desktop, '..') +const out = join(desktop, 'bundle') +const win = process.platform === 'win32' +const PYTHON = '3.11' +// ffmpeg-static's GitHub release with prebuilt binaries per platform (GPL builds; licences are copied alongside). +const FFMPEG_RELEASE = 'https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1' + +const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: 'inherit', ...opts }) +const step = (msg) => console.log(`\n▸ ${msg}`) + +rmSync(out, { recursive: true, force: true }) +mkdirSync(out, { recursive: true }) + +// ---------------------------------------------------------------- python + core +step(`CPython ${PYTHON} (standalone)`) +const cache = join(desktop, '.cache', 'python') +run('uv', ['python', 'install', PYTHON, '--install-dir', cache]) +// uv adds a minor-version alias next to the real install; copy the real directory. +const installs = readdirSync(cache).filter((d) => d.startsWith(`cpython-${PYTHON}.`)) +if (!installs.length) throw new Error(`uv did not install CPython ${PYTHON} into ${cache}`) +const pythonHome = join(out, 'python') +cpSync(realpathSync(join(cache, installs.sort().at(-1))), pythonHome, { recursive: true, verbatimSymlinks: true }) +const python = win ? join(pythonHome, 'python.exe') : join(pythonHome, 'bin', 'python3') +// uv marks its interpreters externally managed; this copy is ours to install into. +rmSync(join(pythonHome, win ? 'Lib' : `lib/python${PYTHON}`, 'EXTERNALLY-MANAGED'), { force: true }) + +step('Metachlorian core and analysis dependencies') +run('uv', ['pip', 'install', '--python', python, '--no-cache', `${join(repo, 'core')}[analysis]`]) +// Precompile so the read-only (and, on macOS, signed) bundle is never written to at runtime. A few files in the +// standard library and in dependencies' test data aren't valid Python 3.11 and fail to compile; that is expected, +// so list them rather than failing the build. +const compiled = spawnSync(python, ['-m', 'compileall', '-q', '-j', '0', pythonHome], { encoding: 'utf8' }) +if (compiled.error) throw compiled.error +const failed = (compiled.stdout || '').split(/\r?\n/).filter((l) => /^\*\*\* Error compiling/.test(l)) +if (failed.length) console.log(` (${failed.length} file(s) not precompiled: ${failed.slice(0, 5).map((l) => l.replace(/^\*\*\* Error compiling /, '')).join(', ')}${failed.length > 5 ? ', …' : ''})`) +run(python, ['-c', 'import metachlorian, onnxruntime, cv2, sherpa_onnx, usearch; print("core", metachlorian.__version__, "imports ok")']) + +// ---------------------------------------------------------------- web app +step('Web app') +const dist = join(repo, 'app', 'dist') +if (!existsSync(join(dist, 'index.html'))) throw new Error('app/dist is missing: run `npm run build` in app/ first') +cpSync(dist, join(out, 'app'), { recursive: true }) + +// ---------------------------------------------------------------- ffmpeg +step('FFmpeg') +const target = { darwin: 'darwin', win32: 'win32', linux: 'linux' }[process.platform] + '-' + process.arch +mkdirSync(join(out, 'ffmpeg'), { recursive: true }) +async function download(name, dest, gz) { + const r = await fetch(`${FFMPEG_RELEASE}/${name}`) + if (!r.ok) throw new Error(`Download failed (${r.status}): ${FFMPEG_RELEASE}/${name}`) + const body = Readable.fromWeb(r.body) + await (gz ? pipeline(body, createGunzip(), createWriteStream(dest)) : pipeline(body, createWriteStream(dest))) +} +for (const tool of ['ffmpeg', 'ffprobe']) { + const dest = join(out, 'ffmpeg', tool + (win ? '.exe' : '')) + await download(`${tool}-${target}.gz`, dest, true) + chmodSync(dest, 0o755) + run(dest, ['-hide_banner', '-version'], { stdio: ['ignore', 'ignore', 'inherit'] }) +} +await download(`${target}.LICENSE`, join(out, 'ffmpeg', 'LICENSE'), false) +await download(`${target}.README`, join(out, 'ffmpeg', 'README'), false) + +console.log(`\nBundle ready in ${out}`) diff --git a/desktop/scripts/check-package.mjs b/desktop/scripts/check-package.mjs new file mode 100644 index 0000000..faf8227 --- /dev/null +++ b/desktop/scripts/check-package.mjs @@ -0,0 +1,93 @@ +/** + * Checks a packaged desktop app works with nothing else installed: the bundled + * Python runs the core, FFmpeg runs, the core serves the web app, a short clip + * goes through analysis, and the Electron app starts and shows the library. + * + * Usage (from desktop/, after `electron-builder --dir`): + * node scripts/check-package.mjs [unpacked app dir] [--no-electron] + * Defaults to release/mac-arm64, release/win-unpacked or release/linux-unpacked. + * Model downloads are skipped (models: none); analysers needing them report "unavailable". + */ +import { execFileSync, spawn } from 'node:child_process' +import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const desktop = resolve(import.meta.dirname, '..') +const win = process.platform === 'win32' +const mac = process.platform === 'darwin' +const appDir = resolve(process.argv.slice(2).find((a) => !a.startsWith('--')) ?? + join(desktop, 'release', mac ? 'mac-arm64' : win ? 'win-unpacked' : 'linux-unpacked')) +const resources = mac ? join(appDir, 'Metachlorian.app', 'Contents', 'Resources') : join(appDir, 'resources') +const electronExe = mac ? join(appDir, 'Metachlorian.app', 'Contents', 'MacOS', 'Metachlorian') + : join(appDir, win ? 'Metachlorian.exe' : 'metachlorian-desktop') +const python = join(resources, 'python', win ? 'python.exe' : join('bin', 'python3')) +const ff = (tool) => join(resources, 'ffmpeg', tool + (win ? '.exe' : '')) +const work = mkdtempSync(join(tmpdir(), 'mc-package-check-')) +const env = { + ...process.env, METACHLORIAN_APP_DIST: join(resources, 'app'), METACHLORIAN_FFMPEG: ff('ffmpeg'), METACHLORIAN_FFPROBE: ff('ffprobe'), + PYTHONDONTWRITEBYTECODE: '1', PYTHONNOUSERSITE: '1', METACHLORIAN_COMMUNITY_ENABLED: 'false', +} +delete env.PYTHONHOME +delete env.PYTHONPATH +const ok = (msg) => console.log(`✓ ${msg}`) +const core = (...args) => execFileSync(python, ['-m', 'metachlorian', '--data', join(work, 'lib'), ...args], { env, encoding: 'utf8' }) + +for (const p of [python, ff('ffmpeg'), ff('ffprobe'), join(resources, 'app', 'index.html'), electronExe]) { + if (!existsSync(p)) throw new Error(`Missing from the package: ${p}`) +} +ok(`package contents (${appDir})`) + +execFileSync(ff('ffmpeg'), ['-hide_banner', '-version'], { stdio: 'ignore' }) +execFileSync(ff('ffprobe'), ['-hide_banner', '-version'], { stdio: 'ignore' }) +ok('ffmpeg and ffprobe run') + +const version = execFileSync(python, ['-c', 'import metachlorian, onnxruntime, cv2, sherpa_onnx, usearch, scenedetect; print(metachlorian.__version__)'], { env, encoding: 'utf8' }).trim() +ok(`bundled core ${version} and its native dependencies import`) + +// A 6-second clip with a hard cut and a tone, analysed end to end. +const footage = join(work, 'footage') +mkdirSync(footage) +execFileSync(ff('ffmpeg'), ['-hide_banner', '-v', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=25:duration=3', + '-f', 'lavfi', '-i', 'smptebars=size=640x360:rate=25:duration=3', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=6', + '-filter_complex', '[0:v][1:v]concat=n=2:v=1[v]', '-map', '[v]', '-map', '2:a', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', + join(footage, 'check.mp4')]) +core('init') +core('add', footage) +core('process') +const status = core('status') +if (!/\b1\b/.test(status)) throw new Error(`Unexpected status after processing:\n${status}`) +const shots = execFileSync(python, ['-c', `import sqlite3,sys; print(sqlite3.connect(sys.argv[1]).execute("select count(*) from shots").fetchone()[0])`, join(work, 'lib', 'library.sqlite')], { env, encoding: 'utf8' }).trim() +if (Number(shots) < 2) throw new Error(`Expected the hard cut to give at least 2 shots, got ${shots}`) +ok(`a clip with a hard cut was analysed into ${shots} shots`) + +// The core serves the API and the bundled web app. +const port = 18765 + Math.floor(Math.random() * 1000) +const server = spawn(python, ['-m', 'metachlorian', '--data', join(work, 'lib'), 'serve', '--host', '127.0.0.1', '--port', String(port)], { env, stdio: 'ignore' }) +try { + let health = null + for (let i = 0; i < 300 && !health; i++) { + await new Promise((r) => setTimeout(r, 400)) + health = await fetch(`http://127.0.0.1:${port}/api/health`).then((r) => r.ok ? r.json() : null).catch(() => null) + } + if (!health || health.version !== version) throw new Error(`Core did not answer /api/health on port ${port}`) + const page = await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()) + if (!/
{ try { return JSON.parse(l) } catch { return null } }).find((r) => r && r.smoke) + if (!result || result.smoke !== 'ok' || !result.bridge) throw new Error(`Desktop app smoke test failed:\n${out.slice(-2000)}`) + ok(`desktop app started its bundled core and showed the library (screenshot: ${shot})`) +} +writeFileSync(join(work, 'ok'), version) +console.log(`\nPackage check passed (work dir ${work})`) diff --git a/desktop/src/core.js b/desktop/src/core.js index 2049a21..f5df165 100644 --- a/desktop/src/core.js +++ b/desktop/src/core.js @@ -32,16 +32,70 @@ async function health(baseUrl, timeoutMs = 1500) { } } -// Where the `metachlorian` command lives: env override, bundled venv, user install, PATH. +// How to run the core: { cmd, prefix (args before ours), env }. +// Installed apps carry their own Python, core, web app and FFmpeg in resources (desktop/scripts/bundle.mjs); +// a source checkout falls back to an env override, the install.sh venv, the repo venv, then PATH. function findCoreCommand(resourcesPath) { + const win = process.platform === 'win32'; + const bundled = resourcesPath && path.join(resourcesPath, 'python', win ? 'python.exe' : path.join('bin', 'python3')); + if (!process.env.METACHLORIAN_BIN && bundled && fs.existsSync(bundled)) { + const ff = (tool) => path.join(resourcesPath, 'ffmpeg', tool + (win ? '.exe' : '')); + return { + cmd: bundled, + prefix: ['-m', 'metachlorian'], + env: { + METACHLORIAN_APP_DIST: path.join(resourcesPath, 'app'), + METACHLORIAN_FFMPEG: ff('ffmpeg'), + METACHLORIAN_FFPROBE: ff('ffprobe'), + // The bundle is read-only (and signed on macOS): never write bytecode or read the user's site-packages. + PYTHONDONTWRITEBYTECODE: '1', + PYTHONNOUSERSITE: '1', + PYTHONHOME: '', PYTHONPATH: '', + }, + }; + } const candidates = []; if (process.env.METACHLORIAN_BIN) candidates.push(process.env.METACHLORIAN_BIN); - const exe = process.platform === 'win32' ? path.join('Scripts', 'metachlorian.exe') : path.join('bin', 'metachlorian'); - if (resourcesPath) candidates.push(path.join(resourcesPath, 'python', exe)); + const exe = win ? path.join('Scripts', 'metachlorian.exe') : path.join('bin', 'metachlorian'); candidates.push(path.join(os.homedir(), '.local', 'src', 'metachlorian', '.venv', exe)); candidates.push(path.join(__dirname, '..', '..', 'core', '.venv', exe)); - for (const c of candidates) if (c && fs.existsSync(c)) return c; - return 'metachlorian'; // rely on PATH + const cmd = candidates.find((c) => c && fs.existsSync(c)) || 'metachlorian'; // rely on PATH + return { cmd, prefix: [], env: {} }; +} + +function runCore(core, args) { + const env = { ...process.env, ...core.env }; + for (const k of Object.keys(env)) if (env[k] === '') delete env[k]; + return spawn(core.cmd, [...core.prefix, ...args], { stdio: ['ignore', 'pipe', 'pipe'], env, windowsHide: true }); +} + +// First-run setup: download any missing analysis models, reporting progress. +// onEvent receives the JSON events of `metachlorian models fetch --json` (plan, start, progress, done, complete). +function ensureModels({ resourcesPath, dataDir, onEvent, onLog } = {}) { + const core = findCoreCommand(resourcesPath); + const args = dataDir ? ['--data', dataDir] : []; + const child = runCore(core, [...args, 'models', 'fetch', '--json']); + let buf = ''; + let failure = null; + child.stdout.on('data', (d) => { + buf += String(d); + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i).trim(); + buf = buf.slice(i + 1); + if (!line) continue; + try { + const ev = JSON.parse(line); + if (ev.event === 'error') failure = ev.message; + onEvent && onEvent(ev); + } catch { onLog && onLog(line + '\n'); } + } + }); + child.stderr.on('data', (d) => onLog && onLog(String(d))); + return new Promise((resolve, reject) => { + child.on('error', (e) => reject(new Error(`Could not start the Metachlorian engine (${core.cmd}): ${e.message}`))); + child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(failure || `Model setup stopped unexpectedly (exit ${code}).`))); + }); } async function startSoloCore({ resourcesPath, dataDir, onLog } = {}) { @@ -49,21 +103,25 @@ async function startSoloCore({ resourcesPath, dataDir, onLog } = {}) { const baseUrl = `http://127.0.0.1:${port}`; const existing = await health(baseUrl); if (existing) return { baseUrl, child: null, reused: true }; - const cmd = findCoreCommand(resourcesPath); + const core = findCoreCommand(resourcesPath); const args = []; if (dataDir) args.push('--data', dataDir); args.push('serve', '--host', '127.0.0.1', '--port', String(port)); - const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } }); + const child = runCore(core, args); + let spawnError = null; + child.on('error', (e) => { spawnError = e; }); child.stdout.on('data', (d) => onLog && onLog(String(d))); child.stderr.on('data', (d) => onLog && onLog(String(d))); const started = Date.now(); - while (Date.now() - started < 60_000) { - if (child.exitCode !== null) throw new Error(`The Metachlorian core exited (${child.exitCode}). Is it installed? Tried: ${cmd}`); + // The first start after installing can be slow (antivirus scans every new file on Windows). + while (Date.now() - started < 180_000) { + if (spawnError) throw new Error(`Could not start the Metachlorian engine (${core.cmd}): ${spawnError.message}`); + if (child.exitCode !== null) throw new Error(`The Metachlorian engine stopped while starting (exit ${child.exitCode}).`); if (await health(baseUrl)) return { baseUrl, child, reused: false }; await new Promise((r) => setTimeout(r, 400)); } child.kill(); - throw new Error('The Metachlorian core did not start within 60 seconds.'); + throw new Error('The Metachlorian engine did not start within 3 minutes.'); } -module.exports = { freePort, health, findCoreCommand, startSoloCore }; +module.exports = { freePort, health, findCoreCommand, ensureModels, startSoloCore }; diff --git a/desktop/src/main.js b/desktop/src/main.js index 5ffa386..324994c 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -5,7 +5,7 @@ const { app, BrowserWindow, ipcMain, shell, dialog, nativeImage } = require('electron'); const path = require('node:path'); const fs = require('node:fs'); -const { startSoloCore, health } = require('./core'); +const { startSoloCore, ensureModels, health } = require('./core'); const settingsFile = () => path.join(app.getPath('userData'), 'settings.json'); function loadSettings() { @@ -27,7 +27,11 @@ async function connect() { if (!h) throw new Error(`Cannot reach the Metachlorian server at ${s.serverUrl}.`); return s.serverUrl; } - core = await startSoloCore({ resourcesPath: process.resourcesPath, dataDir: process.env.METACHLORIAN_DATA, onLog: (l) => process.stdout.write(l) }); + const opts = { resourcesPath: process.resourcesPath, dataDir: process.env.METACHLORIAN_DATA, onLog: (l) => process.stdout.write(l) }; + // First launch downloads the analysis models (~1.9 GB, once); later launches find them and continue at once. + if (!process.env.METACHLORIAN_SKIP_MODEL_SETUP) await ensureModels({ ...opts, onEvent: showSetupProgress }); + setStatus({ title: 'Starting Metachlorian…' }); + core = await startSoloCore(opts); return core.baseUrl; } @@ -44,14 +48,87 @@ function createWindow() { return win; } -function startupPage(message) { +// The window shows this page until the library is ready: starting up, first-run model download, or an error. +function startupPage() { const html = `Metachlorian - -

Metachlorian

${message}

`; + +

Starting Metachlorian…

+ +
+ `; return 'data:text/html;charset=utf-8,' + encodeURIComponent(html); } +let pageReady = null; +function setStatus(state) { + if (!win || win.isDestroyed()) return; + (pageReady || Promise.resolve()).then(() => win.webContents.executeJavaScript(`window.setState && window.setState(${JSON.stringify(state)})`).catch(() => {})); +} +const gb = (bytes) => (bytes / 1e9).toFixed(bytes < 1e9 ? 2 : 1) + ' GB'; +let setup = null; +function showSetupProgress(ev) { + if (ev.event === 'plan') { + setup = { sizes: Object.fromEntries(ev.models.map((m) => [m.name, m.size_mb * 1e6])), done: {}, current: {} }; + setup.total = Object.values(setup.sizes).reduce((a, b) => a + b, 0); + if (!ev.models.length) setup = null; + } + if (!setup) return; + if (ev.event === 'progress') { + if (ev.total) setup.sizes[ev.model] = ev.total; + setup.current[ev.model] = ev.done; + } + if (ev.event === 'done') setup.done[ev.model] = true; + const total = Object.values(setup.sizes).reduce((a, b) => a + b, 0); + const got = Object.keys(setup.sizes).reduce((a, n) => a + (setup.done[n] ? setup.sizes[n] : setup.current[n] || 0), 0); + // Test hook: screenshot the setup screen part-way through (METACHLORIAN_SETUP_SHOT=). + if (process.env.METACHLORIAN_SETUP_SHOT && !setup.shot && total && got / total > 0.2) { + setup.shot = true; + setTimeout(() => win.webContents.capturePage().then((img) => fs.writeFileSync(process.env.METACHLORIAN_SETUP_SHOT, img.toPNG())), 500); + } + setStatus({ + title: 'Setting up Metachlorian', + detail: 'Downloading the models that watch and listen to your footage. This happens once and everything then runs on this computer.', + progress: total ? Math.min(1, got / total) : 0, + note: `${gb(got)} of ${gb(total)}`, + }); +} + +async function start() { + pageReady = win.loadURL(startupPage()).catch(() => {}); + try { + baseUrl = await connect(); + await win.loadURL(baseUrl); + if (process.env.METACHLORIAN_DESKTOP_SMOKE) { + // Headless check: wait for the UI to render, save a screenshot, report the bridge, exit. + await new Promise((r) => setTimeout(r, Number(process.env.METACHLORIAN_SMOKE_WAIT || 6000))); + const img = await win.webContents.capturePage(); + fs.writeFileSync(process.env.METACHLORIAN_DESKTOP_SMOKE, img.toPNG()); + const bridge = await win.webContents.executeJavaScript('typeof window.metachlorian === "object" && window.metachlorian.desktop === true'); + process.stdout.write(JSON.stringify({ smoke: 'ok', baseUrl, bridge }) + '\n'); + app.quit(); + } + } catch (err) { + const team = loadSettings().mode === 'team'; + setStatus({ + title: team ? 'Can\'t reach your team server' : (setup ? 'Setup couldn\'t finish' : 'Metachlorian couldn\'t start'), + detail: team ? 'Check the server is running and this computer is on the same network.' + : setup ? 'Check your internet connection, then try again. Anything already downloaded is kept.' : '', + error: String(err.message || err), + }); + if (process.env.METACHLORIAN_DESKTOP_SMOKE) { process.stdout.write(JSON.stringify({ smoke: 'failed', error: String(err.message || err) }) + '\n'); app.exit(1); } + } +} // ---------------------------------------------------------------- bridge +ipcMain.handle('mc:retry', () => { if (!baseUrl) start(); return true; }); ipcMain.handle('mc:info', () => ({ desktop: true, platform: process.platform, mode: loadSettings().mode, baseUrl, version: app.getVersion() })); ipcMain.handle('mc:setServer', async (_e, { mode, serverUrl }) => { if (mode === 'team') { @@ -92,24 +169,9 @@ ipcMain.handle('mc:openInCutawan', async (_e, packagePath) => { return { ok: false, error: 'Cutawan was not found. Install it from https://github.com/JeremySNR/cutawan/releases' }; }); -app.whenReady().then(async () => { +app.whenReady().then(() => { createWindow(); - win.loadURL(startupPage('Starting the library…')); - try { - baseUrl = await connect(); - await win.loadURL(baseUrl); - if (process.env.METACHLORIAN_DESKTOP_SMOKE) { - // Headless check: wait for the UI to render, save a screenshot, report the bridge, exit. - await new Promise((r) => setTimeout(r, Number(process.env.METACHLORIAN_SMOKE_WAIT || 6000))); - const img = await win.webContents.capturePage(); - fs.writeFileSync(process.env.METACHLORIAN_DESKTOP_SMOKE, img.toPNG()); - const bridge = await win.webContents.executeJavaScript('typeof window.metachlorian === "object" && window.metachlorian.desktop === true'); - process.stdout.write(JSON.stringify({ smoke: 'ok', baseUrl, bridge }) + '\n'); - app.quit(); - } - } catch (err) { - win.loadURL(startupPage(String(err.message || err) + '

Install the core with scripts/install.sh, or switch to a team server in settings.')); - } + start(); }); app.on('window-all-closed', () => app.quit()); diff --git a/desktop/src/preload.js b/desktop/src/preload.js index 84f0c89..703d6e9 100644 --- a/desktop/src/preload.js +++ b/desktop/src/preload.js @@ -5,6 +5,7 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('metachlorian', { desktop: true, info: () => ipcRenderer.invoke('mc:info'), + retry: () => ipcRenderer.invoke('mc:retry'), setServer: (mode, serverUrl) => ipcRenderer.invoke('mc:setServer', { mode, serverUrl }), startDrag: (files, icon) => ipcRenderer.send('mc:startDrag', { files, icon }), reveal: (filePath) => ipcRenderer.invoke('mc:reveal', filePath), diff --git a/docs/licences.md b/docs/licences.md index ed61da1..6fbc5b9 100644 --- a/docs/licences.md +++ b/docs/licences.md @@ -56,7 +56,7 @@ ISC 1, 0BSD 1, Unlicense 1. All OSI-approved or public-domain-equivalent; no cop | Tool | Licence | How we use it | Obligation | |---|---|---|---| -| FFmpeg | LGPL-2.1+, or GPL when built with libx264 (we use libx264 for proxies) | Executed as a separate program; never linked | Server installs use the system FFmpeg. Desktop installers that bundle an FFmpeg binary must ship its licence and offer its source (as Cutawan already does with `ffmpeg-static`). See OPEN_QUESTIONS Q9 | +| FFmpeg | LGPL-2.1+, or GPL when built with libx264 (we use libx264 for proxies) | Executed as a separate program; never linked | Server installs use the system FFmpeg. Desktop installers bundle ffmpeg-static's GPL builds (`desktop/scripts/bundle.mjs`) and ship their `LICENSE` and `README` (which names the build and its source) next to the binaries in `resources/ffmpeg/`, as Cutawan does. See OPEN_QUESTIONS Q9 | | ExifTool (optional) | Artistic/GPL | Executed if present | None for us (not bundled) | | llama.cpp server / Ollama (optional) | MIT | Separate process, OpenAI-compatible API | None | | yt-dlp (imports from web links) | Unlicense (public domain) | The official standalone build is downloaded from GitHub on first use (or the one on PATH is used) and run as a separate program | None. Its cookie-unlock plugin comes from Cutawan (MIT, same author) |