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 + MStudio Sync + + + 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 + Hello AE + + + 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> { @@ -77,9 +67,6 @@ export class AssetDownloader { return results; } - /** - * Download frame media (image/video) from signed URLs. - */ async downloadFrameMedia( projectUuid: string, frameId: number, @@ -104,9 +91,6 @@ export class AssetDownloader { } } - /** - * Download an audio track file. - */ async downloadAudioTrack( url: string, trackId: number, diff --git a/src/sync/asset-uploader.ts b/src/sync/asset-uploader.ts index 8e8c9ef..0d92ffa 100644 --- a/src/sync/asset-uploader.ts +++ b/src/sync/asset-uploader.ts @@ -1,10 +1,11 @@ /** * Uploads local footage/rendered video to Studio with progress tracking. - * Handles the AE → Studio flow: local edits rendered to video, then uploaded. + * 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('AssetUploader'); @@ -23,18 +24,10 @@ export class AssetUploader { this.apiClient = ApiClient.getInstance(); } - /** - * Set progress callback. - */ setProgressCallback(callback: (progress: UploadProgress) => void): void { this.onProgress = callback; } - /** - * Upload a rendered frame video to Studio. - * This is the core of AE → Studio sync: rendered compositions are uploaded - * as frame videos so the web timeline shows the AE-edited version. - */ async uploadRenderedFrame( projectUuid: string, filePath: string, @@ -49,10 +42,9 @@ export class AssetUploader { phase: 'reading', }); - // Read file - const fs = require('uxp').storage.localFileSystem; - const file = await fs.getFileForOpening(filePath); - const buffer = await file.read({ format: 'binary' }); + // Read file via CEP bridge + const raw = await aeBridge.readFile(filePath); + const buffer = raw instanceof ArrayBuffer ? raw : new TextEncoder().encode(raw as string).buffer; const blob = new Blob([buffer], { type: 'video/mp4' }); this.onProgress?.({ @@ -84,9 +76,6 @@ export class AssetUploader { } } - /** - * Upload multiple rendered frames in batch. - */ async uploadBatch( projectUuid: string, files: Array<{ path: string; filename: string; category?: string }> @@ -119,17 +108,10 @@ export class AssetUploader { return results; } - /** - * Compute SHA-256 checksum of a local file. - * Used for change detection before uploading. - */ async computeLocalChecksum(filePath: string): Promise { - const fs = require('uxp').storage.localFileSystem; - const file = await fs.getFileForOpening(filePath); - const raw = await file.read({ format: 'binary' }); - const buffer = raw instanceof ArrayBuffer ? raw : new TextEncoder().encode(raw as string); + const raw = await aeBridge.readFile(filePath); + const buffer = raw instanceof ArrayBuffer ? raw : new TextEncoder().encode(raw as string).buffer; - // Use Web Crypto API for SHA-256 const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); diff --git a/src/sync/sync-engine.ts b/src/sync/sync-engine.ts index ea1e482..0c0ba83 100644 --- a/src/sync/sync-engine.ts +++ b/src/sync/sync-engine.ts @@ -26,6 +26,7 @@ import { ManifestDiffer } from './manifest-differ'; import { ConflictResolver } from './conflict-resolver'; import { AssetDownloader } from './asset-downloader'; import { AssetUploader } from './asset-uploader'; +import { aeBridge } from '../cep/ae-bridge'; import type { SyncManifest, StudioFrame, StudioAudioTrack, BatchOperation, AudioTrackBatchOperation } from '../types/studio-api'; import type { SyncProgress, SyncEvent } from '../types/sync-types'; @@ -110,7 +111,7 @@ export class SyncEngine { // 2. Create AE composition this.emitProgress('pulling', 0, 1, 'Creating AE composition...'); const totalDuration = manifest.frames.reduce((sum, f) => sum + f.duration_seconds, 0); - const comp = this.compWriter.createComposition(manifest.project, totalDuration); + const comp = await this.compWriter.createComposition(manifest.project, totalDuration); // 3. Download and import frames using manifest URLs directly const totalFrames = manifest.frames.length; @@ -124,20 +125,32 @@ export class SyncEngine { // Use manifest download_urls directly (already included from initial fetch) const mediaUrl = frame.download_urls?.video ?? frame.download_urls?.image; + logger.info(`Frame ${frame.sequence_order} (id=${frame.id}): has_video=${frame.has_video}, duration=${frame.duration_seconds}s, url=${mediaUrl ? mediaUrl.substring(0, 80) + '...' : 'NONE'}`); + if (mediaUrl) { - const tempDir = this.getTempDir(); - const type = frame.download_urls?.video ? 'video' : 'image'; - const ext = type === 'video' ? 'mp4' : 'png'; - const filename = `frame_${frame.id}_${type}.${ext}`; - const outputPath = `${tempDir}/${filename}`; - const localPath = await this.downloader.downloadToFile(mediaUrl, outputPath, filename); - if (localPath) { - footageItem = this.compWriter.getFootageMapper().importFootage(localPath, frame.id); + try { + const tempDir = await aeBridge.getTempDir(); + const type = frame.download_urls?.video ? 'video' : 'image'; + const ext = type === 'video' ? 'mp4' : 'png'; + const filename = `frame_${frame.id}_${type}.${ext}`; + const outputPath = `${tempDir}/${filename}`; + const localPath = await this.downloader.downloadToFile(mediaUrl, outputPath, filename); + logger.info(`Downloaded frame ${frame.id}: localPath=${localPath}`); + if (localPath) { + footageItem = await this.compWriter.getFootageMapper().importFootage(localPath, frame.id); + logger.info(`Imported frame ${frame.id}: footageItem=${footageItem ? 'OK' : 'NULL'}`); + } else { + logger.warn(`Frame ${frame.id}: downloadToFile returned null`); + } + } catch (e) { + logger.error(`Frame ${frame.id}: download/import failed`, e); } + } else { + logger.warn(`Frame ${frame.id}: no download URL available`); } // Apply to comp - this.compWriter.applyFrame(comp, frame, footageItem, cumulativeTime); + await this.compWriter.applyFrame(frame, footageItem, cumulativeTime); cumulativeTime += frame.duration_seconds; // Track in sync state @@ -154,13 +167,13 @@ export class SyncEngine { for (const track of manifest.audio_tracks) { if (track.download_url) { this.emitProgress('downloading', 0, 1, `Downloading audio: ${track.name}...`); - const tempDir = this.getTempDir(); + const tempDir = await aeBridge.getTempDir(); const filename = `audio_track_${track.id}.mp3`; const outputPath = `${tempDir}/${filename}`; const localPath = await this.downloader.downloadToFile(track.download_url, outputPath, filename); if (localPath) { - const audioFootage = this.compWriter.getFootageMapper().importFootage(localPath, track.id); - this.compWriter.applyAudioTrack(comp, track, audioFootage); + const audioFootage = await this.compWriter.getFootageMapper().importFootage(localPath, track.id); + await this.compWriter.applyAudioTrack(track, audioFootage); } this.stateManager.setAudioTrack(track.id, { @@ -237,7 +250,7 @@ export class SyncEngine { const manifest = await this.apiClient.getManifest(projectUuid, lastVersion, true); // 3. Read local AE state - const localState = this.compReader.readActiveComp(); + const localState = await this.compReader.readActiveComp(); // 4. Compute diff const diff = this.differ.diff(manifest, localState); @@ -363,7 +376,7 @@ export class SyncEngine { manifest: SyncManifest, frames: StudioFrame[] ): Promise { - const comp = this.getActiveComp(); + const comp = await this.getActiveComp(); if (!comp) return; for (let i = 0; i < frames.length; i++) { @@ -373,21 +386,20 @@ export class SyncEngine { let footageItem = null; const mediaUrl = frame.download_urls?.video ?? frame.download_urls?.image; if (mediaUrl) { - const tempDir = this.getTempDir(); + const tempDir = await this.getTempDir(); const type = frame.download_urls?.video ? 'video' : 'image'; const ext = type === 'video' ? 'mp4' : 'png'; const filename = `frame_${frame.id}_${type}.${ext}`; const outputPath = `${tempDir}/${filename}`; - // Use manifest URL directly instead of re-fetching signed URLs const localPath = await this.downloader.downloadToFile(mediaUrl, outputPath, filename); if (localPath) { - footageItem = this.compWriter.getFootageMapper().importFootage(localPath, frame.id); + footageItem = await this.compWriter.getFootageMapper().importFootage(localPath, frame.id); } } const existing = this.stateManager.getFrame(frame.id); if (existing) { - this.compWriter.updateFrame(comp, frame, footageItem); + await this.compWriter.updateFrame(frame, footageItem); } else { // New frame from server — compute cumulative time from preceding frames const allFrames = manifest.frames; @@ -397,7 +409,7 @@ export class SyncEngine { cumulativeTime += f.duration_seconds; } } - this.compWriter.applyFrame(comp, frame, footageItem, cumulativeTime); + await this.compWriter.applyFrame(frame, footageItem, cumulativeTime); } this.stateManager.setFrame(frame.id, { @@ -414,20 +426,20 @@ export class SyncEngine { manifest: SyncManifest, tracks: StudioAudioTrack[] ): Promise { - const comp = this.getActiveComp(); + const comp = await this.getActiveComp(); if (!comp) return; for (const track of tracks) { if (track.download_url) { - const tempDir = this.getTempDir(); + const tempDir = await this.getTempDir(); const localPath = await this.downloader.downloadAudioTrack(track.download_url, track.id, tempDir); if (localPath) { const existing = this.stateManager.getAudioTrack(track.id); if (existing) { - this.compWriter.updateAudioTrack(comp, track, existing.aeLayerIndex); + await this.compWriter.updateAudioTrack(track, existing.aeLayerIndex); } else { - const audioFootage = this.compWriter.getFootageMapper().importFootage(localPath, track.id); - this.compWriter.applyAudioTrack(comp, track, audioFootage); + const audioFootage = await this.compWriter.getFootageMapper().importFootage(localPath, track.id); + await this.compWriter.applyAudioTrack(track, audioFootage); } } } @@ -451,7 +463,7 @@ export class SyncEngine { operations: BatchOperation[], localState: any ): Promise { - const comp = this.getActiveComp(); + const comp = await this.getActiveComp(); if (!comp) return; const renderBeforePush = this.config.getRenderBeforePush(); @@ -463,10 +475,10 @@ export class SyncEngine { if (op.action === 'update' && op.id) { this.emitProgress('uploading', i + 1, operations.length, `Rendering frame ${op.id}...`); - const tempDir = this.getTempDir(); + const tempDir = await this.getTempDir(); const outputPath = `${tempDir}/render_frame_${op.id}.mp4`; - const renderResult = await this.compWriter.renderFrameToFile(comp, op.id, outputPath); + const renderResult = await this.compWriter.renderFrameToFile(op.id, outputPath); if (renderResult) { // Upload rendered video const uploadResult = await this.uploader.uploadRenderedFrame( @@ -549,16 +561,16 @@ export class SyncEngine { } private async applyServerDeletions(frameIds: number[], audioTrackIds: number[]): Promise { - const comp = this.getActiveComp(); + const comp = await this.getActiveComp(); if (!comp) return; for (const id of frameIds) { - this.compWriter.removeFrame(comp, id); + await this.compWriter.removeFrame(id); this.stateManager.removeFrame(id); } for (const id of audioTrackIds) { - this.compWriter.removeAudioTrack(comp, id); + await this.compWriter.removeAudioTrack(id); this.stateManager.removeAudioTrack(id); } } @@ -585,7 +597,7 @@ export class SyncEngine { this.emitProgress('resolving', 0, 1, `Resolving ${conflictIds.length} conflicts...`); const resolved = await this.conflictResolver.resolveAll(); - const comp = this.getActiveComp(); + const comp = await this.getActiveComp(); const projectUuid = this.config.getSelectedProjectUuid()!; const keepLocalOps: BatchOperation[] = []; @@ -595,7 +607,7 @@ export class SyncEngine { // Pull server version const serverFrame = manifest.frames.find(f => f.id === conflict.entityId); if (serverFrame && comp) { - this.compWriter.updateFrame(comp, serverFrame, null); + await this.compWriter.updateFrame(serverFrame, null); } } else if (conflict.resolution === 'keep_local') { // Build push operation so the local version gets pushed to server @@ -639,7 +651,7 @@ export class SyncEngine { this.emitProgress('resolving', 0, 1, `Resolving ${conflictIds.length} audio conflicts...`); const resolved = await this.conflictResolver.resolveAll(); - const comp = this.getActiveComp(); + const comp = await this.getActiveComp(); const projectUuid = this.config.getSelectedProjectUuid()!; const keepLocalOps: AudioTrackBatchOperation[] = []; @@ -650,7 +662,7 @@ export class SyncEngine { if (serverTrack && comp) { const entry = this.stateManager.getAudioTrack(conflict.entityId); if (entry) { - this.compWriter.updateAudioTrack(comp, serverTrack, entry.aeLayerIndex); + await this.compWriter.updateAudioTrack(serverTrack, entry.aeLayerIndex); } } } else if (conflict.resolution === 'keep_local') { @@ -672,13 +684,12 @@ export class SyncEngine { } } - private getActiveComp(): any | null { - return (globalThis as any).app?.project?.activeItem ?? null; + private async getActiveComp(): Promise { + return aeBridge.getActiveComp(); } - private getTempDir(): string { - // UXP temp directory - return require('os').tmpdir(); + private async getTempDir(): Promise { + return aeBridge.getTempDir(); } /** @@ -686,15 +697,13 @@ export class SyncEngine { */ private async cleanupTempFiles(): Promise { try { - const tempDir = this.getTempDir(); - const fs = require('uxp').storage.localFileSystem; - const folder = await fs.getFolder(tempDir); - const entries = await folder.getEntries(); + const tempDir = await this.getTempDir(); + const entries = await aeBridge.listDir(tempDir); for (const entry of entries) { if (entry.name.startsWith('frame_') || entry.name.startsWith('audio_track_') || entry.name.startsWith('render_frame_')) { try { - await entry.delete(); + await aeBridge.deleteFile(`${tempDir}/${entry.name}`); } catch { // Ignore individual file delete errors } diff --git a/src/types/cep.d.ts b/src/types/cep.d.ts new file mode 100644 index 0000000..dc6fb09 --- /dev/null +++ b/src/types/cep.d.ts @@ -0,0 +1,25 @@ +/** + * CEP CSInterface type declarations. + * CSInterface is provided globally by CSInterface.js (loaded via script tag). + */ + +interface CSInterfaceInstance { + evalScript(script: string, callback?: (result: string) => void): void; + addEventListener(type: string, listener: (event: any) => void): void; + removeEventListener(type: string, listener: (event: any) => void): void; + dispatchEvent(type: string, data?: any): void; + getSystemPath(type: number): string; + openURLInDefaultBrowser(url: string): void; + requestOpenExtension(extensionId: string, params?: string): void; + closeExtension(): void; + getScaleFactor(): number; + getCurrentApiVersion(): number; + getHostCapabilities(): string; + updateHost(appId: string, data: string): void; + getOSInformation(): string; +} + +interface Window { + CSInterface: new () => CSInterfaceInstance; + __adobe_cep__: any; +} diff --git a/src/types/studio-api.ts b/src/types/studio-api.ts index 2686e29..feef250 100644 --- a/src/types/studio-api.ts +++ b/src/types/studio-api.ts @@ -205,3 +205,86 @@ export interface ProjectListItem { created_at: string; updated_at: string; } + +// ─── Generation Types ─── + +export interface GenerationJob { + id: number; + type: 'image' | 'video' | 'voice' | 'sfx'; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + result_url?: string; + error_message?: string; + created_at: string; + updated_at: string; +} + +export interface GenerateImagePayload { + image_prompt?: string; + script?: string; + aspect_ratio?: string; + negative_prompt?: string; + seed?: number; +} + +export interface GenerateImageWithReferencesPayload { + image_prompt?: string; + reference_image_urls?: string[]; + reference_s3_keys?: string[]; + aspect_ratio?: string; + strength?: number; +} + +export interface GenerateMultiShotPayload { + prompt: string; + count?: number; + aspect_ratio?: string; +} + +export interface EditImagePayload { + edit_prompt: string; + mask_url?: string; + strength?: number; +} + +export interface GenerateVideoPayload { + motion_prompt?: string; + duration_seconds?: number; + seed?: number; +} + +export interface ExtendVideoPayload { + extend_prompt?: string; + duration_seconds?: number; +} + +export interface LipSyncPayload { + audio_url: string; + video_url: string; +} + +export interface GenerateVoicePayload { + text: string; + voice_id?: string; + speed?: number; +} + +export interface GenerateSfxPayload { + description: string; + duration_seconds?: number; +} + +export interface ExportPayload { + format?: 'mp4' | 'mov' | 'prores'; + resolution?: string; + frame_rate?: number; + include_audio?: boolean; +} + +export interface ExportJob { + id: number; + status: 'pending' | 'processing' | 'completed' | 'failed'; + download_url?: string; + progress_percent?: number; + created_at: string; + updated_at: string; +} diff --git a/src/ui/panels/settings-panel.ts b/src/ui/panels/settings-panel.ts index 204adde..987b2db 100644 --- a/src/ui/panels/settings-panel.ts +++ b/src/ui/panels/settings-panel.ts @@ -124,8 +124,9 @@ export class SettingsPanel { statusEl.style.color = 'var(--success)'; logger.info('Connection test passed', result.user); } else { - statusEl.textContent = 'Connection failed'; + statusEl.textContent = `Connection failed: ${result.error || 'Unknown error'}`; statusEl.style.color = 'var(--error)'; + logger.error('Connection test failed', result.error); } } diff --git a/src/ui/panels/sync-panel.ts b/src/ui/panels/sync-panel.ts index 569cad1..7cd1dbd 100644 --- a/src/ui/panels/sync-panel.ts +++ b/src/ui/panels/sync-panel.ts @@ -4,6 +4,7 @@ import { Config } from '../../core/config'; import { Logger } from '../../core/logger'; +import { ApiError } from '../../core/api-client'; import { SyncEngine } from '../../sync/sync-engine'; import { StatusIndicator } from '../components/status-indicator'; import { ProgressBar } from '../components/progress-bar'; @@ -139,6 +140,7 @@ export class SyncPanel { this.statusIndicator.setStatus('error', data.message); this.progressBar.hide(); this.addLogEntry(`Error: ${data.message}`); + this.addLogEntry(` Base URL: ${this.config.getApiBaseUrl()}`); }); this.syncEngine.on('sync:complete', () => { @@ -182,7 +184,14 @@ export class SyncPanel { try { await this.syncEngine.incrementalSync(); } catch (e) { - this.addLogEntry(`Sync failed: ${(e as Error).message}`); + const err = e as Error; + let detail = err.message; + if (err instanceof ApiError) { + detail = `${err.message} (HTTP ${err.status})`; + } + this.addLogEntry(`Sync failed: ${detail}`); + this.addLogEntry(` URL: ${this.config.getApiBaseUrl()}`); + this.addLogEntry(` Key: ${this.config.getApiKey()?.substring(0, 10) ?? 'NONE'}...`); } finally { btn.disabled = false; } diff --git a/uninstall.ps1 b/uninstall.ps1 new file mode 100644 index 0000000..6adfc61 --- /dev/null +++ b/uninstall.ps1 @@ -0,0 +1,28 @@ +param() + +$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 } + +Write-Host "" +Write-Host "============================================================" -ForegroundColor White +Write-Host " MStudio AE Plugin - Uninstaller (Windows)" -ForegroundColor White +Write-Host "============================================================" -ForegroundColor White +Write-Host "" + +$CepDir = Join-Path $env:APPDATA "Adobe\CEP\extensions\$BundleId" + +if (-not (Test-Path $CepDir)) { + Write-Info "Plugin not found at $CepDir" + Write-Info "Already uninstalled or never installed." + Write-Host "" + exit 0 +} + +Remove-Item $CepDir -Recurse -Force +Write-OK "Removed $CepDir" +Write-Host "" +Write-Host " Plugin uninstalled. Restart After Effects to finalize." -ForegroundColor White +Write-Host "" \ No newline at end of file diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..2af17d2 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +BUNDLE_ID="com.modelslab.mstudio.ae.sync" + +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +BOLD='\033[1m' +RESET='\033[0m' + +echo "" +echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" +echo -e "${BOLD} MStudio AE Plugin — Uninstaller (macOS / Linux)${RESET}" +echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" +echo "" + +OS="$(uname -s)" +case "$OS" in + Darwin) CEP_DIR="$HOME/Library/Application Support/Adobe/CEP/extensions/$BUNDLE_ID" ;; + *) echo -e "${RED}Unsupported OS: $OS${RESET}"; exit 1 ;; +esac + +if [ ! -d "$CEP_DIR" ]; then + echo -e "${CYAN} ℹ Plugin not found at $CEP_DIR${RESET}" + echo -e "${CYAN} ℹ Already uninstalled or never installed.${RESET}" + echo "" + exit 0 +fi + +rm -rf "$CEP_DIR" +echo -e "${GREEN} ✓ Removed $CEP_DIR${RESET}" +echo "" +echo -e "${BOLD} Plugin uninstalled. Restart After Effects to finalize.${RESET}" +echo "" \ No newline at end of file