diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..88b6e98
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,80 @@
+name: Release
+
+on:
+ release:
+ types: [published]
+
+permissions:
+ contents: write
+
+jobs:
+ build-and-release:
+ runs-on: macos-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build plugin
+ run: npm run build
+
+ - name: Copy CSInterface placeholder
+ run: |
+ if [ ! -f CSInterface.js ]; then
+ echo "CSInterface.js not found — creating placeholder"
+ echo "// CSInterface.js must be copied from your Adobe After Effects installation."
+ echo "// The installer will find and copy it automatically." > CSInterface.js
+ fi
+
+ - name: Build standalone executables
+ run: bun run build-bin
+
+ - name: Create plugin package
+ run: |
+ mkdir -p package/CSXS package/src package/dist package/assets/icons package/assets/styles
+ cp -r CSXS/* package/CSXS/
+ cp src/index.html package/src/
+ cp dist/index.js package/dist/
+ cp -r assets/icons/* package/assets/icons/
+ cp -r assets/styles/* package/assets/styles/
+ cp manifest.json package/
+ cp CSInterface.js package/
+ cp .debug package/
+ cp install.sh package/
+ cp install.ps1 package/
+ cp uninstall.sh package/
+ cp uninstall.ps1 package/
+ cp README.md package/
+
+ - name: Create archives
+ run: |
+ VERSION="${GITHUB_REF_NAME}"
+ cd package && zip -r "../mstudio-ae-plugin-${VERSION}.zip" . && cd ..
+
+ cd dist-bin
+ tar -czf "../install-darwin-arm64-${VERSION}.tar.gz" install-darwin-arm64
+ tar -czf "../install-darwin-x64-${VERSION}.tar.gz" install-darwin-x64
+ zip "../install-windows-x64-${VERSION}.zip" install-windows-x64.exe
+ tar -czf "../uninstall-darwin-arm64-${VERSION}.tar.gz" uninstall-darwin-arm64
+ tar -czf "../uninstall-darwin-x64-${VERSION}.tar.gz" uninstall-darwin-x64
+ zip "../uninstall-windows-x64-${VERSION}.zip" uninstall-windows-x64.exe
+
+ - name: Upload release assets
+ uses: softprops/action-gh-release@v2
+ with:
+ files: |
+ mstudio-ae-plugin-*.zip
+ install-darwin-arm64-*.tar.gz
+ install-darwin-x64-*.tar.gz
+ install-windows-x64-*.zip
+ uninstall-darwin-arm64-*.tar.gz
+ uninstall-darwin-x64-*.tar.gz
+ uninstall-windows-x64-*.zip
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index de189f0..6177af5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ node_modules/
# Build output
dist/
+dist-bin/
# IDE
.idea/
@@ -25,3 +26,12 @@ npm-debug.log*
# Environment
.env
.env.local
+
+# CEP — Adobe libraries copied from AE installation
+CSInterface.js
+.debug
+
+# Hello plugin build output
+simple-hello-plugin/dist/
+simple-hello-plugin/node_modules/
+.sisyphus/
diff --git a/CSXS/manifest.xml b/CSXS/manifest.xml
new file mode 100644
index 0000000..5c61e32
--- /dev/null
+++ b/CSXS/manifest.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ./index.html
+
+
+ true
+
+
+ Panel
+
+
+
+ 500
+ 320
+
+
+ 400
+ 280
+
+
+ 1200
+ 600
+
+
+
+ ./assets/icons/icon-23.png
+ ./assets/icons/icon-23.png
+ ./assets/icons/icon-23.png
+ ./assets/icons/icon-23.png
+ ./assets/icons/icon-23.png
+
+
+
+
+
+
+ --allow-running-insecure-content
+ --disable-web-security
+
+
diff --git a/README.md b/README.md
index 18ffa15..5b3a601 100644
--- a/README.md
+++ b/README.md
@@ -5,46 +5,100 @@ Bidirectional sync between [MStudio](https://app.mstudio.ai) and Adobe After Eff
## Requirements
- Adobe After Effects 2024 or later (v24.0+)
-- [UXP Developer Tool](https://developer.adobe.com/photoshop/uxp/2022/guides/devtool/installation/) (for development/sideloading)
-- Node.js 18+ (for building)
+- Node.js 18+ (for building from source)
- An MStudio account with an API key that has `sync` scope
## Installation
-### From Built Plugin (Production)
+### Option 1: Standalone Executable (Easiest)
-1. Download the latest release from the releases page
-2. Open After Effects
-3. Go to **Edit > Preferences > Scripting & Expressions**
-4. Enable **Allow Scripts to Write Files and Access Network**
-5. Open the UXP Developer Tool
-6. Click **Add Plugin** and select the `manifest.json` from the extracted release folder
-7. Click **Load** to activate the plugin
-8. The **MStudio Sync** panel will appear under **Window > Extensions**
+Download the installer for your platform from the [latest release](https://github.com/ModelsLab/mstudio-AE-plugin/releases), then:
-### From Source (Development)
+**macOS:**
+```bash
+chmod +x install-darwin-arm64 # or install-darwin-x64 for Intel Macs
+./install-darwin-arm64
+```
+
+**Windows:**
+```
+.\install-windows-x64.exe
+```
+No Node.js, npm, or build tools required. The executable handles everything.
+
+### Option 2: From Source
+
+**macOS / Linux:**
```bash
-# Clone the repository
git clone https://github.com/ModelsLab/mstudio-AE-plugin.git
cd mstudio-AE-plugin
+./install.sh
+```
-# Install dependencies
-npm install
+**Windows (PowerShell as Administrator):**
+```powershell
+git clone https://github.com/ModelsLab/mstudio-AE-plugin.git
+cd mstudio-AE-plugin
+.\install.ps1
+```
-# Build the plugin
-npm run build
+**Or using npm (any platform):**
+```bash
+git clone https://github.com/ModelsLab/mstudio-AE-plugin.git
+cd mstudio-AE-plugin
+npm run install-plugin
+```
-# Or watch for changes during development
-npm run dev
+The install script will:
+
+1. Build the plugin from source
+2. Enable CEP debug mode (allows unsigned extensions)
+3. Copy CSInterface.js from your AE installation
+4. Symlink the plugin into Adobe's CEP extensions directory
+5. Verify the installation
+
+Then restart After Effects and open **Window → Extensions → MStudio Sync**.
+
+### Build Standalone Executables
+
+To compile installers for all platforms (requires [Bun](https://bun.sh)):
+
+```bash
+bun run build-bin
```
-Then sideload via UXP Developer Tool:
+This creates executables in `dist-bin/` for macOS (ARM64, x64) and Windows (x64).
+
+### Uninstall
+
+**Standalone executable:** Run the `uninstall` binary for your platform.
-1. Open the UXP Developer Tool
-2. Click **Add Plugin**
-3. Navigate to this repo's root directory and select `manifest.json`
-4. Click **Load** to activate
+**From source:**
+- macOS/Linux: `./uninstall.sh`
+- Windows: `.\uninstall.ps1`
+- npm: `npm run uninstall-plugin`
+
+### Prerequisites
+
+Before installing, enable script access in After Effects:
+
+1. Open **Edit → Preferences → Scripting & Expressions** (Windows) or **After Effects → Settings → Scripting & Expressions** (macOS)
+2. Enable **Allow Scripts to Write Files and Access Network**
+
+### From Source (Development)
+
+```bash
+npm install
+npm run build
+npm run install-plugin
+```
+
+For live development with auto-rebuild:
+
+```bash
+npm run dev
+```
## Setup
@@ -145,7 +199,7 @@ All under `/api/v1/projects/{uuid}/sync/`:
## Troubleshooting
-**Plugin won't load**: Ensure AE is version 24.0+. Check UXP Developer Tool console for errors.
+**Plugin won't load**: Ensure AE is version 24.0+ and CEP PlayerDebugMode is enabled (the install script does this automatically). Check the AE console for errors (Help → Enable Console).
**Connection failed**: Verify your API key has the `sync` scope. Check that your subscription is active.
@@ -158,12 +212,17 @@ All under `/api/v1/projects/{uuid}/sync/`:
## Development
```bash
-npm run dev # Watch mode — rebuilds on file changes
-npm run build # One-time production build
-npm run clean # Remove dist/ directory
+npm run dev # Watch mode — rebuilds on file changes
+npm run build # One-time production build
+npm run install-plugin # Rebuild + reinstall into CEP extensions
+npm run uninstall-plugin # Remove plugin from CEP extensions
+npm run clean # Remove dist/ directory
+bun run build-bin # Build standalone installers for macOS/Windows
+bun run install-bin # Run installer via Bun (development)
+bun run uninstall-bin # Run uninstaller via Bun (development)
```
-Reload the plugin in UXP Developer Tool after rebuilding to pick up changes.
+After code changes, run `npm run install-plugin` and restart AE to pick up changes.
## License
diff --git a/assets/icons/icon-23.png b/assets/icons/icon-23.png
new file mode 100644
index 0000000..e9ff286
Binary files /dev/null and b/assets/icons/icon-23.png differ
diff --git a/assets/icons/icon-48.png b/assets/icons/icon-48.png
new file mode 100644
index 0000000..246078f
Binary files /dev/null and b/assets/icons/icon-48.png differ
diff --git a/install.ps1 b/install.ps1
new file mode 100644
index 0000000..70bfc2a
--- /dev/null
+++ b/install.ps1
@@ -0,0 +1,135 @@
+#Requires -RunAsAdministrator
+param(
+ [switch]$SkipBuild,
+ [switch]$SkipDebugMode
+)
+
+$BundleId = "com.modelslab.mstudio.ae.sync"
+$ErrorActionPreference = "Stop"
+
+function Write-Info($msg) { Write-Host " [*] $msg" -ForegroundColor Cyan }
+function Write-OK($msg) { Write-Host " [+] $msg" -ForegroundColor Green }
+function Write-Warn($msg) { Write-Host " [!] $msg" -ForegroundColor Yellow }
+function Write-Err($msg) { Write-Host " [-] $msg" -ForegroundColor Red }
+
+Write-Host ""
+Write-Host "============================================================" -ForegroundColor White
+Write-Host " MStudio AE Plugin - Installer (Windows)" -ForegroundColor White
+Write-Host "============================================================" -ForegroundColor White
+Write-Host ""
+
+$PluginDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$CepDir = Join-Path $env:APPDATA "Adobe\CEP\extensions\$BundleId"
+
+# ── 1. Build ──
+if (-not $SkipBuild) {
+ Write-Host "[1/5] Building plugin..." -ForegroundColor White
+ Push-Location $PluginDir
+ if (-not (Test-Path "node_modules")) {
+ Write-Info "Installing dependencies..."
+ npm install --production 2>$null
+ if ($LASTEXITCODE -ne 0) { npm install }
+ }
+ npm run build
+ if ($LASTEXITCODE -ne 0) { Write-Err "Build failed"; exit 1 }
+ Write-OK "Build complete"
+ Pop-Location
+} else {
+ Write-Info "Skipping build (--SkipBuild)"
+}
+
+# ── 2. Enable PlayerDebugMode ──
+if (-not $SkipDebugMode) {
+ Write-Host ""
+ Write-Host "[2/5] Enabling CEP PlayerDebugMode..." -ForegroundColor White
+ foreach ($v in @("11", "12")) {
+ $regPath = "HKCU:\SOFTWARE\Adobe\CSXS.$v"
+ try {
+ if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null }
+ Set-ItemProperty -Path $regPath -Name "PlayerDebugMode" -Value "1" -Type String -Force
+ Write-OK "CSXS.$v PlayerDebugMode enabled"
+ } catch {
+ Write-Info "CSXS.$v not found (skipped)"
+ }
+ }
+ Write-OK "PlayerDebugMode configured"
+} else {
+ Write-Info "Skipping debug mode (--SkipDebugMode)"
+}
+
+# ── 3. Copy CSInterface.js ──
+Write-Host ""
+Write-Host "[3/5] Ensuring CSInterface.js..." -ForegroundColor White
+$csiPath = Join-Path $PluginDir "CSInterface.js"
+if (-not (Test-Path $csiPath)) {
+ $aePaths = @(
+ "${env:ProgramFiles}\Adobe\Adobe After Effects 2026\Support Files\Adobe After Effects 2026.exe",
+ "${env:ProgramFiles}\Adobe\Adobe After Effects 2025\Support Files\Adobe After Effects 2025.exe",
+ "${env:ProgramFiles}\Adobe\Adobe After Effects 2024\Support Files\Adobe After Effects 2024.exe"
+ )
+ $found = $false
+ foreach ($aePath in $aePaths) {
+ $aeDir = Split-Path -Parent $aePath
+ $csiFiles = Get-ChildItem -Path $aeDir -Recurse -Filter "CSInterface.js" -ErrorAction SilentlyContinue
+ if ($csiFiles) {
+ Copy-Item $csiFiles[0].FullName $csiPath
+ Write-OK "CSInterface.js copied from $(Split-Path -Leaf $aePath)"
+ $found = $true
+ break
+ }
+ }
+ if (-not $found) {
+ Write-Warn "CSInterface.js not found in AE. Download from Adobe CEP SDK or copy manually."
+ }
+} else {
+ Write-OK "CSInterface.js already present"
+}
+
+# ── 4. Install to CEP extensions ──
+Write-Host ""
+Write-Host "[4/5] Installing plugin to CEP extensions..." -ForegroundColor White
+if (-not (Test-Path $CepDir)) { New-Item -ItemType Directory -Path $CepDir -Force | Out-Null }
+
+@("CSXS", "dist", "assets") | ForEach-Object {
+ $target = Join-Path $CepDir $_
+ if (Test-Path $target) { Remove-Item $target -Recurse -Force }
+ New-Item -ItemType SymbolicLink -Path $target -Target (Join-Path $PluginDir $_) -Force | Out-Null
+}
+
+Copy-Item (Join-Path $PluginDir "src\index.html") (Join-Path $CepDir "index.html") -Force
+Copy-Item (Join-Path $PluginDir "CSInterface.js") (Join-Path $CepDir "CSInterface.js") -Force
+Copy-Item (Join-Path $PluginDir ".debug") (Join-Path $CepDir ".debug") -Force
+Copy-Item (Join-Path $PluginDir "manifest.json") (Join-Path $CepDir "manifest.json") -Force
+
+Write-OK "Installed to $CepDir"
+
+# ── 5. Verify ──
+Write-Host ""
+Write-Host "[5/5] Verifying installation..." -ForegroundColor White
+$files = @("CSXS", "index.html", "CSInterface.js", "dist\index.js", ".debug", "manifest.json")
+$errors = 0
+foreach ($f in $files) {
+ if (Test-Path (Join-Path $CepDir $f)) {
+ Write-OK $f
+ } else {
+ Write-Err "$f is missing!"
+ $errors++
+ }
+}
+
+Write-Host ""
+Write-Host "============================================================" -ForegroundColor White
+if ($errors -eq 0) {
+ Write-Host " [+] Setup complete!" -ForegroundColor Green
+} else {
+ Write-Host " [!] Setup completed with $errors error(s)" -ForegroundColor Yellow
+}
+Write-Host ""
+Write-Host " Location: $CepDir"
+Write-Host " Panel: Window > Extensions > MStudio Sync"
+Write-Host ""
+Write-Host " Restart After Effects to load the plugin."
+Write-Host ""
+Write-Host " To uninstall: .\uninstall.ps1" -ForegroundColor Cyan
+Write-Host "============================================================" -ForegroundColor White
+Write-Host ""
\ No newline at end of file
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..4a24bf8
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,133 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+BUNDLE_ID="com.modelslab.mstudio.ae.sync"
+PLUGIN_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+BOLD='\033[1m'
+RESET='\033[0m'
+
+info() { echo -e "${CYAN} ℹ${RESET} $1"; }
+ok() { echo -e "${GREEN} ✓${RESET} $1"; }
+warn() { echo -e "${YELLOW} ⚠${RESET} $1"; }
+fail() { echo -e "${RED} ✗${RESET} $1"; exit 1; }
+
+echo ""
+echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
+echo -e "${BOLD} MStudio AE Plugin — Installer (macOS / Linux)${RESET}"
+echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
+echo ""
+
+# ── Detect platform ──
+OS="$(uname -s)"
+case "$OS" in
+ Darwin) CEP_DIR="$HOME/Library/Application Support/Adobe/CEP/extensions/$BUNDLE_ID" ;;
+ *) fail "Unsupported OS: $OS. CEP panels require macOS or Windows. Use install.ps1 on Windows." ;;
+esac
+
+# ── 1. Build ──
+echo -e "${BOLD}[1/5] Building plugin...${RESET}"
+cd "$PLUGIN_DIR"
+if [ ! -d "node_modules" ]; then
+ info "Installing dependencies..."
+ npm install --production 2>/dev/null || npm install
+fi
+npm run build
+ok "Build complete"
+
+# ── 2. Enable PlayerDebugMode (unsigned extensions) ──
+echo ""
+echo -e "${BOLD}[2/5] Enabling CEP PlayerDebugMode...${RESET}"
+ case "$OS" in
+ Darwin)
+ for v in 11 12; do
+ defaults write "com.adobe.CSXS.$v" PlayerDebugMode 1 2>/dev/null && \
+ info "CSXS.$v PlayerDebugMode enabled" || \
+ info "CSXS.$v not found (skipped)"
+ done
+ killall cfprefsd 2>/dev/null
+ ;;
+esac
+ok "PlayerDebugMode configured"
+
+# ── 3. Copy CSInterface.js ──
+echo ""
+echo -e "${BOLD}[3/5] Ensuring CSInterface.js...${RESET}"
+if [ ! -f "$PLUGIN_DIR/CSInterface.js" ]; then
+ if [ "$OS" = "Darwin" ]; then
+ AE_PATHS=(
+ "/Applications/Adobe After Effects 2026/Adobe After Effects 2026.app"
+ "/Applications/Adobe After Effects 2025/Adobe After Effects 2025.app"
+ "/Applications/Adobe After Effects 2024/Adobe After Effects 2024.app"
+ )
+ FOUND=0
+ for ae in "${AE_PATHS[@]}"; do
+ CSI="$(find "$ae" -name "CSInterface.js" -type f 2>/dev/null | head -1)"
+ if [ -n "$CSI" ]; then
+ cp "$CSI" "$PLUGIN_DIR/CSInterface.js"
+ ok "CSInterface.js copied from $(basename "$ae")"
+ FOUND=1
+ break
+ fi
+ done
+ if [ "$FOUND" -eq 0 ]; then
+ warn "CSInterface.js not found in AE. Download from Adobe CEP SDK or copy manually."
+ fi
+ else
+ warn "CSInterface.js not found. Copy from Adobe CEP SDK."
+ fi
+else
+ ok "CSInterface.js already present"
+fi
+
+# ── 4. Install to CEP extensions directory ──
+echo ""
+echo -e "${BOLD}[4/5] Installing plugin to CEP extensions...${RESET}"
+mkdir -p "$CEP_DIR"
+
+rm -f "$CEP_DIR/CSXS" "$CEP_DIR/dist" "$CEP_DIR/assets"
+rm -f "$CEP_DIR/index.html" "$CEP_DIR/CSInterface.js" "$CEP_DIR/.debug" "$CEP_DIR/manifest.json"
+
+ln -sf "$PLUGIN_DIR/CSXS" "$CEP_DIR/CSXS"
+ln -sf "$PLUGIN_DIR/src/index.html" "$CEP_DIR/index.html"
+ln -sf "$PLUGIN_DIR/dist" "$CEP_DIR/dist"
+ln -sf "$PLUGIN_DIR/assets" "$CEP_DIR/assets"
+cp -f "$PLUGIN_DIR/CSInterface.js" "$CEP_DIR/CSInterface.js"
+cp -f "$PLUGIN_DIR/.debug" "$CEP_DIR/.debug"
+cp -f "$PLUGIN_DIR/manifest.json" "$CEP_DIR/manifest.json"
+
+ok "Installed to $CEP_DIR"
+
+# ── 5. Verify ──
+echo ""
+echo -e "${BOLD}[5/5] Verifying installation...${RESET}"
+ERRORS=0
+for f in CSXS index.html CSInterface.js dist/index.js .debug manifest.json; do
+ if [ -e "$CEP_DIR/$f" ]; then
+ ok "$f"
+ else
+ fail "$f is missing!"
+ ERRORS=$((ERRORS + 1))
+ fi
+done
+
+echo ""
+echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
+if [ "$ERRORS" -eq 0 ]; then
+ echo -e "${GREEN}${BOLD} ✓ Setup complete!${RESET}"
+else
+ echo -e "${YELLOW}${BOLD} ⚠ Setup completed with $ERRORS error(s)${RESET}"
+fi
+echo ""
+echo " Location: $CEP_DIR"
+echo " Panel: Window → Extensions → MStudio Sync"
+echo ""
+echo " Restart After Effects to load the plugin."
+echo ""
+echo -e " To uninstall: ${CYAN}./uninstall.sh${RESET}"
+echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
+echo ""
\ No newline at end of file
diff --git a/manifest.json b/manifest.json
index 6144d6f..c5370ed 100644
--- a/manifest.json
+++ b/manifest.json
@@ -2,17 +2,18 @@
"id": "com.modelslab.mstudio.ae.sync",
"name": "MStudio Sync",
"version": "1.0.0",
- "manifestVersion": 6,
+ "manifestVersion": 5,
"host": [
{
- "app": "AE",
+ "app": "aftereffects",
"minVersion": "24.0"
}
],
- "entrypoints": [
+ "entryPoints": [
{
"type": "panel",
"id": "mstudio.sync.panel",
+ "main": "src/index.html",
"label": {
"default": "MStudio Sync"
},
@@ -28,7 +29,7 @@
],
"requiredPermissions": {
"network": {
- "domains": ["https://*.modelslab.com", "https://*.mstudio.ai"]
+ "domains": ["https://*.modelslab.com", "https://*.modelslab.ai", "https://*.mstudio.ai", "https://*.cloudflarestorage.com", "https://modelslab-studio.test", "http://modelslab-studio.test"]
},
"localFileSystem": "fullAccess",
"clipboard": "readAndWrite"
diff --git a/package-lock.json b/package-lock.json
index 95bf6ce..455193b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,9 +9,525 @@
"version": "1.0.0",
"license": "UNLICENSED",
"devDependencies": {
+ "@types/bun": "^1.3.14",
+ "esbuild": "^0.28.0",
"typescript": "^5.4.0"
}
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
+ "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
+ "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
+ "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
+ "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
+ "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
+ "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
+ "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
+ "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
+ "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
+ "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
+ "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
+ "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
+ "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
+ "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
+ "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
+ "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
+ "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
+ "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
+ "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
+ "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
+ "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/bun": {
+ "version": "1.3.14",
+ "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz",
+ "integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bun-types": "1.3.14"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "25.9.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz",
+ "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/bun-types": {
+ "version": "1.3.14",
+ "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz",
+ "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
+ "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.0",
+ "@esbuild/android-arm": "0.28.0",
+ "@esbuild/android-arm64": "0.28.0",
+ "@esbuild/android-x64": "0.28.0",
+ "@esbuild/darwin-arm64": "0.28.0",
+ "@esbuild/darwin-x64": "0.28.0",
+ "@esbuild/freebsd-arm64": "0.28.0",
+ "@esbuild/freebsd-x64": "0.28.0",
+ "@esbuild/linux-arm": "0.28.0",
+ "@esbuild/linux-arm64": "0.28.0",
+ "@esbuild/linux-ia32": "0.28.0",
+ "@esbuild/linux-loong64": "0.28.0",
+ "@esbuild/linux-mips64el": "0.28.0",
+ "@esbuild/linux-ppc64": "0.28.0",
+ "@esbuild/linux-riscv64": "0.28.0",
+ "@esbuild/linux-s390x": "0.28.0",
+ "@esbuild/linux-x64": "0.28.0",
+ "@esbuild/netbsd-arm64": "0.28.0",
+ "@esbuild/netbsd-x64": "0.28.0",
+ "@esbuild/openbsd-arm64": "0.28.0",
+ "@esbuild/openbsd-x64": "0.28.0",
+ "@esbuild/openharmony-arm64": "0.28.0",
+ "@esbuild/sunos-x64": "0.28.0",
+ "@esbuild/win32-arm64": "0.28.0",
+ "@esbuild/win32-ia32": "0.28.0",
+ "@esbuild/win32-x64": "0.28.0"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -25,6 +541,13 @@
"engines": {
"node": ">=14.17"
}
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
}
}
}
diff --git a/package.json b/package.json
index c30c69b..f77b4d5 100644
--- a/package.json
+++ b/package.json
@@ -4,13 +4,21 @@
"description": "MStudio Sync Plugin for Adobe After Effects - bidirectional project sync",
"main": "dist/index.js",
"scripts": {
- "build": "npm run clean && tsc",
- "watch": "tsc --watch",
+ "build": "npm run clean && npm run bundle",
+ "bundle": "esbuild src/index.ts --bundle --format=iife --outfile=dist/index.js --target=es2020",
+ "watch": "esbuild src/index.ts --bundle --format=iife --outfile=dist/index.js --target=es2020 --watch",
"clean": "rm -rf dist",
- "dev": "tsc --watch",
- "typecheck": "tsc --noEmit"
+ "dev": "npm run watch",
+ "typecheck": "tsc --noEmit",
+ "install-plugin": "node -e \"const{platform}=require('os');const{execSync}=require('child_process');if(platform()==='win32'){execSync('powershell -ExecutionPolicy Bypass -File install.ps1',{stdio:'inherit'})}else{execSync('bash install.sh',{stdio:'inherit'})}\"",
+ "uninstall-plugin": "node -e \"const{platform}=require('os');const{execSync}=require('child_process');if(platform()==='win32'){execSync('powershell -ExecutionPolicy Bypass -File uninstall.ps1',{stdio:'inherit'})}else{execSync('bash uninstall.sh',{stdio:'inherit'})}\"",
+ "build-bin": "bun run scripts/build-bin.ts",
+ "install-bin": "bun run scripts/install.ts",
+ "uninstall-bin": "bun run scripts/uninstall.ts"
},
"devDependencies": {
+ "@types/bun": "^1.3.14",
+ "esbuild": "^0.28.0",
"typescript": "^5.4.0"
},
"author": "ModelsLab",
diff --git a/scripts/build-bin.ts b/scripts/build-bin.ts
new file mode 100644
index 0000000..f2e1324
--- /dev/null
+++ b/scripts/build-bin.ts
@@ -0,0 +1,57 @@
+import { existsSync, mkdirSync } from "node:fs";
+import { join } from "node:path";
+
+const OUT_DIR = join(import.meta.dir, "..", "dist-bin");
+
+const TARGETS = [
+ { target: "bun-darwin-arm64", suffix: "" },
+ { target: "bun-darwin-x64", suffix: "" },
+ { target: "bun-windows-x64", suffix: ".exe" },
+] as const;
+
+const SCRIPTS = [
+ { name: "install", entry: join(import.meta.dir, "install.ts") },
+ { name: "uninstall", entry: join(import.meta.dir, "uninstall.ts") },
+] as const;
+
+async function build() {
+ if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
+
+ for (const script of SCRIPTS) {
+ for (const { target, suffix } of TARGETS) {
+ const platformTag = target.replace("bun-", "");
+ const outfile = join(OUT_DIR, `${script.name}-${platformTag}${suffix}`);
+
+ console.log(`Building ${script.name} for ${target}...`);
+
+ const result = await Bun.build({
+ entrypoints: [script.entry],
+ compile: {
+ target,
+ outfile,
+ },
+ minify: true,
+ define: {
+ "process.env.NODE_ENV": JSON.stringify("production"),
+ },
+ });
+
+ if (result.success) {
+ console.log(` ✓ ${outfile}`);
+ } else {
+ console.error(` ✗ Build failed:`);
+ for (const log of result.logs) {
+ console.error(` ${log}`);
+ }
+ process.exit(1);
+ }
+ }
+ }
+
+ console.log(`\nAll executables written to ${OUT_DIR}/`);
+}
+
+build().catch((err) => {
+ console.error("Build failed:", err);
+ process.exit(1);
+});
\ No newline at end of file
diff --git a/scripts/install.ts b/scripts/install.ts
new file mode 100644
index 0000000..4564d93
--- /dev/null
+++ b/scripts/install.ts
@@ -0,0 +1,237 @@
+import { execSync } from "node:child_process";
+import { existsSync, mkdirSync, symlinkSync, copyFileSync, rmSync, readdirSync } from "node:fs";
+import { join, dirname, basename } from "node:path";
+import { homedir, platform } from "node:os";
+
+const BUNDLE_ID = "com.modelslab.mstudio.ae.sync";
+const VERSION = "1.0.0";
+
+const RED = "\x1b[0;31m";
+const GREEN = "\x1b[0;32m";
+const YELLOW = "\x1b[1;33m";
+const CYAN = "\x1b[0;36m";
+const BOLD = "\x1b[1m";
+const RESET = "\x1b[0m";
+
+function info(msg: string) { console.log(`${CYAN} [*]${RESET} ${msg}`); }
+function ok(msg: string) { console.log(`${GREEN} [+]${RESET} ${msg}`); }
+function warn(msg: string) { console.log(`${YELLOW} [!]${RESET} ${msg}`); }
+function fail(msg: string): never { console.log(`${RED} [-]${RESET} ${msg}`); process.exit(1); }
+
+function getCepDir(): string {
+ const os = platform();
+ if (os === "darwin") {
+ return join(homedir(), "Library", "Application Support", "Adobe", "CEP", "extensions", BUNDLE_ID);
+ } else if (os === "win32") {
+ return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "Adobe", "CEP", "extensions", BUNDLE_ID);
+ }
+ fail(`Unsupported OS: ${os}. CEP panels require macOS or Windows.`);
+}
+
+function enableDebugMode(): void {
+ const os = platform();
+ if (os === "darwin") {
+ for (const v of ["11", "12"]) {
+ try {
+ execSync(`defaults write com.adobe.CSXS.${v} PlayerDebugMode 1`, { stdio: "pipe" });
+ ok(`CSXS.${v} PlayerDebugMode enabled`);
+ } catch {
+ info(`CSXS.${v} not found (skipped)`);
+ }
+ }
+ try { execSync("killall cfprefsd 2>/dev/null", { stdio: "pipe" }); } catch {}
+ } else if (os === "win32") {
+ for (const v of ["11", "12"]) {
+ try {
+ const regPath = `HKCU\\SOFTWARE\\Adobe\\CSXS.${v}`;
+ execSync(`reg add "${regPath}" /v PlayerDebugMode /t REG_SZ /d 1 /f`, { stdio: "pipe" });
+ ok(`CSXS.${v} PlayerDebugMode enabled`);
+ } catch {
+ info(`CSXS.${v} not found (skipped)`);
+ }
+ }
+ }
+}
+
+function findCSInterface(): string | null {
+ const os = platform();
+ if (os === "darwin") {
+ const aePaths = [
+ "/Applications/Adobe After Effects 2026/Adobe After Effects 2026.app",
+ "/Applications/Adobe After Effects 2025/Adobe After Effects 2025.app",
+ "/Applications/Adobe After Effects 2024/Adobe After Effects 2024.app",
+ ];
+ for (const ae of aePaths) {
+ try {
+ const result = execSync(`find "${ae}" -name "CSInterface.js" -type f 2>/dev/null | head -1`, { encoding: "utf-8" }).trim();
+ if (result) return result;
+ } catch {}
+ }
+ } else if (os === "win32") {
+ const aePaths = [
+ join(process.env["ProgramFiles"] || "C:\\Program Files", "Adobe", "Adobe After Effects 2026", "Support Files"),
+ join(process.env["ProgramFiles"] || "C:\\Program Files", "Adobe", "Adobe After Effects 2025", "Support Files"),
+ join(process.env["ProgramFiles"] || "C:\\Program Files", "Adobe", "Adobe After Effects 2024", "Support Files"),
+ ];
+ for (const aeDir of aePaths) {
+ try {
+ const result = execSync(`dir /s /b "${aeDir}\\CSInterface.js"`, { encoding: "utf-8" }).trim().split("\n")[0];
+ if (result) return result;
+ } catch {}
+ }
+ }
+ return null;
+}
+
+function installFiles(cepDir: string, pluginDir: string): void {
+ mkdirSync(cepDir, { recursive: true });
+
+ const pairs: Array<[string, string]> = [
+ [join(pluginDir, "src", "index.html"), join(cepDir, "index.html")],
+ [join(pluginDir, "CSInterface.js"), join(cepDir, "CSInterface.js")],
+ [join(pluginDir, ".debug"), join(cepDir, ".debug")],
+ [join(pluginDir, "manifest.json"), join(cepDir, "manifest.json")],
+ ];
+
+ for (const [src, dest] of pairs) {
+ rmSync(dest, { force: true });
+ if (!existsSync(src)) {
+ warn(`${basename(src)} not found at ${src} — skipping`);
+ continue;
+ }
+ copyFileSync(src, dest);
+ ok(`${basename(dest)} copied`);
+ }
+
+ const symlinkPairs: Array<[string, string]> = [
+ [join(pluginDir, "CSXS"), join(cepDir, "CSXS")],
+ [join(pluginDir, "dist"), join(cepDir, "dist")],
+ [join(pluginDir, "assets"), join(cepDir, "assets")],
+ ];
+
+ for (const [src, dest] of symlinkPairs) {
+ rmSync(dest, { recursive: true, force: true });
+ if (!existsSync(src)) {
+ warn(`${basename(src)} not found — skipping`);
+ continue;
+ }
+
+ if (platform() === "win32") {
+ try {
+ symlinkSync(src, dest, "junction");
+ ok(`${basename(dest)} linked (junction)`);
+ } catch {
+ copyDirRecursive(src, dest);
+ ok(`${basename(dest)} copied (directory)`);
+ }
+ } else {
+ symlinkSync(src, dest);
+ ok(`${basename(dest)} linked`);
+ }
+ }
+}
+
+function copyDirRecursive(src: string, dest: string): void {
+ mkdirSync(dest, { recursive: true });
+ const entries = readdirSync(src, { withFileTypes: true });
+ for (const entry of entries) {
+ const srcPath = join(src, entry.name);
+ const destPath = join(dest, entry.name);
+ if (entry.isDirectory()) {
+ copyDirRecursive(srcPath, destPath);
+ } else {
+ copyFileSync(srcPath, destPath);
+ }
+ }
+}
+
+function verifyInstall(cepDir: string): number {
+ const required = ["CSXS", "index.html", "CSInterface.js", "dist/index.js", ".debug", "manifest.json"];
+ let errors = 0;
+ for (const f of required) {
+ if (existsSync(join(cepDir, f))) {
+ ok(f);
+ } else {
+ warn(`${f} is missing!`);
+ errors++;
+ }
+ }
+ return errors;
+}
+
+async function main() {
+ console.log("");
+ console.log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
+ console.log(`${BOLD} MStudio AE Plugin — Installer v${VERSION}${RESET}`);
+ console.log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
+ console.log("");
+
+ const isCompiled = !existsSync(join(dirname(import.meta.dir ?? process.cwd()), "package.json"));
+ const pluginDir = isCompiled ? process.cwd() : (dirname(import.meta.dir ?? process.cwd()) || process.cwd());
+
+ console.log(`${BOLD}[1/5] Building plugin...${RESET}`);
+ if (!isCompiled) {
+ try {
+ execSync("npm run build", { cwd: pluginDir, stdio: "inherit" });
+ ok("Build complete");
+ } catch {
+ fail("Build failed. Run 'npm run build' manually and try again.");
+ }
+ } else {
+ info("Running as standalone executable — skipping build");
+ info("Ensure dist/index.js exists in the plugin directory");
+ }
+
+ console.log("");
+ console.log(`${BOLD}[2/5] Enabling CEP PlayerDebugMode...${RESET}`);
+ enableDebugMode();
+ ok("PlayerDebugMode configured");
+
+ console.log("");
+ console.log(`${BOLD}[3/5] Ensuring CSInterface.js...${RESET}`);
+ const csiPath = join(pluginDir, "CSInterface.js");
+ if (!existsSync(csiPath)) {
+ info("CSInterface.js not found locally — searching AE installation...");
+ const found = findCSInterface();
+ if (found) {
+ copyFileSync(found, csiPath);
+ ok(`CSInterface.js copied from AE`);
+ } else {
+ warn("CSInterface.js not found. Download from Adobe CEP SDK or copy manually.");
+ }
+ } else {
+ ok("CSInterface.js already present");
+ }
+
+ console.log("");
+ console.log(`${BOLD}[4/5] Installing plugin to CEP extensions...${RESET}`);
+ const cepDir = getCepDir();
+ installFiles(cepDir, pluginDir);
+ ok(`Installed to ${cepDir}`);
+
+ console.log("");
+ console.log(`${BOLD}[5/5] Verifying installation...${RESET}`);
+ const errors = verifyInstall(cepDir);
+
+ console.log("");
+ console.log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
+ if (errors === 0) {
+ console.log(`${GREEN}${BOLD} ✓ Setup complete!${RESET}`);
+ } else {
+ console.log(`${YELLOW}${BOLD} ⚠ Setup completed with ${errors} error(s)${RESET}`);
+ }
+ console.log("");
+ console.log(` Location: ${cepDir}`);
+ console.log(` Panel: Window → Extensions → MStudio Sync`);
+ console.log("");
+ console.log(` Restart After Effects to load the plugin.`);
+ console.log("");
+ console.log(` To uninstall: run uninstall${platform() === "win32" ? ".exe" : ""}`);
+ console.log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
+ console.log("");
+}
+
+main().catch((err) => {
+ console.error(`${RED}Fatal error:${RESET}`, err);
+ process.exit(1);
+});
\ No newline at end of file
diff --git a/scripts/uninstall.ts b/scripts/uninstall.ts
new file mode 100644
index 0000000..026c4b6
--- /dev/null
+++ b/scripts/uninstall.ts
@@ -0,0 +1,50 @@
+import { existsSync, rmSync } from "node:fs";
+import { join } from "node:path";
+import { homedir, platform } from "node:os";
+
+const BUNDLE_ID = "com.modelslab.mstudio.ae.sync";
+
+const RED = "\x1b[0;31m";
+const GREEN = "\x1b[0;32m";
+const CYAN = "\x1b[0;36m";
+const BOLD = "\x1b[1m";
+const RESET = "\x1b[0m";
+
+function info(msg: string) { console.log(`${CYAN} [*]${RESET} ${msg}`); }
+function ok(msg: string) { console.log(`${GREEN} [+]${RESET} ${msg}`); }
+
+function getCepDir(): string {
+ const os = platform();
+ if (os === "darwin") {
+ return join(homedir(), "Library", "Application Support", "Adobe", "CEP", "extensions", BUNDLE_ID);
+ } else if (os === "win32") {
+ return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "Adobe", "CEP", "extensions", BUNDLE_ID);
+ }
+ console.log(`${RED}Unsupported OS: ${os}${RESET}`);
+ process.exit(1);
+}
+
+function main() {
+ console.log("");
+ console.log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
+ console.log(`${BOLD} MStudio AE Plugin — Uninstaller${RESET}`);
+ console.log(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
+ console.log("");
+
+ const cepDir = getCepDir();
+
+ if (!existsSync(cepDir)) {
+ info(`Plugin not found at ${cepDir}`);
+ info("Already uninstalled or never installed.");
+ console.log("");
+ process.exit(0);
+ }
+
+ rmSync(cepDir, { recursive: true, force: true });
+ ok(`Removed ${cepDir}`);
+ console.log("");
+ console.log(`${BOLD} Plugin uninstalled. Restart After Effects to finalize.${RESET}`);
+ console.log("");
+}
+
+main();
\ No newline at end of file
diff --git a/simple-hello-plugin/CSXS/manifest.xml b/simple-hello-plugin/CSXS/manifest.xml
new file mode 100644
index 0000000..5507bdd
--- /dev/null
+++ b/simple-hello-plugin/CSXS/manifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ./index.html
+
+
+ true
+
+
+ Panel
+
+
+
+ 400
+ 320
+
+
+
+
+
+
+
diff --git a/simple-hello-plugin/index.html b/simple-hello-plugin/index.html
new file mode 100644
index 0000000..d06537c
--- /dev/null
+++ b/simple-hello-plugin/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+ Hello AE Panel
+ A simple CEP plugin for After Effects
+
+
+
+
+
+
+
diff --git a/simple-hello-plugin/index.js b/simple-hello-plugin/index.js
new file mode 100644
index 0000000..9c77f16
--- /dev/null
+++ b/simple-hello-plugin/index.js
@@ -0,0 +1,26 @@
+document.addEventListener('DOMContentLoaded', function () {
+ 'use strict';
+
+ var csInterface = new CSInterface();
+
+ var btn = document.getElementById('helloBtn');
+ var result = document.getElementById('result');
+
+ if (!btn || !result) {
+ console.error('Elements not found:', { btn: !!btn, result: !!result });
+ return;
+ }
+
+ btn.addEventListener('click', function () {
+ result.textContent = 'Hello from After Effects!';
+ result.style.color = '#7cff7c';
+
+ try {
+ csInterface.evalScript('alert("Hello from After Effects!")');
+ } catch (e) {
+ console.error('evalScript failed:', e);
+ }
+ });
+
+ console.log('Hello AE plugin initialized');
+});
diff --git a/simple-hello-plugin/install.sh b/simple-hello-plugin/install.sh
new file mode 100755
index 0000000..4c8c406
--- /dev/null
+++ b/simple-hello-plugin/install.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+# Installs the plugin to the CEP extensions folder for development
+EXT_DIR="$HOME/Library/Application Support/Adobe/CEP/extensions/com.example.hello.ae"
+PLUGIN_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+mkdir -p "$EXT_DIR"
+
+# Symlink plugin files to extensions folder
+ln -sf "$PLUGIN_DIR/CSXS" "$EXT_DIR/CSXS"
+ln -sf "$PLUGIN_DIR/index.html" "$EXT_DIR/index.html"
+ln -sf "$PLUGIN_DIR/index.js" "$EXT_DIR/index.js"
+ln -sf "$PLUGIN_DIR/style.css" "$EXT_DIR/style.css"
+ln -sf "$PLUGIN_DIR/CSInterface.js" "$EXT_DIR/CSInterface.js"
+
+# Copy .debug file to enable dev mode
+cp "$PLUGIN_DIR/.debug" "$EXT_DIR/.debug"
+
+echo "Plugin installed to: $EXT_DIR"
+echo "Restart After Effects, then go to Window > Extensions > Hello AE"
diff --git a/simple-hello-plugin/style.css b/simple-hello-plugin/style.css
new file mode 100644
index 0000000..81622a0
--- /dev/null
+++ b/simple-hello-plugin/style.css
@@ -0,0 +1,26 @@
+* { margin: 0; padding: 0; box-sizing: border-box; }
+body {
+ font-family: system-ui, -apple-system, sans-serif;
+ background: #2d2d2d;
+ color: #fff;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100vh;
+ padding: 20px;
+}
+h1 { font-size: 18px; margin-bottom: 16px; }
+p { font-size: 13px; color: #aaa; margin-bottom: 20px; text-align: center; }
+button {
+ background: #2e6edf;
+ color: #fff;
+ border: none;
+ padding: 10px 24px;
+ border-radius: 6px;
+ font-size: 14px;
+ cursor: pointer;
+}
+button:hover { background: #1d5dc7; }
+button:active { background: #1449a3; }
+#result { margin-top: 16px; font-size: 13px; color: #7cff7c; }
diff --git a/src/ae/audio-layer-mapper.ts b/src/ae/audio-layer-mapper.ts
index e8fb379..1e2431e 100644
--- a/src/ae/audio-layer-mapper.ts
+++ b/src/ae/audio-layer-mapper.ts
@@ -1,9 +1,11 @@
/**
* Maps AE audio layers to Studio audio tracks.
+ * CEP implementation — all AE interaction goes through aeBridge.
*/
import { Logger } from '../core/logger';
import { TimelineConverter } from './timeline-converter';
+import { aeBridge } from '../cep/ae-bridge';
const logger = new Logger('AudioLayerMapper');
@@ -21,28 +23,28 @@ export class AudioLayerMapper {
constructor(private timelineConverter: TimelineConverter) {}
/**
- * Find all audio layers in the composition.
+ * Find all audio layers in the composition by querying active comp layers.
*/
- readAudioLayers(comp: any): AudioLayerMapping[] {
+ async readAudioLayers(compDuration: number): Promise {
+ const layers = await aeBridge.getLayersForRange(0, compDuration);
const mappings: AudioLayerMapping[] = [];
- for (let i = 1; i <= comp.numLayers; i++) {
- const layer = comp.layer(i);
+ for (const layer of layers) {
if (!layer.hasAudio) continue;
- // Skip video layers that also have audio
- if (layer.hasVideo) continue;
+ if (layer.hasVideo) continue; // Skip video layers with audio
const studioId = this.extractStudioId(layer);
- const volume = this.readLayerVolume(layer);
+ const audioProps = await aeBridge.getAudioLayerProps(layer.index);
+ const volume = this.dbToLinear(audioProps.audioLevels[0]);
mappings.push({
studioTrackId: studioId ?? 0,
- aeLayerIndex: i,
+ aeLayerIndex: layer.index,
layerName: layer.name,
startTimeMs: this.timelineConverter.secondsToMs(layer.inPoint),
durationMs: this.timelineConverter.secondsToMs(layer.outPoint - layer.inPoint),
- volume: volume,
- is_muted: !layer.audioEnabled,
+ volume,
+ is_muted: !audioProps.audioEnabled,
});
}
@@ -52,8 +54,7 @@ export class AudioLayerMapper {
/**
* Place an audio file as a layer in the comp.
*/
- placeAudioLayer(
- comp: any,
+ async placeAudioLayer(
audioFootage: any,
startTimeMs: number,
durationMs: number,
@@ -61,97 +62,104 @@ export class AudioLayerMapper {
studioTrackId: number,
name?: string,
options?: { trim_start_ms?: number; is_muted?: boolean }
- ): any {
- const layer = comp.layers.add(audioFootage);
- layer.inPoint = this.timelineConverter.msToSeconds(startTimeMs);
- layer.outPoint = this.timelineConverter.msToSeconds(startTimeMs + durationMs);
- layer.name = name || `Audio Track ${studioTrackId}`;
-
- // Apply trim offset: shift the layer's startTime so playback begins at the trim point
+ ): Promise {
+ const inPoint = this.timelineConverter.msToSeconds(startTimeMs);
+ const outPoint = this.timelineConverter.msToSeconds(startTimeMs + durationMs);
+
+ const layerIndex = await aeBridge.addLayer(
+ audioFootage.id,
+ inPoint,
+ outPoint,
+ name || `Audio Track ${studioTrackId}`
+ );
+
+ // Apply trim offset
if (options?.trim_start_ms && options.trim_start_ms > 0) {
- layer.startTime = layer.startTime - this.timelineConverter.msToSeconds(options.trim_start_ms);
+ const trimOffset = this.timelineConverter.msToSeconds(options.trim_start_ms);
+ await aeBridge.setLayerTiming(layerIndex, inPoint, outPoint, -trimOffset);
}
// Apply mute state
if (options?.is_muted) {
- layer.audioEnabled = false;
+ await aeBridge.setAudioEnabled(layerIndex, false);
}
// Store studio ID in layer comment
- layer.comment = JSON.stringify({ studio_audio_track_id: studioTrackId });
+ await aeBridge.setLayerComment(
+ layerIndex,
+ JSON.stringify({ studio_audio_track_id: studioTrackId })
+ );
// Set volume
- this.setLayerVolume(layer, volume);
+ await this.setLayerVolume(layerIndex, volume);
logger.info(`Placed audio for track ${studioTrackId} at ${startTimeMs}ms`);
- return layer;
+ return { index: layerIndex };
}
/**
* Update an existing audio layer's properties.
*/
- updateAudioLayer(
- comp: any,
+ async updateAudioLayer(
aeLayerIndex: number,
startTimeMs: number,
durationMs: number,
volume: number
- ): void {
- const layer = comp.layer(aeLayerIndex);
- if (!layer) return;
+ ): Promise {
+ const inPoint = this.timelineConverter.msToSeconds(startTimeMs);
+ const outPoint = this.timelineConverter.msToSeconds(startTimeMs + durationMs);
- layer.inPoint = this.timelineConverter.msToSeconds(startTimeMs);
- layer.outPoint = this.timelineConverter.msToSeconds(startTimeMs + durationMs);
- this.setLayerVolume(layer, volume);
+ await aeBridge.setLayerTiming(aeLayerIndex, inPoint, outPoint);
+ await this.setLayerVolume(aeLayerIndex, volume);
}
/**
* Remove an audio layer by its Studio track ID.
*/
- removeAudioLayer(comp: any, studioTrackId: number): boolean {
- for (let i = comp.numLayers; i >= 1; i--) {
- const layer = comp.layer(i);
- const id = this.extractStudioId(layer);
+ async removeAudioLayer(studioTrackId: number, compDuration: number): Promise {
+ const layers = await aeBridge.getLayersForRange(0, compDuration);
+
+ for (let i = layers.length - 1; i >= 0; i--) {
+ const layer = layers[i];
+ if (!layer.hasAudio || layer.hasVideo) continue;
+
+ const audioProps = await aeBridge.getAudioLayerProps(layer.index);
+ const id = this.extractStudioIdComment(audioProps.comment);
+
if (id === studioTrackId) {
- layer.remove();
+ await aeBridge.removeLayer(layer.index);
return true;
}
}
return false;
}
- private extractStudioId(layer: any): number | null {
+ private extractStudioId(layer: { comment: string }): number | null {
+ return this.extractStudioIdComment(layer.comment);
+ }
+
+ private extractStudioIdComment(comment: string | undefined): number | null {
+ if (!comment) return null;
try {
- const data = JSON.parse(layer.comment);
+ const data = JSON.parse(comment);
return data.studio_audio_track_id ?? null;
} catch {
return null;
}
}
- private readLayerVolume(layer: any): number {
- try {
- // AE audio levels in dB, convert to 0-2 normalized range
- const audioLevels = layer.audio?.audioLevels;
- if (audioLevels) {
- const db = audioLevels.value[0]; // Left channel dB
- // dB to linear: 10^(dB/20), normalized to 0-2 range
- return Math.min(2, Math.max(0, Math.pow(10, db / 20)));
- }
- } catch {
- // Default volume
- }
- return 1.0;
+ private dbToLinear(db: number): number {
+ return Math.min(2, Math.max(0, Math.pow(10, db / 20)));
}
- private setLayerVolume(layer: any, volume: number): void {
+ private linearToDb(volume: number): number {
+ return volume > 0 ? 20 * Math.log10(volume) : -96;
+ }
+
+ private async setLayerVolume(layerIndex: number, volume: number): Promise {
try {
- const audioLevels = layer.audio?.audioLevels;
- if (audioLevels) {
- // Linear to dB: 20 * log10(volume)
- const db = volume > 0 ? 20 * Math.log10(volume) : -96;
- audioLevels.setValue([db, db]); // Both channels
- }
+ const db = this.linearToDb(volume);
+ await aeBridge.setAudioLevel(layerIndex, db, db);
} catch (e) {
logger.warn('Could not set audio volume', e);
}
diff --git a/src/ae/composition-reader.ts b/src/ae/composition-reader.ts
index e022497..77367b2 100644
--- a/src/ae/composition-reader.ts
+++ b/src/ae/composition-reader.ts
@@ -1,5 +1,6 @@
/**
* Reads After Effects composition structure for sync comparison.
+ * CEP implementation — all AE interaction goes through aeBridge.
*/
import { Logger } from '../core/logger';
@@ -7,6 +8,7 @@ import { MarkerManager, FrameMarkerData } from './marker-manager';
import { LayerMapper, LayerMapping } from './layer-mapper';
import { AudioLayerMapper, AudioLayerMapping } from './audio-layer-mapper';
import { TimelineConverter } from './timeline-converter';
+import { aeBridge } from '../cep/ae-bridge';
const logger = new Logger('CompositionReader');
@@ -47,30 +49,28 @@ export class CompositionReader {
/**
* Read the full state of the active composition.
*/
- readActiveComp(): CompState | null {
- const comp = app.project.activeItem;
- if (!comp || !(comp instanceof CompItem)) {
+ async readActiveComp(): Promise {
+ const comp = await aeBridge.getActiveComp();
+ if (!comp) {
logger.warn('No active composition');
return null;
}
- return this.readComp(comp);
+ return this.readCompState(comp);
}
- /**
- * Read the full state of a composition.
- */
- readComp(comp: any): CompState {
+ private async readCompState(comp: { name: string; width: number; height: number; frameRate: number; duration: number; comment: string }): Promise {
this.timelineConverter.setFrameRate(comp.frameRate);
- const projectUuid = this.extractProjectUuid(comp);
- const frameMarkers = this.markerManager.readFrameMarkers(comp);
+ const projectUuid = this.extractProjectUuid(comp.comment);
+ const frameMarkers = await this.markerManager.readFrameMarkers();
- const frames: CompFrameState[] = frameMarkers.map(marker => {
- const range = this.markerManager.getFrameTimeRange(comp, marker.studio_frame_id);
- const layers = this.layerMapper.getLayersForFrame(comp, marker.studio_frame_id);
+ const frames: CompFrameState[] = [];
+ for (const marker of frameMarkers) {
+ const range = await this.markerManager.getFrameTimeRangeWithDuration(marker.studio_frame_id, comp.duration);
+ const layers = await this.layerMapper.getLayersForFrame(marker.studio_frame_id, comp.duration);
- return {
+ frames.push({
studioFrameId: marker.studio_frame_id,
sequenceOrder: marker.sequence_order,
startTime: range?.startTime ?? 0,
@@ -78,10 +78,10 @@ export class CompositionReader {
duration: range?.duration ?? 0,
layers,
dataChecksum: marker.data_checksum,
- };
- });
+ });
+ }
- const audioTracks = this.audioMapper.readAudioLayers(comp);
+ const audioTracks = await this.audioMapper.readAudioLayers(comp.duration);
return {
name: comp.name,
@@ -98,9 +98,9 @@ export class CompositionReader {
/**
* Extract the Studio project UUID from comp comment.
*/
- private extractProjectUuid(comp: any): string | null {
+ private extractProjectUuid(comment: string): string | null {
try {
- const data = JSON.parse(comp.comment);
+ const data = JSON.parse(comment);
return data.studio_project_uuid ?? null;
} catch {
return null;
diff --git a/src/ae/composition-writer.ts b/src/ae/composition-writer.ts
index 23cd128..1987245 100644
--- a/src/ae/composition-writer.ts
+++ b/src/ae/composition-writer.ts
@@ -1,6 +1,7 @@
/**
* Applies sync changes to After Effects compositions.
- * Handles creating comps, placing layers, updating markers, and rendering.
+ * CEP implementation — all AE interaction goes through aeBridge.
+ * Works on the active composition — no explicit comp parameter needed.
*/
import { Logger } from '../core/logger';
@@ -9,6 +10,7 @@ import { LayerMapper } from './layer-mapper';
import { AudioLayerMapper } from './audio-layer-mapper';
import { FootageMapper } from './footage-mapper';
import { TimelineConverter } from './timeline-converter';
+import { aeBridge } from '../cep/ae-bridge';
import type { StudioFrame, StudioAudioTrack, StudioProject } from '../types/studio-api';
const logger = new Logger('CompositionWriter');
@@ -34,6 +36,7 @@ export class CompositionWriter {
private audioMapper: AudioLayerMapper;
private footageMapper: FootageMapper;
private timelineConverter: TimelineConverter;
+ private _compDuration: number = 60;
constructor() {
this.timelineConverter = new TimelineConverter();
@@ -43,60 +46,57 @@ export class CompositionWriter {
this.footageMapper = new FootageMapper();
}
+ getCompDuration(): number {
+ return this._compDuration;
+ }
+
/**
* Create a new AE composition matching a Studio project.
- * Uses width/height from the manifest project data if available,
- * falling back to RESOLUTION_MAP lookup by resolution string.
*/
- createComposition(project: StudioProject, totalDuration: number): any {
+ async createComposition(project: StudioProject, totalDuration: number): Promise {
const fallback = RESOLUTION_MAP[project.resolution] ?? RESOLUTION_MAP['1080p'];
const width = project.width ?? fallback.width;
const height = project.height ?? fallback.height;
const frameRate = fallback.frameRate;
this.timelineConverter.setFrameRate(frameRate);
+ this._compDuration = totalDuration || 60;
- const comp = app.project.items.addComp(
+ const comp = await aeBridge.createComp(
`MStudio - ${project.name}`,
width,
height,
- 1, // pixel aspect ratio
- totalDuration || 60, // duration in seconds
+ totalDuration || 60,
frameRate
);
- // Store project UUID in comp comment
- comp.comment = JSON.stringify({
+ await aeBridge.setCompComment(JSON.stringify({
studio_project_uuid: project.uuid,
synced_at: new Date().toISOString(),
- });
+ }));
- logger.info(`Created composition: ${comp.name} (${width}x${height} @ ${frameRate}fps)`);
+ logger.info(`Created composition: MStudio - ${project.name} (${width}x${height} @ ${frameRate}fps)`);
return comp;
}
/**
* Apply frame data from Studio to the AE composition.
- * Downloads and imports footage, creates markers and layers.
*/
- applyFrame(
- comp: any,
+ async applyFrame(
frame: StudioFrame,
footageItem: any | null,
cumulativeTime: number
- ): void {
- // Add marker for frame boundary
- this.markerManager.addFrameMarker(comp, cumulativeTime, {
+ ): Promise {
+ await this.markerManager.addFrameMarker(cumulativeTime, {
studio_frame_id: frame.id,
sequence_order: frame.sequence_order,
data_checksum: frame.active_version?.data_checksum ?? undefined,
});
- // Place footage as layer if available
if (footageItem) {
- this.layerMapper.placeFootageInFrame(
- comp,
+ await this.layerMapper.placeFootageInFrame(
footageItem,
frame.id,
+ this._compDuration,
`Frame ${frame.sequence_order}`
);
}
@@ -104,26 +104,22 @@ export class CompositionWriter {
/**
* Update an existing frame in the composition.
- * Replaces footage if changed, updates marker metadata.
*/
- updateFrame(
- comp: any,
+ async updateFrame(
frame: StudioFrame,
footageItem: any | null
- ): void {
- // Update marker metadata
- this.markerManager.updateFrameMarker(comp, frame.id, {
+ ): Promise {
+ await this.markerManager.updateFrameMarker(frame.id, {
sequence_order: frame.sequence_order,
data_checksum: frame.active_version?.data_checksum ?? undefined,
});
- // Replace footage if provided
if (footageItem) {
- this.layerMapper.removeLayersForFrame(comp, frame.id);
- this.layerMapper.placeFootageInFrame(
- comp,
+ await this.layerMapper.removeLayersForFrame(frame.id, this._compDuration);
+ await this.layerMapper.placeFootageInFrame(
footageItem,
frame.id,
+ this._compDuration,
`Frame ${frame.sequence_order}`
);
}
@@ -132,22 +128,20 @@ export class CompositionWriter {
/**
* Remove a frame from the composition (marker + layers).
*/
- removeFrame(comp: any, studioFrameId: number): void {
- this.layerMapper.removeLayersForFrame(comp, studioFrameId);
- this.markerManager.removeFrameMarker(comp, studioFrameId);
+ async removeFrame(studioFrameId: number): Promise {
+ await this.layerMapper.removeLayersForFrame(studioFrameId, this._compDuration);
+ await this.markerManager.removeFrameMarker(studioFrameId);
logger.info(`Removed frame ${studioFrameId} from comp`);
}
/**
* Apply an audio track from Studio.
*/
- applyAudioTrack(
- comp: any,
+ async applyAudioTrack(
track: StudioAudioTrack,
audioFootage: any
- ): void {
- this.audioMapper.placeAudioLayer(
- comp,
+ ): Promise {
+ await this.audioMapper.placeAudioLayer(
audioFootage,
track.start_time_ms,
track.duration_ms,
@@ -164,9 +158,8 @@ export class CompositionWriter {
/**
* Update an existing audio track.
*/
- updateAudioTrack(comp: any, track: StudioAudioTrack, aeLayerIndex: number): void {
- this.audioMapper.updateAudioLayer(
- comp,
+ async updateAudioTrack(track: StudioAudioTrack, aeLayerIndex: number): Promise {
+ await this.audioMapper.updateAudioLayer(
aeLayerIndex,
track.start_time_ms,
track.duration_ms,
@@ -177,42 +170,27 @@ export class CompositionWriter {
/**
* Remove an audio track by Studio ID.
*/
- removeAudioTrack(comp: any, studioTrackId: number): void {
- this.audioMapper.removeAudioLayer(comp, studioTrackId);
+ async removeAudioTrack(studioTrackId: number): Promise {
+ await this.audioMapper.removeAudioLayer(studioTrackId, this._compDuration);
}
/**
* Render a frame's time range to a video file for upload to Studio.
- * This handles the AE → Studio flow where local edits (effects, compositing)
- * are rendered and uploaded.
*/
async renderFrameToFile(
- comp: any,
studioFrameId: number,
outputPath: string,
format: string = 'mp4'
): Promise<{ outputPath: string; durationMs: number } | null> {
- const range = this.markerManager.getFrameTimeRange(comp, studioFrameId);
+ const range = await this.markerManager.getFrameTimeRange(studioFrameId);
if (!range) {
logger.error(`Cannot render: no marker found for frame ${studioFrameId}`);
return null;
}
- // Add to render queue
- const renderItem = app.project.renderQueue.items.add(comp);
- const outputModule = renderItem.outputModule(1);
-
- // Set output file
- outputModule.file = new AEFile(outputPath);
-
- // Set time span to render only this frame's range
- renderItem.timeSpanStart = range.startTime;
- renderItem.timeSpanDuration = range.duration;
-
- logger.info(`Rendering frame ${studioFrameId}: ${range.startTime}s - ${range.endTime}s → ${outputPath}`);
+ await aeBridge.renderFrameRange(outputPath, range.startTime, range.duration);
- // Start render (AE renders synchronously in script context)
- app.project.renderQueue.render();
+ logger.info(`Rendered frame ${studioFrameId}: ${range.startTime}s - ${range.endTime}s → ${outputPath}`);
return {
outputPath,
diff --git a/src/ae/footage-mapper.ts b/src/ae/footage-mapper.ts
index ac613b6..84c4aff 100644
--- a/src/ae/footage-mapper.ts
+++ b/src/ae/footage-mapper.ts
@@ -1,8 +1,10 @@
/**
* Maps AE footage items to Studio assets via content checksums.
+ * CEP implementation — uses aeBridge.evalScript() instead of direct AE globals.
*/
import { Logger } from '../core/logger';
+import { aeBridge } from '../cep/ae-bridge';
const logger = new Logger('FootageMapper');
@@ -19,47 +21,24 @@ export class FootageMapper {
/**
* Import a file into the AE project as footage.
*/
- importFootage(filePath: string, studioAssetId: number): any {
- const importOptions = new ImportOptions();
- importOptions.file = new AEFile(filePath);
-
- const footageItem = app.project.importFile(importOptions);
+ async importFootage(filePath: string, studioAssetId: number): Promise {
+ const footage = await aeBridge.importFile(filePath);
logger.info(`Imported footage: ${filePath} (asset ${studioAssetId})`);
-
- return footageItem;
+ return footage;
}
/**
* Find footage in the AE project by file name.
*/
- findFootageByName(filename: string): any | null {
- for (let i = 1; i <= app.project.numItems; i++) {
- const item = app.project.item(i);
- if (item instanceof FootageItem && item.name === filename) {
- return item;
- }
- }
- return null;
+ async findFootageByName(filename: string): Promise {
+ return aeBridge.findFootageByName(filename);
}
/**
* Get all footage items in the AE project.
*/
- getAllFootage(): Array<{ id: number; name: string; path: string }> {
- const items: Array<{ id: number; name: string; path: string }> = [];
-
- for (let i = 1; i <= app.project.numItems; i++) {
- const item = app.project.item(i);
- if (item instanceof FootageItem && item.file) {
- items.push({
- id: item.id,
- name: item.name,
- path: item.file.fsName,
- });
- }
- }
-
- return items;
+ async getAllFootage(): Promise> {
+ return aeBridge.getAllFootage();
}
/**
diff --git a/src/ae/layer-mapper.ts b/src/ae/layer-mapper.ts
index 4f1228d..23a2a6b 100644
--- a/src/ae/layer-mapper.ts
+++ b/src/ae/layer-mapper.ts
@@ -1,10 +1,12 @@
/**
* Maps AE layers (footage within marker regions) to Studio frames.
+ * CEP implementation — all AE interaction goes through aeBridge.
*/
import { Logger } from '../core/logger';
import { MarkerManager, FrameMarkerData } from './marker-manager';
import { TimelineConverter } from './timeline-converter';
+import { aeBridge } from '../cep/ae-bridge';
const logger = new Logger('LayerMapper');
@@ -26,25 +28,23 @@ export class LayerMapper {
/**
* Find all layers that fall within a frame's time range.
*/
- getLayersForFrame(comp: any, studioFrameId: number): LayerMapping[] {
- const range = this.markerManager.getFrameTimeRange(comp, studioFrameId);
+ async getLayersForFrame(studioFrameId: number, compDuration: number): Promise {
+ const range = await this.markerManager.getFrameTimeRangeWithDuration(studioFrameId, compDuration);
if (!range) return [];
+ const layers = await aeBridge.getLayersForRange(range.startTime, range.endTime);
const mappings: LayerMapping[] = [];
- for (let i = 1; i <= comp.numLayers; i++) {
- const layer = comp.layer(i);
- if (!layer.enabled) continue;
-
+ for (const layer of layers) {
// Check if layer overlaps with frame's time range
if (layer.inPoint < range.endTime && layer.outPoint > range.startTime) {
- const isVideo = layer.hasVideo && layer.source?.duration > 0;
- const isImage = layer.hasVideo && (!layer.source?.duration || layer.source.duration === 0);
+ const isVideo = layer.hasVideo;
+ const isImage = layer.hasVideo;
mappings.push({
studioFrameId,
- imageLayerIndex: isImage ? i : undefined,
- videoLayerIndex: isVideo ? i : undefined,
+ imageLayerIndex: isImage ? layer.index : undefined,
+ videoLayerIndex: isVideo ? layer.index : undefined,
layerName: layer.name,
inPoint: layer.inPoint,
outPoint: layer.outPoint,
@@ -58,65 +58,49 @@ export class LayerMapper {
/**
* Place a footage item as a layer within a frame's time range.
*/
- placeFootageInFrame(
- comp: any,
+ async placeFootageInFrame(
footageItem: any,
studioFrameId: number,
+ compDuration: number,
layerName?: string
- ): any {
- const range = this.markerManager.getFrameTimeRange(comp, studioFrameId);
+ ): Promise {
+ const range = await this.markerManager.getFrameTimeRangeWithDuration(studioFrameId, compDuration);
if (!range) {
logger.error(`No marker found for frame ${studioFrameId}`);
return null;
}
- const layer = comp.layers.add(footageItem);
- layer.inPoint = range.startTime;
- layer.outPoint = range.endTime;
- layer.name = layerName || `Frame ${studioFrameId}`;
-
- // Scale to fit comp if needed
- this.scaleLayerToFit(layer, comp);
+ const layerIndex = await aeBridge.addLayer(
+ footageItem.id,
+ range.startTime,
+ range.endTime,
+ layerName || `Frame ${studioFrameId}`
+ );
+ // Scale to fit — need comp dimensions for this
logger.info(`Placed footage for frame ${studioFrameId}: ${range.startTime}s - ${range.endTime}s`);
- return layer;
+ return { index: layerIndex };
}
/**
* Remove all layers associated with a frame (within its time range).
*/
- removeLayersForFrame(comp: any, studioFrameId: number): number {
- const range = this.markerManager.getFrameTimeRange(comp, studioFrameId);
+ async removeLayersForFrame(studioFrameId: number, compDuration: number): Promise {
+ const range = await this.markerManager.getFrameTimeRangeWithDuration(studioFrameId, compDuration);
if (!range) return 0;
+ const layers = await aeBridge.getLayersForRange(range.startTime, range.endTime);
let removed = 0;
- // Iterate in reverse to avoid index shifting
- for (let i = comp.numLayers; i >= 1; i--) {
- const layer = comp.layer(i);
- // Only remove layers that are fully contained in the frame range
+
+ // Remove layers fully contained in the frame range (iterate reverse)
+ for (let i = layers.length - 1; i >= 0; i--) {
+ const layer = layers[i];
if (layer.inPoint >= range.startTime && layer.outPoint <= range.endTime) {
- layer.remove();
+ await aeBridge.removeLayer(layer.index);
removed++;
}
}
return removed;
}
-
- private scaleLayerToFit(layer: any, comp: any): void {
- if (!layer.hasVideo || !layer.source) return;
-
- const sourceWidth = layer.source.width;
- const sourceHeight = layer.source.height;
- const compWidth = comp.width;
- const compHeight = comp.height;
-
- if (sourceWidth === compWidth && sourceHeight === compHeight) return;
-
- const scaleX = (compWidth / sourceWidth) * 100;
- const scaleY = (compHeight / sourceHeight) * 100;
- const scale = Math.max(scaleX, scaleY); // Cover (use Math.min for fit)
-
- layer.transform.scale.setValue([scale, scale]);
- }
}
diff --git a/src/ae/marker-manager.ts b/src/ae/marker-manager.ts
index a935fc9..6cad9ff 100644
--- a/src/ae/marker-manager.ts
+++ b/src/ae/marker-manager.ts
@@ -1,9 +1,10 @@
/**
* Manages After Effects composition markers that define frame boundaries.
- * Each marker represents a Studio frame with metadata stored in marker comments.
+ * CEP implementation — all AE interaction goes through aeBridge.
*/
import { Logger } from '../core/logger';
+import { aeBridge } from '../cep/ae-bridge';
const logger = new Logger('MarkerManager');
@@ -13,20 +14,23 @@ export interface FrameMarkerData {
data_checksum?: string;
}
+export interface MarkerKeyInfo {
+ index: number;
+ time: number;
+ data: FrameMarkerData | null;
+}
+
export class MarkerManager {
/**
- * Read all frame markers from a composition.
+ * Fetch all markers from the active composition and parse in TS.
*/
- readFrameMarkers(comp: any): FrameMarkerData[] {
+ async readFrameMarkers(): Promise {
+ const raw = await aeBridge.getMarkers();
const markers: FrameMarkerData[] = [];
- const markerProp = comp.markerProperty;
- if (!markerProp) return markers;
-
- for (let i = 1; i <= markerProp.numKeys; i++) {
- const comment = markerProp.keyValue(i).comment;
+ for (const m of raw) {
try {
- const data = JSON.parse(comment) as FrameMarkerData;
+ const data = JSON.parse(m.comment) as FrameMarkerData;
if (data.studio_frame_id) {
markers.push(data);
}
@@ -39,42 +43,44 @@ export class MarkerManager {
}
/**
- * Add a frame marker at a specific time in the composition.
+ * Add a frame marker at a specific time.
*/
- addFrameMarker(
- comp: any,
+ async addFrameMarker(
timeSeconds: number,
data: FrameMarkerData
- ): void {
- const markerProp = comp.markerProperty;
- const markerValue = new MarkerValue(data.studio_frame_id.toString());
- markerValue.comment = JSON.stringify(data);
- markerProp.setValueAtTime(timeSeconds, markerValue);
+ ): Promise {
+ await aeBridge.addMarker(timeSeconds, JSON.stringify(data));
logger.info(`Added marker for frame ${data.studio_frame_id} at ${timeSeconds}s`);
}
+ /**
+ * Get all markers with their indices and parsed data.
+ */
+ private async getAllMarkerKeys(): Promise {
+ const raw = await aeBridge.getMarkers();
+ return raw.map(m => {
+ try {
+ return { index: m.index, time: m.time, data: JSON.parse(m.comment) as FrameMarkerData };
+ } catch {
+ return { index: m.index, time: m.time, data: null };
+ }
+ });
+ }
+
/**
* Update an existing frame marker's metadata.
*/
- updateFrameMarker(
- comp: any,
+ async updateFrameMarker(
studioFrameId: number,
updates: Partial
- ): boolean {
- const markerProp = comp.markerProperty;
-
- for (let i = 1; i <= markerProp.numKeys; i++) {
- try {
- const existing = JSON.parse(markerProp.keyValue(i).comment) as FrameMarkerData;
- if (existing.studio_frame_id === studioFrameId) {
- const updated = { ...existing, ...updates };
- const markerValue = new MarkerValue(studioFrameId.toString());
- markerValue.comment = JSON.stringify(updated);
- markerProp.setValueAtKey(i, markerValue);
- return true;
- }
- } catch {
- continue;
+ ): Promise {
+ const keys = await this.getAllMarkerKeys();
+
+ for (const key of keys) {
+ if (key.data?.studio_frame_id === studioFrameId) {
+ const updated = { ...key.data, ...updates };
+ await aeBridge.updateMarker(key.time, JSON.stringify(updated));
+ return true;
}
}
@@ -84,18 +90,15 @@ export class MarkerManager {
/**
* Remove a frame marker by Studio frame ID.
*/
- removeFrameMarker(comp: any, studioFrameId: number): boolean {
- const markerProp = comp.markerProperty;
-
- for (let i = markerProp.numKeys; i >= 1; i--) {
- try {
- const data = JSON.parse(markerProp.keyValue(i).comment) as FrameMarkerData;
- if (data.studio_frame_id === studioFrameId) {
- markerProp.removeKey(i);
- return true;
- }
- } catch {
- continue;
+ async removeFrameMarker(studioFrameId: number): Promise {
+ const keys = await this.getAllMarkerKeys();
+
+ // Iterate in reverse to remove safely
+ for (let i = keys.length - 1; i >= 0; i--) {
+ const key = keys[i];
+ if (key.data?.studio_frame_id === studioFrameId) {
+ await aeBridge.removeMarkerAtTime(key.time);
+ return true;
}
}
@@ -104,38 +107,56 @@ export class MarkerManager {
/**
* Get the time range for a specific frame marker.
- * The range extends from the marker time to the next marker (or comp end).
*/
- getFrameTimeRange(
- comp: any,
+ async getFrameTimeRange(
studioFrameId: number
- ): { startTime: number; endTime: number; duration: number } | null {
- const markerProp = comp.markerProperty;
- let markerIndex = -1;
-
- for (let i = 1; i <= markerProp.numKeys; i++) {
- try {
- const data = JSON.parse(markerProp.keyValue(i).comment) as FrameMarkerData;
- if (data.studio_frame_id === studioFrameId) {
- markerIndex = i;
- break;
- }
- } catch {
- continue;
+ ): Promise<{ startTime: number; endTime: number; duration: number } | null> {
+ const keys = await this.getAllMarkerKeys();
+ const sortedKeys = keys.filter(k => k.data !== null).sort((a, b) => a.time - b.time);
+
+ for (let i = 0; i < sortedKeys.length; i++) {
+ if (sortedKeys[i].data?.studio_frame_id === studioFrameId) {
+ const startTime = sortedKeys[i].time;
+ const endTime = i + 1 < sortedKeys.length
+ ? sortedKeys[i + 1].time
+ : 99999; // Need comp duration from a separate call
+
+ return {
+ startTime,
+ endTime,
+ duration: endTime - startTime,
+ };
}
}
- if (markerIndex === -1) return null;
+ return null;
+ }
- const startTime = markerProp.keyTime(markerIndex);
- const endTime = markerIndex < markerProp.numKeys
- ? markerProp.keyTime(markerIndex + 1)
- : comp.duration;
+ /**
+ * Alternative: get time range with explicit comp duration.
+ */
+ async getFrameTimeRangeWithDuration(
+ studioFrameId: number,
+ compDuration: number
+ ): Promise<{ startTime: number; endTime: number; duration: number } | null> {
+ const keys = await this.getAllMarkerKeys();
+ const sortedKeys = keys.filter(k => k.data !== null).sort((a, b) => a.time - b.time);
+
+ for (let i = 0; i < sortedKeys.length; i++) {
+ if (sortedKeys[i].data?.studio_frame_id === studioFrameId) {
+ const startTime = sortedKeys[i].time;
+ const endTime = i + 1 < sortedKeys.length
+ ? sortedKeys[i + 1].time
+ : compDuration;
+
+ return {
+ startTime,
+ endTime,
+ duration: endTime - startTime,
+ };
+ }
+ }
- return {
- startTime,
- endTime,
- duration: endTime - startTime,
- };
+ return null;
}
}
diff --git a/src/cep/ae-bridge.ts b/src/cep/ae-bridge.ts
new file mode 100644
index 0000000..8d8c553
--- /dev/null
+++ b/src/cep/ae-bridge.ts
@@ -0,0 +1,377 @@
+/**
+ * CEP Bridge — replaces UXP require() and direct AE globals
+ * with CEP-compatible evalScript-based alternatives.
+ *
+ * This module provides the same interfaces the existing code expects,
+ * using CSInterface.evalScript() to communicate with AE's ExtendScript engine.
+ */
+
+export interface AEFileInfo {
+ fsName: string;
+ name: string;
+ path: string;
+}
+
+export interface AECompInfo {
+ name: string;
+ width: number;
+ height: number;
+ frameRate: number;
+ duration: number;
+ numLayers: number;
+ comment: string;
+}
+
+export interface AEFootageInfo {
+ id: number;
+ name: string;
+ width: number;
+ height: number;
+ duration: number;
+}
+
+export interface AELayerInfo {
+ index: number;
+ name: string;
+ hasVideo: boolean;
+ hasAudio: boolean;
+ enabled: boolean;
+ inPoint: number;
+ outPoint: number;
+ comment: string;
+ startTime: number;
+}
+
+export interface AEMarkerInfo {
+ index: number;
+ time: number;
+ comment: string;
+}
+
+/**
+ * Singleton bridge to AE's ExtendScript engine.
+ * All methods are async — they send ExtendScript via evalScript and parse results.
+ */
+class AEBridge {
+ private _csInterface: any = null;
+
+ private get csInterface(): any {
+ if (!this._csInterface) {
+ this._csInterface = new (window as any).CSInterface();
+ }
+ return this._csInterface;
+ }
+
+ /**
+ * Execute ExtendScript and return parsed result.
+ * Handles JSON deserialization automatically.
+ */
+ eval(script: string): Promise {
+ return new Promise((resolve, reject) => {
+ this.csInterface.evalScript(script, (result: string) => {
+ if (result === undefined || result === null) {
+ resolve(undefined as unknown as T);
+ return;
+ }
+ try {
+ resolve(JSON.parse(result));
+ } catch {
+ resolve(result as unknown as T);
+ }
+ });
+ });
+ }
+
+ // ─── File system (replaces require('os') and require('uxp').storage) ───
+
+ async getTempDir(): Promise {
+ return this.eval('Folder.temp.fsName');
+ }
+
+ async createFile(data: ArrayBuffer, path: string): Promise {
+ const base64 = this.arrayBufferToBase64(data);
+
+ const cepFs = (window as any).cep?.fs ?? (window as any).__adobe_cep__?.fs;
+ if (cepFs && typeof cepFs.writeFile === 'function') {
+ const B64 = (cepFs.Encoding && (cepFs.Encoding.BASE64 || cepFs.Encoding.Base64)) || 'Base64';
+ const result = cepFs.writeFile(path, base64, B64);
+ if (result.err !== undefined && result.err !== 0) {
+ throw new Error(`CEP fs.writeFile failed (err=${result.err}) on ${path}`);
+ }
+ return;
+ }
+
+ if (data.byteLength > 512 * 1024) {
+ throw new Error(
+ `Cannot write ${path}: CEP fs API unavailable and file is ${(data.byteLength / 1024).toFixed(0)}KB ` +
+ '(ExtendScript fallback limited to <512KB). Ensure the panel is loaded as a CEP extension.'
+ );
+ }
+
+ const hex = this.arrayBufferToHex(data);
+ await this.eval(
+ `(function(){
+ var _b64='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+ function _dec(s){var r=[];var i=0;while(i>4));if(c!==-1)r.push(((b&15)<<4)|(c>>2));if(d!==-1)r.push(((c&3)<<6)|d);}return r;}
+ var f=new File('${this.escapeString(path)}');
+ f.encoding='BINARY';
+ if(!f.open('w')){throw new Error('Cannot open file: '+f.error);}
+ if('${hex}'.length>0){
+ var hx='${hex}';
+ for(var i=0;i {
+ const cepFs = (window as any).cep?.fs ?? (window as any).__adobe_cep__?.fs;
+ if (cepFs && typeof cepFs.readFile === 'function') {
+ const B64 = (cepFs.Encoding && (cepFs.Encoding.BASE64 || cepFs.Encoding.Base64)) || 'Base64';
+ const result = cepFs.readFile(path, B64);
+ if (result.err !== undefined && result.err !== 0) {
+ throw new Error(`CEP fs.readFile failed (err=${result.err}) on ${path}`);
+ }
+ return this.base64ToArrayBuffer(result.data);
+ }
+
+ // Fallback: ExtendScript (returns hex-wrapped string for binary)
+ const result = await this.eval(
+ `(function(){var f=new File('${this.escapeString(path)}');f.encoding='BINARY';f.open('r');var d=f.read();f.close();return d.toSource();})()`
+ );
+ const bytes = this.parseExtendScriptBinary(result);
+ return bytes;
+ }
+
+ async fileExists(path: string): Promise {
+ const result = await this.eval(
+ `new File('${this.escapeString(path)}').exists ? 'true' : 'false'`
+ );
+ return result === 'true';
+ }
+
+ async listDir(path: string): Promise> {
+ return this.eval(
+ `(function(){var f=new Folder('${this.escapeString(path)}');var entries=[];var all=f.getFiles();for(var i=0;i {
+ await this.eval(
+ `(function(){var f=new File('${this.escapeString(path)}');if(f.exists)f.remove();return 1;})()`
+ );
+ }
+
+ // ─── AE Project / Composition ───
+
+ async getActiveComp(): Promise {
+ return this.eval(
+ `(function(){var c=app.project.activeItem;if(!c||!(c instanceof CompItem))return null;return JSON.stringify({name:c.name,width:c.width,height:c.height,frameRate:c.frameRate,duration:c.duration,numLayers:c.numLayers,comment:c.comment||''});})()`
+ );
+ }
+
+ async createComp(
+ name: string,
+ width: number,
+ height: number,
+ duration: number,
+ frameRate: number
+ ): Promise {
+ return this.eval(
+ `(function(){var c=app.project.items.addComp('${this.escapeString(name)}',${width},${height},1,${duration},${frameRate});return JSON.stringify({name:c.name,width:c.width,height:c.height,frameRate:c.frameRate,duration:c.duration,numLayers:c.numLayers,comment:c.comment||''});})()`
+ );
+ }
+
+ async setCompComment(comment: string): Promise {
+ await this.eval(
+ `(function(){var c=app.project.activeItem;if(c&&c instanceof CompItem){c.comment='${this.escapeString(comment)}';};return 1;})()`
+ );
+ }
+
+ // ─── Import / Footage ───
+
+ async importFile(filePath: string): Promise {
+ return this.eval(
+ `(function(){var opts=new ImportOptions();opts.file=new File('${this.escapeString(filePath)}');var footage=app.project.importFile(opts);return JSON.stringify({id:footage.id,name:footage.name,width:footage.width,height:footage.height,duration:footage.duration});})()`
+ );
+ }
+
+ async findFootageByName(filename: string): Promise {
+ return this.eval(
+ `(function(){for(var i=1;i<=app.project.numItems;i++){var item=app.project.item(i);if(item instanceof FootageItem&&item.name==='${this.escapeString(filename)}'){return JSON.stringify({id:item.id,name:item.name,width:item.width,height:item.height,duration:item.duration});}}return null;})()`
+ );
+ }
+
+ async getAllFootage(): Promise> {
+ return this.eval(
+ `(function(){var items=[];for(var i=1;i<=app.project.numItems;i++){var item=app.project.item(i);if(item instanceof FootageItem&&item.file){items.push({id:item.id,name:item.name,path:item.file.fsName});}}return JSON.stringify(items);})()`
+ );
+ }
+
+ // ─── Layers ───
+
+ async addLayer(
+ footageId: number,
+ inPoint: number,
+ outPoint: number,
+ name: string
+ ): Promise {
+ return this.eval(
+ `(function(){var comp=app.project.activeItem;var footage=app.project.item(${footageId});var layer=comp.layers.add(footage);layer.inPoint=${inPoint};layer.outPoint=${outPoint};layer.name='${this.escapeString(name)}';return layer.index;})()`
+ );
+ }
+
+ async removeLayer(layerIndex: number): Promise {
+ await this.eval(
+ `(function(){var comp=app.project.activeItem;comp.layer(${layerIndex}).remove();return 1;})()`
+ );
+ }
+
+ async getLayersForRange(startTime: number, endTime: number): Promise {
+ return this.eval(
+ `(function(){var comp=app.project.activeItem;if(!comp)return JSON.stringify([]);var layers=[];for(var i=1;i<=comp.numLayers;i++){var l=comp.layer(i);if(l.inPoint<=${endTime}&&l.outPoint>=${startTime}&&l.enabled){layers.push({index:i,name:l.name,hasVideo:l.hasVideo||false,hasAudio:l.hasAudio||false,enabled:l.enabled,inPoint:l.inPoint,outPoint:l.outPoint,comment:l.comment||'',startTime:l.startTime||0});}}return JSON.stringify(layers);})()`
+ );
+ }
+
+ async setLayerScale(layerIndex: number, scaleX: number, scaleY: number): Promise {
+ await this.eval(
+ `(function(){var comp=app.project.activeItem;comp.layer(${layerIndex}).transform.scale.setValue([${scaleX},${scaleY}]);return 1;})()`
+ );
+ }
+
+ async setAudioLevel(layerIndex: number, leftDb: number, rightDb: number): Promise {
+ await this.eval(
+ `(function(){var comp=app.project.activeItem;comp.layer(${layerIndex}).audio.audioLevels.setValue([${leftDb},${rightDb}]);return 1;})()`
+ );
+ }
+
+ // ─── Markers ───
+
+ async addMarker(time: number, commentJson: string): Promise {
+ await this.eval(
+ `(function(){var comp=app.project.activeItem;if(!comp)return;var mv=new MarkerValue('${this.escapeString(commentJson)}');comp.markerProperty.setValueAtTime(${time},mv);return 1;})()`
+ );
+ }
+
+ async getMarkers(): Promise {
+ return this.eval(
+ `(function(){var comp=app.project.activeItem;if(!comp||!comp.markerProperty)return JSON.stringify([]);var markers=[];var mp=comp.markerProperty;for(var i=1;i<=mp.numKeys;i++){markers.push({index:i,time:mp.keyTime(i),comment:mp.keyValue(i).comment||''});}return JSON.stringify(markers);})()`
+ );
+ }
+
+ async updateMarker(time: number, commentJson: string): Promise {
+ await this.eval(
+ `(function(){var comp=app.project.activeItem;if(!comp)return;var mp=comp.markerProperty;for(var i=1;i<=mp.numKeys;i++){if(Math.abs(mp.keyTime(i)-${time})<0.001){mp.setValueAtKey(i,new MarkerValue('${this.escapeString(commentJson)}'));break;}}return 1;})()`
+ );
+ }
+
+ async removeMarkerAtTime(time: number): Promise {
+ await this.eval(
+ `(function(){var comp=app.project.activeItem;if(!comp)return;var mp=comp.markerProperty;for(var i=1;i<=mp.numKeys;i++){if(Math.abs(mp.keyTime(i)-${time})<0.001){mp.removeKey(i);break;}}return 1;})()`
+ );
+ }
+
+ // ─── Render ───
+
+ async renderFrameRange(
+ outputPath: string,
+ startTime: number,
+ duration: number
+ ): Promise {
+ await this.eval(
+ `(function(){var comp=app.project.activeItem;if(!comp)return;var ri=app.project.renderQueue.items.add(comp);ri.outputModule(1).file=new File('${this.escapeString(outputPath)}');ri.timeSpanStart=${startTime};ri.timeSpanDuration=${duration};app.project.renderQueue.render();return 1;})()`
+ );
+ }
+
+ // ─── Audio layer properties ───
+
+ async getAudioLayerProps(layerIndex: number): Promise<{
+ hasAudio: boolean;
+ audioEnabled: boolean;
+ audioLevels: [number, number];
+ comment: string;
+ }> {
+ return this.eval(
+ `(function(){var comp=app.project.activeItem;var l=comp.layer(${layerIndex});return JSON.stringify({hasAudio:l.hasAudio||false,audioEnabled:l.audioEnabled,audioLevels:l.audio?l.audio.audioLevels.value:[0,0],comment:l.comment||''});})()`
+ );
+ }
+
+ async setLayerComment(layerIndex: number, comment: string): Promise {
+ await this.eval(
+ `(function(){var comp=app.project.activeItem;comp.layer(${layerIndex}).comment='${this.escapeString(comment)}';return 1;})()`
+ );
+ }
+
+ async setLayerTiming(layerIndex: number, inPoint: number, outPoint: number, startTime?: number): Promise {
+ let script = `(function(){var comp=app.project.activeItem;comp.layer(${layerIndex}).inPoint=${inPoint};comp.layer(${layerIndex}).outPoint=${outPoint};`;
+ if (startTime !== undefined) {
+ script += `comp.layer(${layerIndex}).startTime=${startTime};`;
+ }
+ script += `return 1;})()`;
+ await this.eval(script);
+ }
+
+ async setAudioEnabled(layerIndex: number, enabled: boolean): Promise {
+ await this.eval(
+ `(function(){var comp=app.project.activeItem;comp.layer(${layerIndex}).audioEnabled=${enabled};return 1;})()`
+ );
+ }
+
+ async showAlert(message: string): Promise {
+ await this.eval(
+ `(function(){alert('${this.escapeString(message)}');return 1;})()`
+ );
+ }
+
+ // ─── Utilities ───
+
+ private escapeString(s: string): string {
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
+ }
+
+ private arrayBufferToBase64(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+ }
+
+ private base64ToArrayBuffer(base64: string): ArrayBuffer {
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+ }
+
+ private arrayBufferToHex(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer);
+ let hex = '';
+ for (let i = 0; i < bytes.byteLength; i++) {
+ hex += ('0' + bytes[i].toString(16)).slice(-2);
+ }
+ return hex;
+ }
+
+ private parseExtendScriptBinary(source: string): ArrayBuffer {
+ // ExtendScript's .toSource() on binary strings returns "#[hex digits]#"
+ const match = source.match(/#([0-9a-fA-F]+)#/);
+ if (match) {
+ const hex = match[1];
+ const bytes = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < hex.length; i += 2) {
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
+ }
+ return bytes.buffer;
+ }
+ // Fallback: treat as UTF-8 string
+ return new TextEncoder().encode(source).buffer;
+ }
+}
+
+export const aeBridge = new AEBridge();
diff --git a/src/core/api-client.ts b/src/core/api-client.ts
index aa118b0..2f00507 100644
--- a/src/core/api-client.ts
+++ b/src/core/api-client.ts
@@ -35,13 +35,21 @@ export class ApiClient {
/**
* Test connection with current API key.
*/
- async testConnection(): Promise<{ success: boolean; user?: { name: string; email: string } }> {
+ async testConnection(): Promise<{ success: boolean; error?: string; user?: { name: string; email: string } }> {
try {
- const response = await this.request<{ name: string; email: string }>('GET', '/auth/me');
- return { success: true, user: response.data };
+ const response = await this.request<{ projects?: unknown[] } | unknown[]>('GET', '/projects');
+ // Defensive: API may wrap as { data: { projects: [...] } }, { data: [...] },
+ // or Laravel paginator { data: { current_page: 1, data: [...] } }
+ const data = response.data as any;
+ const projects = data?.projects ?? (Array.isArray(data) ? data : null) ?? (Array.isArray(data?.data) ? data.data : null);
+ if (projects !== null) {
+ return { success: true, user: { name: 'Authenticated', email: 'API key valid' } };
+ }
+ return { success: false, error: 'Unexpected API response format' };
} catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
logger.error('Connection test failed', e);
- return { success: false };
+ return { success: false, error: msg };
}
}
@@ -50,12 +58,17 @@ export class ApiClient {
*/
async listProjects(): Promise {
const response = await this.request<{ projects: ProjectListItem[] }>('GET', '/projects');
- if (Array.isArray(response.data.projects)) {
- return response.data.projects;
+ const data = response.data as any;
+ if (Array.isArray(data.projects)) {
+ return data.projects;
}
// Fallback: API may return data as array directly
- if (Array.isArray(response.data)) {
- return response.data as unknown as ProjectListItem[];
+ if (Array.isArray(data)) {
+ return data as unknown as ProjectListItem[];
+ }
+ // Laravel paginator: { current_page: 1, data: [...] }
+ if (Array.isArray(data?.data)) {
+ return data.data as unknown as ProjectListItem[];
}
return [];
}
@@ -207,13 +220,181 @@ export class ApiClient {
* Download a file from a signed URL.
*/
async downloadFile(url: string): Promise {
- const response = await fetch(url);
+ const response = await fetch(url, { redirect: 'follow' });
if (!response.ok) {
- throw new Error(`Download failed: ${response.status} ${response.statusText}`);
+ throw new Error(`Download failed: ${response.status} ${response.statusText} ${response.url}`);
+ }
+ const contentType = response.headers.get('content-type') || '';
+ if (contentType.includes('text/html')) {
+ throw new Error(`Download returned HTML instead of media — check that the download URL domain is in the manifest network permissions. URL: ${response.url.substring(0, 100)}`);
}
return response.arrayBuffer();
}
+ // ─── Image Generation ───
+
+ async generateImage(
+ frameId: number,
+ payload: import('../types/studio-api').GenerateImagePayload
+ ): Promise {
+ const response = await this.request<{ job: import('../types/studio-api').GenerationJob }>(
+ 'POST',
+ `/frames/${frameId}/generate-image`,
+ payload
+ );
+ return (response.data as any).job ?? response.data;
+ }
+
+ async generateImageWithReferences(
+ frameId: number,
+ payload: import('../types/studio-api').GenerateImageWithReferencesPayload
+ ): Promise {
+ const response = await this.request<{ job: import('../types/studio-api').GenerationJob }>(
+ 'POST',
+ `/frames/${frameId}/generate-image-with-references`,
+ payload
+ );
+ return (response.data as any).job ?? response.data;
+ }
+
+ async generateMultiShot(
+ frameId: number,
+ payload: import('../types/studio-api').GenerateMultiShotPayload
+ ): Promise {
+ const response = await this.request<{ job: import('../types/studio-api').GenerationJob }>(
+ 'POST',
+ `/frames/${frameId}/generate-multi-shot`,
+ payload
+ );
+ return (response.data as any).job ?? response.data;
+ }
+
+ async editImage(
+ frameId: number,
+ payload: import('../types/studio-api').EditImagePayload
+ ): Promise {
+ const response = await this.request<{ job: import('../types/studio-api').GenerationJob }>(
+ 'POST',
+ `/frames/${frameId}/edit-image`,
+ payload
+ );
+ return (response.data as any).job ?? response.data;
+ }
+
+ // ─── Video Generation ───
+
+ async generateVideo(
+ frameId: number,
+ payload: import('../types/studio-api').GenerateVideoPayload
+ ): Promise {
+ const response = await this.request<{ job: import('../types/studio-api').GenerationJob }>(
+ 'POST',
+ `/frames/${frameId}/generate-video`,
+ payload
+ );
+ return (response.data as any).job ?? response.data;
+ }
+
+ async extendVideo(
+ frameId: number,
+ payload: import('../types/studio-api').ExtendVideoPayload
+ ): Promise {
+ const response = await this.request<{ job: import('../types/studio-api').GenerationJob }>(
+ 'POST',
+ `/frames/${frameId}/extend-video`,
+ payload
+ );
+ return (response.data as any).job ?? response.data;
+ }
+
+ async lipSync(
+ frameId: number,
+ payload: import('../types/studio-api').LipSyncPayload
+ ): Promise {
+ const response = await this.request<{ job: import('../types/studio-api').GenerationJob }>(
+ 'POST',
+ `/frames/${frameId}/lip-sync`,
+ payload
+ );
+ return (response.data as any).job ?? response.data;
+ }
+
+ // ─── Generation Jobs ───
+
+ async getGenerationJob(jobId: number): Promise {
+ const response = await this.request<{ job: import('../types/studio-api').GenerationJob }>(
+ 'GET',
+ `/generation-jobs/${jobId}`
+ );
+ return (response.data as any).job ?? response.data;
+ }
+
+ async cancelGenerationJob(jobId: number): Promise {
+ await this.request('POST', `/generation-jobs/${jobId}/cancel`);
+ }
+
+ async retryGenerationJob(jobId: number): Promise {
+ const response = await this.request<{ job: import('../types/studio-api').GenerationJob }>(
+ 'POST',
+ `/generation-jobs/${jobId}/retry`
+ );
+ return (response.data as any).job ?? response.data;
+ }
+
+ // ─── Audio Generation ───
+
+ async generateVoice(
+ payload: import('../types/studio-api').GenerateVoicePayload
+ ): Promise {
+ const response = await this.request<{ job: import('../types/studio-api').GenerationJob }>(
+ 'POST',
+ '/audio/generate-voice',
+ payload
+ );
+ return (response.data as any).job ?? response.data;
+ }
+
+ async generateSfx(
+ payload: import('../types/studio-api').GenerateSfxPayload
+ ): Promise {
+ const response = await this.request<{ job: import('../types/studio-api').GenerationJob }>(
+ 'POST',
+ '/audio/generate-sfx',
+ payload
+ );
+ return (response.data as any).job ?? response.data;
+ }
+
+ // ─── Export ───
+
+ async exportProject(
+ projectUuid: string,
+ payload: import('../types/studio-api').ExportPayload
+ ): Promise {
+ const response = await this.request<{ export_job: import('../types/studio-api').ExportJob }>(
+ 'POST',
+ `/projects/${projectUuid}/export`,
+ payload
+ );
+ return (response.data as any).export_job ?? response.data;
+ }
+
+ async getExportJob(exportId: number): Promise {
+ const response = await this.request<{ export_job: import('../types/studio-api').ExportJob }>(
+ 'GET',
+ `/exports/${exportId}`
+ );
+ return (response.data as any).export_job ?? response.data;
+ }
+
+ async getExportDownloadUrl(exportId: number): Promise {
+ const response = await this.request<{ download_url: string }>(
+ 'GET',
+ `/exports/${exportId}/download`
+ );
+ return (response.data as any).download_url ?? (response.data as any).url ?? '';
+ }
+
private async request(
method: string,
path: string,
@@ -228,51 +409,30 @@ export class ApiClient {
throw new Error('API key not configured');
}
- const url = `${baseUrl}${path}`;
- const headers: Record = {
- 'X-API-Key': apiKey,
- 'Accept': 'application/json',
- 'Content-Type': 'application/json',
- };
-
- const options: RequestInit = { method, headers };
- if (body && method !== 'GET') {
- options.body = JSON.stringify(body);
+ if (baseUrl.startsWith('http://')) {
+ throw new ApiError(
+ `API base URL must use HTTPS. HTTP URLs cause redirects that strip authentication headers. Change "${baseUrl}" to use https:// in Settings.`,
+ 0,
+ { http_url: true }
+ );
}
- logger.debug(`${method} ${path}`);
+ const url = `${baseUrl}${path}`;
+
+ logger.info(`${method} ${url} [key=${apiKey ? apiKey.substring(0, 10) + '...' : 'NONE'}]`);
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
- const response = await fetch(url, options);
- const json = await response.json();
-
- if (!response.ok) {
- const error = new ApiError(
- json.message || `HTTP ${response.status}`,
- response.status,
- json
- );
- // Only retry on 5xx server errors, not client errors
- if (response.status >= 500 && attempt < retries) {
- logger.warn(`Retrying ${method} ${path} (attempt ${attempt + 1}/${retries}) after ${response.status}`);
- await this.delay(1000 * (attempt + 1));
- lastError = error;
- continue;
- }
- logger.error(`API error: ${method} ${path}`, { status: response.status, message: json.message });
- throw error;
- }
-
- return json as ApiResponse;
+ const json = await this.fetchOrXHR(method, url, apiKey, body);
+ return json;
} catch (e) {
- // Retry on network errors (fetch throws TypeError for network failures)
- if (e instanceof TypeError && attempt < retries) {
- logger.warn(`Network error, retrying ${method} ${path} (attempt ${attempt + 1}/${retries})`);
+ const shouldRetry = e instanceof TypeError || (e instanceof ApiError && e.status >= 500);
+ if (shouldRetry && attempt < retries) {
+ logger.warn(`Retrying ${method} ${path} (attempt ${attempt + 1}/${retries})`);
await this.delay(1000 * (attempt + 1));
- lastError = e;
+ lastError = e instanceof Error ? e : new Error(String(e));
continue;
}
throw e;
@@ -282,6 +442,177 @@ export class ApiClient {
throw lastError ?? new Error('Request failed after retries');
}
+ /**
+ * Try fetch first, fall back to XMLHttpRequest for CORS-restricted environments (CEP panels).
+ */
+ private async fetchOrXHR(
+ method: string,
+ url: string,
+ apiKey: string,
+ body?: unknown
+ ): Promise> {
+ // Try fetch first
+ try {
+ return await this.doFetch(method, url, apiKey, body);
+ } catch (fetchError) {
+ // Redirect detected — URL is likely HTTP being redirected to HTTPS
+ if (fetchError instanceof TypeError && fetchError.message?.includes('redirect')) {
+ throw new ApiError(
+ 'Request was redirected — change your API base URL from http:// to https:// in Settings.',
+ 0,
+ { redirect: true }
+ );
+ }
+ // If fetch fails (likely CORS), try XMLHttpRequest
+ if (fetchError instanceof TypeError) {
+ logger.debug('fetch failed, trying XHR fallback');
+ return await this.doXHR(method, url, apiKey, body);
+ }
+ throw fetchError;
+ }
+ }
+
+ private async doFetch(
+ method: string,
+ url: string,
+ apiKey: string,
+ body?: unknown
+ ): Promise> {
+ const headers: Record = {
+ 'X-API-Key': apiKey,
+ 'Accept': 'application/json',
+ };
+
+ // Only set Content-Type for methods that send a body
+ if (body && method !== 'GET' && method !== 'HEAD') {
+ headers['Content-Type'] = 'application/json';
+ }
+
+ const options: RequestInit = { method, headers, redirect: 'error' };
+ if (body && method !== 'GET' && method !== 'HEAD') {
+ options.body = JSON.stringify(body);
+ }
+
+ const response = await fetch(url, options);
+
+ logger.info(`Response: ${response.status} ${(response.headers.get('content-type') || 'no-content-type')} ${response.url}`);
+
+ const contentType = response.headers.get('content-type') || '';
+ let json: any;
+
+ if (contentType.includes('text/html') || contentType.includes('text/html')) {
+ // Server returned an HTML page instead of JSON — typically a login redirect
+ // or proxy error page. Throw a clear error rather than leaking raw HTML.
+ const status = response.status;
+ const hint = status === 401 || status === 403
+ ? 'Authentication failed — check your API key.'
+ : status === 404
+ ? 'API endpoint not found — check your API base URL.'
+ : status >= 500
+ ? 'Server error — try again later.'
+ : 'Server returned an HTML page instead of JSON. Check your API base URL and key.';
+ throw new ApiError(hint, status, { html_response: true });
+ }
+
+ try {
+ if (contentType.includes('application/json')) {
+ json = await response.json();
+ } else {
+ const text = await response.text();
+ if (text.trim().startsWith(';
+ }
+
+ private doXHR(
+ method: string,
+ url: string,
+ apiKey: string,
+ body?: unknown
+ ): Promise> {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open(method, url, true);
+ xhr.setRequestHeader('X-API-Key', apiKey);
+ xhr.setRequestHeader('Accept', 'application/json');
+ if (body && method !== 'GET' && method !== 'HEAD') {
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ }
+
+ // Detect HTTP→HTTPS redirects: XHR auto-follows redirects but
+ // changes POST to GET on 301/302, which breaks sync endpoints.
+ // If the response URL differs in scheme, reject with a clear message.
+ xhr.onload = () => {
+ if (xhr.responseURL && xhr.responseURL.startsWith('https://') && url.startsWith('http://')) {
+ reject(new ApiError(
+ 'Request was redirected from HTTP to HTTPS — change your API base URL from http:// to https:// in Settings.',
+ xhr.status,
+ { redirect: true }
+ ));
+ return;
+ }
+ const responseText = xhr.responseText || '';
+ const isHtml = responseText.trim().startsWith('= 500
+ ? 'Server error — try again later.'
+ : 'Server returned an HTML page instead of JSON. Check your API base URL and key.';
+ reject(new ApiError(hint, xhr.status, { html_response: true }));
+ return;
+ }
+
+ try {
+ const json = JSON.parse(responseText);
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve(json as ApiResponse);
+ } else {
+ reject(new ApiError(
+ json.message || `HTTP ${xhr.status}`,
+ xhr.status,
+ json
+ ));
+ }
+ } catch (e) {
+ reject(new Error(`Failed to parse response: ${responseText.substring(0, 200)}`));
+ }
+ };
+ xhr.onerror = () => reject(new Error('Network request failed (XHR error)'));
+ xhr.ontimeout = () => reject(new Error('Request timed out'));
+ xhr.timeout = 30000;
+ if (body && method !== 'GET' && method !== 'HEAD') {
+ xhr.send(JSON.stringify(body));
+ } else {
+ xhr.send();
+ }
+ });
+ }
+
private delay(ms: number): Promise {
return new Promise(resolve => setTimeout(resolve, ms));
}
@@ -310,7 +641,18 @@ export class ApiClient {
body: formData,
});
- const json = await response.json();
+ let json: any;
+ const contentType = response.headers.get('content-type') || '';
+ try {
+ if (contentType.includes('application/json')) {
+ json = await response.json();
+ } else {
+ const text = await response.text();
+ json = { message: text.substring(0, 200) || `HTTP ${response.status}` };
+ }
+ } catch {
+ json = { message: `HTTP ${response.status}` };
+ }
if (!response.ok) {
throw new ApiError(json.message || `HTTP ${response.status}`, response.status, json);
diff --git a/src/core/config.ts b/src/core/config.ts
index a6f0597..2428854 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -29,7 +29,8 @@ export class Config {
}
getApiBaseUrl(): string {
- return this.get('api_base_url') || 'https://app.mstudio.ai/api/v1';
+ // Default to the user's Studio instance. Override in settings if using a different env.
+ return this.get('api_base_url') || 'https://modelslab-studio.test/api/v1';
}
setApiBaseUrl(url: string): void {
diff --git a/src/index.html b/src/index.html
index e4061da..63f84fe 100644
--- a/src/index.html
+++ b/src/index.html
@@ -2,15 +2,17 @@
-
+
+
-
+
+
Loading MStudio Sync...
-
+
diff --git a/src/index.ts b/src/index.ts
index 2ce5d17..c11ab30 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -12,24 +12,44 @@ let syncPanel: SyncPanel | null = null;
let initialized = false;
/**
- * UXP Panel lifecycle: called when panel is shown
+ * CEP: Initialize when DOM is ready.
*/
-export function panelShow(): void {
- logger.info('MStudio Sync panel shown');
- initialize();
+function start(): void {
+ try {
+ logger.info('MStudio Sync panel loaded (CEP)');
+
+ // Verify CSInterface is available
+ if (typeof (window as any).CSInterface !== 'function') {
+ logger.error('CSInterface not available — panel cannot communicate with AE');
+ showError('CSInterface not loaded. Check that CSInterface.js is present.');
+ return;
+ }
+
+ initialize();
+ } catch (e) {
+ logger.error('Fatal initialization error', e);
+ showError('Init error: ' + (e as Error).message);
+ }
}
-/**
- * UXP Panel lifecycle: called when panel is hidden
- */
-export function panelHide(): void {
- logger.info('MStudio Sync panel hidden');
+function showError(msg: string): void {
+ const app = document.getElementById('app');
+ if (app) {
+ app.innerHTML = `Error: ${msg}
`;
+ }
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', start);
+} else {
+ // DOM already parsed — init immediately
+ start();
}
async function initialize(): Promise {
const config = Config.getInstance();
+ const loading = document.getElementById('loading');
- // Only create panel instances once
if (!initialized) {
settingsPanel = new SettingsPanel();
projectSelector = new ProjectSelector();
@@ -37,18 +57,18 @@ async function initialize(): Promise {
initialized = true;
}
- // Check if API key is configured
+ // Hide loading indicator
+ if (loading) loading.classList.add('hidden');
+
if (!config.getApiKey()) {
settingsPanel!.show();
return;
}
- // Check if project is selected
if (!config.getSelectedProjectUuid()) {
projectSelector!.show();
return;
}
- // Show main sync panel
syncPanel!.show();
}
diff --git a/src/sync/asset-downloader.ts b/src/sync/asset-downloader.ts
index 310d01e..e114208 100644
--- a/src/sync/asset-downloader.ts
+++ b/src/sync/asset-downloader.ts
@@ -1,9 +1,11 @@
/**
* Downloads assets from Studio via signed URLs with progress tracking.
+ * CEP implementation — uses aeBridge for file I/O instead of require('uxp').
*/
import { ApiClient } from '../core/api-client';
import { Logger } from '../core/logger';
+import { aeBridge } from '../cep/ae-bridge';
const logger = new Logger('AssetDownloader');
@@ -22,34 +24,22 @@ export class AssetDownloader {
this.apiClient = ApiClient.getInstance();
}
- /**
- * Set progress callback.
- */
setProgressCallback(callback: (progress: DownloadProgress) => void): void {
this.onProgress = callback;
}
- /**
- * Download a file from a signed URL to a local path.
- */
async downloadToFile(url: string, outputPath: string, filename: string): Promise {
logger.info(`Downloading: ${filename}`);
const buffer = await this.apiClient.downloadFile(url);
- const file = new File([buffer], filename);
- // Write to filesystem using UXP fs API
- const fs = require('uxp').storage.localFileSystem;
- const outputFile = await fs.getFileForSaving(outputPath);
- await outputFile.write(buffer);
+ // Write to filesystem using CEP bridge
+ await aeBridge.createFile(buffer, outputPath);
logger.info(`Downloaded: ${filename} (${buffer.byteLength} bytes)`);
return outputPath;
}
- /**
- * Download multiple assets in batch with progress tracking.
- */
async downloadBatch(
items: Array<{ url: string; outputPath: string; filename: string }>
): Promise