diff --git a/CLAUDE.md b/CLAUDE.md index c561a2a..b23f3ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,18 +27,20 @@ Each collaborator works on a branch. Vit serializes the NLE timeline into domain - **AI-assisted semantic merging** — LLM resolves cross-domain conflicts (e.g., deleted clip still in color.json) - **Snapshot-based** — each commit = full timeline state - **No media storage, no database** — JSON in git only -- **CLI-first** — Resolve plugin scripts serve as in-NLE UI +- **CLI-first** — NLE plugins serve as in-NLE UI --- ## System Architecture ``` -Resolve Panel (primary) → vit-core (Python) → Git (system binary) -CLI (`vit` command) → vit-core (Python) → Git (system binary) [power users / fallback] +Resolve Panel → vit-core (Python) → Git (system binary) +Premiere Panel → vit-core (Python) → Git (system binary) +CLI (`vit`) → vit-core (Python) → Git (system binary) [power users / fallback] ``` - **Primary interface:** Resolve plugin panel (`vit_panel_launcher.py` + `vit_panel_tkinter.py`), accessed via Resolve's Scripts menu. This is what end users (editors, colorists) interact with. +- **Premiere interface:** CEP extension (`premiere_plugin/`), accessed via Window > Extensions > Vit. Node.js spawns `premiere_bridge.py`; ExtendScript handles serialize/deserialize. - **vit-core:** serializer.py, deserializer.py, json_writer.py, core.py, ai_merge.py, differ.py, cli.py - **Resolve scripts dir:** `~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Edit/` - **Fallback:** If Resolve API too limited → FCPXML + OpenTimelineIO; vit-core stays the same. @@ -63,7 +65,8 @@ CLI (`vit` command) → vit-core (Python) → Git (system binary) [power u vit/ ├── vit/ # cli.py, core.py, models.py, serializer.py, deserializer.py, │ # json_writer.py, ai_merge.py, validator.py, differ.py -├── resolve_plugin/ # vit_commit.py, vit_branch.py, vit_merge.py, vit_status.py, vit_restore.py +├── resolve_plugin/ # vit_commit.py, vit_branch.py, vit_merge.py, vit_status.py, vit_restore.py +├── premiere_plugin/ # CEP extension: HTML/CSS/JS, ExtendScript, Python bridge ├── tests/ └── docs/ # Optional top-up: JSON_SCHEMAS.md, RESOLVE_API_LIMITATIONS.md, AI_MERGE_DETAILS.md ``` @@ -117,7 +120,7 @@ These are CLI commands for power users and scripting. Most users access equivale --- -## Resolve Plugin Scripts +## NLE Plugins **Primary user interface.** The panel (`vit_panel_launcher.py` + `vit_panel_tkinter.py`) is the main way users interact with Vit — launched from Resolve's Scripts menu. It exposes commit, branch, merge, push/pull, and status as GUI actions. @@ -125,6 +128,8 @@ These are CLI commands for power users and scripting. Most users access equivale All scripts follow the pattern: add vit to path, get `resolve`/`project`/`timeline`, call into vit-core. Symlink the folder to Resolve's Edit scripts dir. +The Premiere extension lives in `premiere_plugin/`. It uses a CEP panel for UI, ExtendScript for Premiere serialization/deserialization, and a Node.js layer that spawns `premiere_bridge.py` for git/core operations over stdin/stdout JSON IPC. + --- ## AI-Powered Semantic Merging @@ -183,6 +188,6 @@ Serializer tests (mock Resolve), git wrapper tests, merge tests, validation test ## Scope Boundaries -**In scope:** Resolve serializer/deserializer, full vit CLI, domain-split JSON, AI merge (Gemini), post-merge validation, human-readable diff, asset manifest, 5 Resolve plugin scripts. +**In scope:** Resolve serializer/deserializer, Premiere CEP extension + bridge, full vit CLI, domain-split JSON, AI merge (Gemini), post-merge validation, human-readable diff, asset manifest, NLE plugin install paths. -**Out of scope:** Web UI, hosted platform, database, media storage/sync, conflict GUI, locking, real-time collab, other NLEs (fallback only), LUT versioning, auth. +**Out of scope:** Web UI, hosted platform, database, media storage/sync, conflict GUI, locking, real-time collab, NLEs beyond Resolve and Premiere, LUT versioning, auth. diff --git a/CROSS_NLE_PLAN.md b/CROSS_NLE_PLAN.md new file mode 100644 index 0000000..12ec879 --- /dev/null +++ b/CROSS_NLE_PLAN.md @@ -0,0 +1,319 @@ +# Vit Cross-NLE Refinement Plan (macOS first) + +## Context + +Vit's core Python code was built with DaVinci Resolve as the sole NLE. Now that `premiere_plugin/` exists, the shared core needs refinement so both NLEs work as first-class citizens. This plan is intentionally **macOS-first**: land the shared-core cleanup and a working Premiere install path on macOS without destabilizing existing Resolve workflows, then adapt the installer and bootstrap details for Windows in a follow-up. + +**Already NLE-agnostic (no changes needed):** `ai_merge.py`, `validator.py`, `differ.py`, `json_writer.py`, `merge_utils.py`, `models.py` + +**Needs changes in this phase:** `core.py`, `cli.py`, `install.sh`, `premiere_bridge.py`, `CLAUDE.md` + +**Explicitly deferred to a later phase:** Windows installer/registry work, Windows Premiere bootstrap behavior, and packaging/distribution cleanup beyond the existing source-tree / `~/.vit/vit-src` install path. + +--- + +## Change 1: `vit/core.py` — NLE-aware init and universal gitignore + +### 1a. Make gitignore cover both NLEs (line 53) + +Add `*.prproj` and `*.prpref` alongside existing `*.drp`. This is purely additive — a Resolve user will never have `.prproj` files, so the extra patterns are harmless. + +``` +# NLE project files (managed by the NLE, not vit) +*.drp +*.prproj +*.prpref +``` + +### 1b. Add `nle` parameter to `git_init()` (line 66) + +```python +def git_init(project_dir: str, nle: str = "resolve") -> None: +``` + +Change line 76: `config = {"version": "0.1.0", "nle": nle}` + +Default `"resolve"` preserves all existing callers (tests, CLI, Resolve plugin). + +### 1c. Add `nle` parameter to `git_clone()` (line 279) + +```python +def git_clone(url: str, dest_dir: str, nle: str = "resolve") -> None: +``` + +Change line 296 to use the parameter. Only fires when cloned repo has no config (edge case — config should always exist in a properly initialized repo). + +### 1d. Add `read_nle()` helper (new function near `find_project_root()`) + +```python +def read_nle(project_dir: str) -> str: + """Read the NLE type from .vit/config.json. Returns 'resolve' as default.""" + config_path = os.path.join(project_dir, ".vit", "config.json") + try: + with open(config_path) as f: + return json.load(f).get("nle", "resolve") + except (FileNotFoundError, json.JSONDecodeError, KeyError): + return "resolve" +``` + +--- + +## Change 2: `vit/cli.py` — macOS Premiere install commands + NLE-agnostic messages + +### 2a. Add `--nle` flag to `init` command + +```python +p_init.add_argument("--nle", choices=["resolve", "premiere"], default="resolve", + help="Target NLE (default: resolve)") +``` + +Pass through in `cmd_init()`: `git_init(project_dir, nle=args.nle)` + +### 2b. Add macOS Premiere CEP path and `cmd_install_premiere()` (new, after `cmd_install_resolve`) + +```python +if sys.platform == "darwin": + PREMIERE_CEP_DIR = os.path.expanduser("~/Library/Application Support/Adobe/CEP/extensions") +else: + PREMIERE_CEP_DIR = "" + +PREMIERE_EXTENSION_ID = "com.vit.premiere" +``` + +**`cmd_install_premiere()` — key differences from Resolve installer:** + +Unlike Resolve (which symlinks individual `.py` scripts), CEP extensions are loaded as entire directories. The installer must: + +1. **Find `premiere_plugin/` directory** — same two-location fallback as Resolve: + - `../../premiere_plugin` relative to `cli.py` + - `~/.vit/vit-src/premiere_plugin` (curl installer location) + +2. **Fail fast outside macOS** for this phase: + ```python + if sys.platform != "darwin": + print(" Premiere install is currently supported on macOS only.") + sys.exit(1) + ``` + +3. **Symlink the entire directory** into the CEP extensions folder: + ``` + ~/Library/Application Support/Adobe/CEP/extensions/com.vit.premiere → /path/to/premiere_plugin/ + ``` + +4. **Enable PlayerDebugMode** (required for unsigned extensions to load): + ```python + if sys.platform == "darwin": + import subprocess + for version in range(9, 12): # CSXS 9, 10, 11 + subprocess.run( + ["defaults", "write", f"com.adobe.CSXS.{version}", "PlayerDebugMode", "1"], + capture_output=True, + ) + ``` + +5. **Save `package_path`** to `~/.vit/package_path` (same as Resolve installer). This preserves the existing source-tree assumptions used by the symlinked plugin layout on macOS. + +**`cmd_uninstall_premiere()`:** Remove the symlink at `PREMIERE_CEP_DIR/com.vit.premiere`. If called outside macOS, print that Premiere uninstall is not yet supported there. + +### 2c. Register new subcommands (after `uninstall-resolve`) + +```python +p_install_pr = subparsers.add_parser("install-premiere", help="Install Vit extension for Adobe Premiere Pro") +p_install_pr.set_defaults(func=cmd_install_premiere) + +p_uninstall_pr = subparsers.add_parser("uninstall-premiere", help="Remove Vit extension from Adobe Premiere Pro") +p_uninstall_pr.set_defaults(func=cmd_uninstall_premiere) +``` + +### 2d. Make user messages NLE-agnostic + +- Line 565 (`cmd_clone`): `"Open the project in Resolve and relink"` → `"Open the project in your NLE and relink"` +- Line 642 (`cmd_collab_setup`): `"Open the project folder in DaVinci Resolve"` → `"Open the project folder in your NLE (Resolve or Premiere)"` + +### 2e. Add `read_nle` to imports from core + +--- + +## Change 3: `install.sh` — Add Premiere install + NLE-aware next steps + +After the existing Resolve install block (line 96-107), add: + +```bash +echo " Installing Adobe Premiere Pro extension..." +"$VIT_BIN/vit" install-premiere 2>/dev/null || true +``` + +`|| true` makes Premiere failure non-fatal (most users only have one NLE). + +Update "Next steps" (lines 111-124) to mention both NLEs: + +```bash +echo " Next steps:" +echo " 1. Restart your terminal (or run: source ~/.zshrc)" +echo " 2. Create and open your project in DaVinci Resolve or Adobe Premiere Pro" +echo " 3. Run: vit init your-project-name" +echo " For Premiere projects, add: vit init --nle premiere your-project-name" +echo " 4. Run: vit collab setup" +echo " (connect to a GitHub repo so your team can share the project)" +echo " 5. In Resolve: Workspace > Scripts > Vit" +echo " In Premiere: Window > Extensions > Vit" +echo " 6. The panel handles everything from there (save, branch, merge, push, pull)" +``` + +--- + +## Deferred: `install.ps1` and packaging + +This phase does **not** modify `install.ps1` or add `MANIFEST.in`. + +- **Windows installer work is deferred.** The Premiere CEP install path on Windows needs a different strategy (directory copy, registry changes, and bridge bootstrap validation) and should be handled as a dedicated follow-up. +- **Packaging cleanup is deferred.** The current macOS plan relies on the existing source-tree install model (`repo checkout` or `~/.vit/vit-src`) that `cmd_install_resolve()` already uses as a fallback. Making top-level plugin assets consistently available from sdists/wheels is worthwhile, but it is orthogonal to landing the shared-core and macOS CLI changes safely. + +--- + +## Change 4: `premiere_plugin/premiere_bridge.py` — Simplify init handler + +The `init` action currently does a post-hoc config patch (7 lines). With Change 1, simplify to: + +```python +elif action == "init": + from vit.core import git_init + git_init(project_dir, nle="premiere") + return {"ok": True} +``` + +--- + +## Change 5: `CLAUDE.md` — Reflect dual-NLE support + +### 5a. System Architecture + +Replace: +``` +Resolve Panel (primary) → vit-core (Python) → Git (system binary) +CLI (`vit` command) → vit-core (Python) → Git (system binary) [power users / fallback] +``` + +With: +``` +Resolve Panel → vit-core (Python) → Git (system binary) +Premiere Panel → vit-core (Python) → Git (system binary) +CLI (`vit`) → vit-core (Python) → Git (system binary) [power users / fallback] +``` + +Add Premiere bullet to the interface list: +``` +- **Premiere interface:** CEP extension (`premiere_plugin/`), accessed via Window > Extensions > Vit. + Node.js spawns `premiere_bridge.py` as subprocess; ExtendScript handles serialize/deserialize. +``` + +### 5b. Repository Structure + +Add `premiere_plugin/` alongside `resolve_plugin/`: +``` +├── resolve_plugin/ # vit_panel.py — PySide6 panel for Resolve +├── premiere_plugin/ # CEP extension — ExtendScript + Node.js + Python bridge +``` + +### 5c. Scope Boundaries + +Move "other NLEs" from out-of-scope to in-scope: +- **In scope:** Add "Adobe Premiere Pro CEP extension (serialize/deserialize via ExtendScript, git ops via premiere_bridge.py)" +- **Out of scope:** Change "other NLEs (fallback only)" → "NLEs beyond Resolve and Premiere, plus Windows-specific Premiere installation/bootstrap work" + +### 5d. Resolve Plugin Scripts section + +Rename to "NLE Plugins" or similar. Add a Premiere subsection explaining the CEP architecture (Node.js IPC, ExtendScript serialization, no direct Python serialize/deserialize). + +--- + +## Implementation Order + +1. **`core.py`** (Change 1) — foundation, all other changes depend on this +2. **`cli.py`** (Change 2) — depends on Change 1 (`git_init` signature, `read_nle`) +3. **`premiere_bridge.py`** (Change 4) — depends on Change 1 +4. **`install.sh`** (Change 3) — depends on Change 2 (`install-premiere` must exist) +5. **`CLAUDE.md`** (Change 5) — independent, can parallel with any step +6. **Tests** — run existing suite after each change, add new tests at end + +--- + +## Files Modified + +| File | Type of Change | +|------|---------------| +| `vit/core.py` | Add `nle` param to `git_init`/`git_clone`, add `read_nle()`, expand gitignore | +| `vit/cli.py` | Add macOS-only `install-premiere`/`uninstall-premiere`, `--nle` flag, NLE-agnostic messages | +| `install.sh` | Add Premiere install step, update next-steps text | +| `premiere_plugin/premiere_bridge.py` | Simplify init handler (remove 7-line workaround) | +| `CLAUDE.md` | Update architecture, repo structure, scope boundaries for dual-NLE | + +## Files NOT Modified + +- `resolve_plugin/*` — zero changes +- `install.ps1` — Windows follow-up +- `setup.py` — packaging/distribution cleanup deferred +- `MANIFEST.in` — packaging/distribution cleanup deferred +- `vit/models.py`, `vit/serializer.py`, `vit/deserializer.py` — NLE-specific by design, untouched +- `vit/ai_merge.py`, `vit/validator.py`, `vit/differ.py`, `vit/json_writer.py` — already NLE-agnostic +- most `tests/*` — existing tests pass unchanged (default `nle="resolve"`); add targeted core/CLI coverage only where behavior changed + +--- + +## New Tests + +```python +# test_core.py additions +def test_init_with_nle_premiere(): + git_init(tmpdir, nle="premiere") + config = json.load(open(os.path.join(tmpdir, ".vit", "config.json"))) + assert config["nle"] == "premiere" + +def test_init_default_nle_is_resolve(): + git_init(tmpdir) + config = json.load(open(os.path.join(tmpdir, ".vit", "config.json"))) + assert config["nle"] == "resolve" + +def test_read_nle_returns_config_value(project_dir): + assert read_nle(project_dir) == "resolve" + +def test_read_nle_missing_config_defaults(): + assert read_nle(empty_dir) == "resolve" + +def test_gitignore_contains_both_nle_patterns(): + git_init(tmpdir) + gitignore = open(os.path.join(tmpdir, ".gitignore")).read() + assert "*.drp" in gitignore + assert "*.prproj" in gitignore + assert "*.prpref" in gitignore + +# test_cli.py additions +def test_install_premiere_rejects_non_macos(...): + ... + +def test_install_premiere_uses_repo_or_vit_src_plugin_dir_on_macos(...): + ... +``` + +--- + +## Verification + +1. `python -m pytest tests/` — all existing tests pass (zero regression) +2. `vit init --nle premiere /tmp/test-pr` — config shows `"nle": "premiere"` +3. `vit init /tmp/test-resolve` — config shows `"nle": "resolve"` (default) +4. `vit install-premiere` on macOS — symlink created in `~/Library/Application Support/Adobe/CEP/extensions/` as `com.vit.premiere` +5. `defaults read com.adobe.CSXS.9 PlayerDebugMode` (and 10/11) returns `1` +6. `readlink ~/Library/Application\ Support/Adobe/CEP/extensions/com.vit.premiere` points at the source-tree `premiere_plugin/` directory +7. Bridge test from the symlinked extension tree: `echo '{"action":"init"}' | python -u ~/Library/Application\\ Support/Adobe/CEP/extensions/com.vit.premiere/premiere_bridge.py --project-dir /tmp/test` — config shows `"nle": "premiere"` without post-hoc patch +8. `vit install-resolve` — unchanged behavior +9. CLAUDE.md reflects both NLEs in architecture, scope, and repo structure + +## Follow-up Phase (Windows + packaging) + +After the macOS rollout is stable: + +1. Add Windows Premiere install/uninstall support in `vit/cli.py` +2. Validate bridge bootstrap when the CEP extension directory is copied instead of symlinked +3. Add Windows PlayerDebugMode registry writes +4. Decide whether packaging should support plugin assets from sdists/wheels, then update `setup.py` / `MANIFEST.in` accordingly diff --git a/README.md b/README.md index f0f7830..aee8397 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,280 @@ # Vit — Git for Video Editing -[![Vit Demo](https://img.youtube.com/vi/phS28hhJSP8/maxresdefault.jpg)](https://www.youtube.com/watch?v=phS28hhJSP8) +Vit brings git-style version control to video editing. Instead of versioning raw media files, Vit tracks **timeline metadata** as JSON and stores that history in Git. -Vit brings git-style version control to video editing. Instead of versioning raw media files, Vit tracks **timeline metadata** — clip placements, color grades, audio levels, effects, and markers — as lightweight JSON, using Git as the backend. +Vit is built around the idea that editors, colorists, and sound designers should be able to work in parallel on branches, then merge edit decisions the same way software teams merge code. -Collaborators (editors, colorists, sound designers) work in parallel on branches and merge changes cleanly, just like developers with code. +## Current Build Status -## How It Works +This repo currently supports: -Vit serializes your DaVinci Resolve timeline into **domain-split JSON files**: +- `vit` CLI workflows on macOS +- DaVinci Resolve integration through `resolve_plugin/` +- Adobe Premiere Pro integration through `premiere_plugin/` +- macOS-first plugin install commands: `vit install-resolve` and `vit install-premiere` + +Current constraints: + +- plugin installers expect either a source checkout of this repo or the one-line installer layout in `~/.vit/vit-src` +- Premiere support is currently documented and wired as a macOS-first path +- collaboration docs in [`docs/COLLABORATION.md`](docs/COLLABORATION.md) are still Resolve-centric + +## How Vit Works + +Vit versions **edit decisions**, not media files. + +Typical project output looks like this: + +```text +my-video-project/ +├── .git/ +├── .vit/config.json +├── timeline/ +│ ├── cuts.json +│ ├── color.json +│ ├── audio.json +│ ├── effects.json +│ ├── markers.json +│ └── metadata.json +└── assets/ +``` + +These files are domain-split so different collaborators can often work without stepping on each other: | File | Contents | Typical Owner | |------|----------|---------------| -| `cuts.json` | Clip placements, in/out points, transforms | Editor | +| `cuts.json` | Clip placements, in/out points, transforms, speed | Editor | | `color.json` | Color grading per clip | Colorist | | `audio.json` | Levels, panning | Sound Designer | | `effects.json` | Effects, transitions | Editor / VFX | | `markers.json` | Markers, notes | Anyone | | `metadata.json` | Frame rate, resolution, track counts | Rarely changed | -Different roles edit different files, so Git merges them without conflicts. When cross-domain issues arise (e.g., a deleted clip still referenced in `color.json`), an AI-powered semantic merge resolves them. +When branches touch overlapping meaning across domains, Vit can run validation and optional AI-assisted merge flows. + +## Requirements -## Installation +- Python 3.8+ +- Git +- macOS for the current installer flow +- DaVinci Resolve if you want the Resolve panel +- Adobe Premiere Pro if you want the Premiere extension -**Requirements:** Python 3.8+, Git, DaVinci Resolve (optional, for Resolve integration) +## Install Options -### One-Line Install (macOS/Linux) +### One-line install + +For the fastest setup on macOS: ```bash curl -fsSL https://raw.githubusercontent.com/LucasHJin/vit/main/install.sh | bash ``` -### Manual Install +That installer keeps a source checkout in `~/.vit/vit-src`, installs the Python package into `~/.vit/venv`, then attempts both plugin installs non-destructively. + +### Source checkout install + +If you are developing or modifying Vit itself, use an editable install from a local checkout: ```bash git clone https://github.com/LucasHJin/vit.git cd vit -pip install . -vit install-resolve # symlink plugin scripts into DaVinci Resolve +pip install -e . ``` -For the optional Qt-based GUI panel inside Resolve: +If you want to run tests locally: + +```bash +pip install -e ".[dev]" +python -m pytest tests/ +``` + +If you want the optional Qt Resolve panel dependencies: ```bash pip install ".[qt]" ``` -## Usage Guide (GUI — Primary Workflow) +## DaVinci Resolve Install Guide -The main way to use Vit is through the **panel inside DaVinci Resolve** (`Workspace → Scripts → Vit Panel`). You only need the terminal for the one-time project setup. +### Install from a source checkout -### What you need before starting +```bash +git clone https://github.com/LucasHJin/vit.git +cd vit +pip install -e . +vit install-resolve +``` -- **A shared GitHub repo** (or any Git remote) — this is how collaborators share timeline changes. Create an empty repo on GitHub first (no README, no license). -- **Your footage shared separately** — Vit tracks edit decisions, not raw video files. Share footage the way you already do (shared drive, Dropbox, server). Collaborators relink in Resolve if paths differ. -- **An initialized vit project** — run `vit init` once in Terminal to create the `.vit/` config and initial timeline snapshot. This produces the JSON metadata files (`cuts.json`, `color.json`, etc.) that Vit versions from that point on. +What this does: -> See [`docs/COLLABORATION.md`](docs/COLLABORATION.md) for the full step-by-step collaboration setup, including how to invite teammates and handle relinking footage. +- locates `resolve_plugin/` from the repo checkout or `~/.vit/vit-src` +- symlinks the required scripts into Resolve's Scripts menu directory +- saves the repo path to `~/.vit/package_path` so Resolve-side Python can import `vit` ---- +After install: -### Person who starts the project (once, in Terminal) +1. Restart DaVinci Resolve. +2. Open `Workspace > Scripts > Vit`. +3. Point the panel at your Vit project folder if prompted. + +### Start a new Resolve-tracked project ```bash -# 1. Create and enter the project folder vit init my-project cd my-project - -# 2. Connect to your shared GitHub repo -vit collab setup # paste your empty repo URL when prompted +vit collab setup ``` -Open DaVinci Resolve, load your project and timeline, then open the Vit Panel (`Workspace → Scripts → Vit Panel`) and **Save Version** to create the first snapshot. Vit serializes the timeline to JSON and commits it. Send the `vit clone …` URL that Terminal prints to your collaborators. +Then open Resolve, open the Vit panel, and use **Save Version** to create the first serialized snapshot. ---- +## Adobe Premiere Pro Install Guide -### Collaborators joining (once, in Terminal) +### Install from a source checkout ```bash -# Clone the project -vit clone https://github.com/yourname/your-repo.git -cd your-repo - -# Pull the latest timeline state -vit checkout main +git clone https://github.com/LucasHJin/vit.git +cd vit +pip install -e . +vit install-premiere ``` -Open Resolve, run **Vit Panel → Switch Branch**, choose your footage folder, and relink any offline clips. Then create your own branch: +What this does on macOS: -```bash -vit branch your-name -``` +- locates `premiere_plugin/` from the repo checkout or `~/.vit/vit-src` +- symlinks the whole CEP extension into `~/Library/Application Support/Adobe/CEP/extensions/com.vit.premiere` +- enables `PlayerDebugMode` for CSXS 9/10/11 +- saves the repo path to `~/.vit/package_path` -From here on, everything happens in the panel. +After install: ---- +1. Restart Premiere Pro. +2. Open `Window > Extensions > Vit`. +3. Point the extension at your Vit project folder if prompted. -### Daily workflow (entirely in the Resolve panel) +### Start a new Premiere-tracked project -1. **Pull** — fetch the latest changes from the team -2. **Switch Branch** — restore the timeline to your branch -3. Edit in Resolve as usual -4. **Save Version** — serialize your timeline changes and commit -5. **Push** — share your work +```bash +vit init --nle premiere my-project +cd my-project +vit collab setup +``` ---- +The bridge will create `.vit/config.json` with `"nle": "premiere"` so the project is tagged correctly from the start. -### Merging work (lead / editor) +## Collaboration Flow -In the panel: +Vit still expects Git to be the system of record for collaboration. -1. Pull to get everyone's latest commits -2. Switch to `main` (or whichever branch you merge into) -3. **Merge** → select the branch to bring in -4. Review the diff summary the panel shows; the panel uses AI to recommend a strategy when a key is set, or falls back to change-count heuristics without one -5. Push the merged result -6. Tell teammates to Pull and Switch Branch to see the merged timeline +### Project owner -For complex cross-domain conflicts (e.g., a clip deleted on one branch but color-graded on another), use `vit merge ` in Terminal — it runs the full AI-assisted resolution flow. +```bash +vit init my-project +cd my-project +vit collab setup +``` ---- +Then: -## Quick Start (CLI) +1. Open the project in your NLE. +2. Open the Vit panel/extension. +3. Save the first version. +4. Share the `vit clone ...` command printed by `vit collab setup`. -The CLI mirrors everything the panel does, useful for scripting or when outside Resolve: +### Collaborators ```bash -vit init # initialize project (required once) -vit commit -m "rough cut done" # save a version -vit branch color-grade # create a branch -vit checkout color-grade # switch to it -vit commit -m "first color pass" +vit clone https://github.com/yourname/your-repo.git +cd your-repo vit checkout main -vit merge color-grade # merge back -vit diff # see what changed -vit log # version history +vit branch your-name ``` -## Commands +Then open the project in Resolve or Premiere, relink any offline media, and work from your own branch. -| Command | Description | -|---------|-------------| -| `vit init` | Initialize a new vit project | -| `vit add` | Serialize timeline and stage changes | -| `vit commit -m "msg"` | Stage + commit | -| `vit branch ` | Create a new branch | -| `vit checkout ` | Switch branches (restores timeline in Resolve) | -| `vit merge ` | Merge a branch (with AI conflict resolution) | -| `vit diff` | Human-readable timeline diff | -| `vit log` | Formatted version history | -| `vit revert` | Undo the last commit | -| `vit push` / `vit pull` | Sync with a remote | -| `vit status` | Show project status | +Resolve-specific collaboration steps are still documented in [`docs/COLLABORATION.md`](docs/COLLABORATION.md). -## Project Structure +## CLI Quick Start +The CLI is the stable cross-NLE surface in this repo. + +```bash +vit init # initialize a Resolve-tracked project +vit init --nle premiere promo # initialize a Premiere-tracked project +vit commit -m "rough cut done" +vit branch color-grade +vit checkout color-grade +vit commit -m "first color pass" +vit checkout main +vit merge color-grade +vit diff +vit log ``` -vit/ # Core library - cli.py # CLI entry point - core.py # Git operations wrapper - serializer.py # Resolve timeline -> JSON - deserializer.py # JSON -> Resolve timeline - ai_merge.py # AI-powered conflict resolution (Gemini) - differ.py # Human-readable diff formatting - validator.py # Post-merge validation - models.py # Data models - json_writer.py # Domain-split JSON I/O - -resolve_plugin/ # DaVinci Resolve integration (primary UI) - vit_panel_launcher.py # Panel backend: all git + serialize/deserialize logic - vit_panel_tkinter.py # Tkinter panel UI (default) - vit_panel_qt.py # Qt panel UI (optional, pip install ".[qt]") - vit_commit.py # Script menu: commit - vit_branch.py # Script menu: branch - vit_merge.py # Script menu: merge - vit_status.py # Script menu: status - vit_restore.py # Script menu: restore timeline - -tests/ # Test suite -docs/ # Reference docs - COLLABORATION.md # Step-by-step multi-user setup - JSON_SCHEMAS.md # Full schema for all domain JSON files - RESOLVE_API_LIMITATIONS.md # Known Resolve API constraints - AI_MERGE_DETAILS.md # AI merge architecture and prompts + +Available commands in the current build: + +- `vit init` +- `vit add` +- `vit commit` +- `vit branch` +- `vit checkout` +- `vit merge` +- `vit diff` +- `vit log` +- `vit status` +- `vit revert` +- `vit push` +- `vit pull` +- `vit validate` +- `vit clone` +- `vit remote` +- `vit collab setup` +- `vit install-resolve` +- `vit uninstall-resolve` +- `vit install-premiere` +- `vit uninstall-premiere` + +## Repository Layout + +```text +vit/ +├── vit/ # core library and CLI +├── resolve_plugin/ # DaVinci Resolve panel and script entry points +├── premiere_plugin/ # Premiere CEP extension + Python bridge +├── tests/ # test suite +└── docs/ # reference docs ``` +Key directories: + +- `vit/`: git wrappers, serializer/deserializer, merge logic, validation, diffing +- `resolve_plugin/`: Resolve launcher, panel UIs, and script-menu entry points +- `premiere_plugin/`: CEP HTML/JS assets, ExtendScript, and `premiere_bridge.py` + ## AI Features -Vit uses the **Gemini API** (`gemini-2.5-flash`) to assist with video editing workflows that go beyond what plain Git can handle. Key uses: +Vit uses the Gemini API for optional workflow assistance: -- **Semantic merge resolution** — When a merge creates cross-domain conflicts (e.g., one branch deletes a clip while another color-grades it), the AI analyzes BASE/OURS/THEIRS states across all domain files and produces structured per-domain decisions with confidence levels -- **Interactive conflict clarification** — For ambiguous merges (low-confidence decisions), the AI presents options to the user, then resolves the final JSON based on their choices -- **Post-merge validation** — A rule-based validator catches orphaned references, overlapping clips, audio/video sync mismatches, and speed/duration inconsistencies after every merge; these issues feed into the AI prompt for smarter resolution -- **Commit message suggestions** — `vit commit` can auto-generate a descriptive message from the timeline diff using video editing terminology (e.g., "Add B-roll on V2, trim interview end point") -- **Log summaries** — `vit log --summary` produces a natural-language overview of recent commits for the team -- **Branch comparison analysis** — The Resolve panel uses AI to compare two branches and recommend a merge strategy before you commit to it -- **Commit classification** — Commits are auto-categorized as audio, video, or color changes (with a fast heuristic fallback when AI is unavailable) +- semantic merge resolution for cross-domain conflicts +- commit message suggestions +- log summaries +- branch comparison analysis -Set `GEMINI_API_KEY` in your environment or project `.env` file to enable AI features. All AI features degrade gracefully — Vit works fully without an API key, you just lose the smart merge and suggestions. +Set `GEMINI_API_KEY` in your shell or project `.env` file to enable those features. The CLI and UI degrade to non-AI behavior when the key is missing or an optional AI step fails. ## Testing ```bash +pip install -e ".[dev]" python -m pytest tests/ ``` +For a quick non-pytest syntax pass: + +```bash +python -m compileall vit tests resolve_plugin premiere_plugin/premiere_bridge.py +``` + ## License MIT diff --git a/install.sh b/install.sh index 6553521..67ff698 100755 --- a/install.sh +++ b/install.sh @@ -106,6 +106,9 @@ else } fi +echo " Installing Adobe Premiere Pro extension..." +"$VIT_BIN/vit" install-premiere 2>/dev/null || true + # ── Done ────────────────────────────────────── echo "" @@ -113,12 +116,14 @@ echo " Vit installed successfully!" echo "" echo " Next steps:" echo " 1. Restart your terminal (or run: source ~/.zshrc)" -echo " 2. Create and open your project in DaVinci Resolve" +echo " 2. Create and open your project in DaVinci Resolve or Adobe Premiere Pro" echo " 3. Run: vit init your-project-name (in your terminal)" +echo " For Premiere projects, add: vit init --nle premiere your-project-name" echo " (creates a vit tracking folder anywhere on disk — location doesn't matter)" echo " 4. Run: vit collab setup" echo " (connect to a GitHub repo so your team can share the project)" echo " 5. In Resolve: Workspace > Scripts > Vit" +echo " In Premiere: Window > Extensions > Vit" echo " (first launch will ask you to select the vit folder you just created)" echo " 6. The panel handles everything from there (save, branch, merge, push, pull)" echo "" diff --git a/premiere_plugin/.debug b/premiere_plugin/.debug new file mode 100644 index 0000000..cd97009 --- /dev/null +++ b/premiere_plugin/.debug @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/premiere_plugin/CSInterface.js b/premiere_plugin/CSInterface.js new file mode 100644 index 0000000..1d1a6cb --- /dev/null +++ b/premiere_plugin/CSInterface.js @@ -0,0 +1,130 @@ +/** + * CSInterface.js — Adobe CEP CSInterface Library (v11.2) + * + * Minimal shim providing the CSInterface API surface. The real __adobe_cep__ + * native object is injected by the CEP runtime when running inside Premiere. + * + * For the full library, download from: + * https://github.com/AdobeDocs/CEP-Resources/blob/master/CEP_11.x/CSInterface.js + * and replace this file. + * + * This shim is sufficient for production use because all methods delegate + * to __adobe_cep__ which is always present inside Premiere. + */ + +/* jshint ignore:start */ + +var SystemPath = { + USER_DATA: "userData", + COMMON_FILES: "commonFiles", + MY_DOCUMENTS: "myDocuments", + APPLICATION: "application", + EXTENSION: "extension", + HOST_APPLICATION: "hostApplication" +}; + +function CSInterface() {} + +/** + * Evaluate an ExtendScript expression in the host application. + */ +CSInterface.prototype.evalScript = function (script, callback) { + if (typeof __adobe_cep__ !== "undefined") { + var result = __adobe_cep__.evalScript(script); + if (callback && typeof callback === "function") { + callback(result); + } + } else { + // Development fallback — window.cep_node may be available + if (callback && typeof callback === "function") { + callback("EvalScript error."); + } + } +}; + +/** + * Get a system path. + */ +CSInterface.prototype.getSystemPath = function (pathType) { + if (typeof __adobe_cep__ !== "undefined") { + return __adobe_cep__.getSystemPath(pathType); + } + // Development fallback + if (pathType === SystemPath.EXTENSION) { + // Try to determine from script location + if (typeof __dirname !== "undefined") { + return __dirname; + } + return "."; + } + return ""; +}; + +/** + * Register an event listener. + */ +CSInterface.prototype.addEventListener = function (type, listener, obj) { + if (typeof __adobe_cep__ !== "undefined" && __adobe_cep__.addEventListener) { + __adobe_cep__.addEventListener(type, listener, obj); + } +}; + +/** + * Remove an event listener. + */ +CSInterface.prototype.removeEventListener = function (type, listener, obj) { + if (typeof __adobe_cep__ !== "undefined" && __adobe_cep__.removeEventListener) { + __adobe_cep__.removeEventListener(type, listener, obj); + } +}; + +/** + * Dispatch an event. + */ +CSInterface.prototype.dispatchEvent = function (event) { + if (typeof __adobe_cep__ !== "undefined" && __adobe_cep__.dispatchEvent) { + __adobe_cep__.dispatchEvent(event); + } +}; + +/** + * Close this extension. + */ +CSInterface.prototype.closeExtension = function () { + if (typeof __adobe_cep__ !== "undefined" && __adobe_cep__.closeExtension) { + __adobe_cep__.closeExtension(); + } +}; + +/** + * Get the host environment info. + */ +CSInterface.prototype.getHostEnvironment = function () { + if (typeof __adobe_cep__ !== "undefined" && __adobe_cep__.getHostEnvironment) { + var env = __adobe_cep__.getHostEnvironment(); + return typeof env === "string" ? JSON.parse(env) : env; + } + return { + appName: "PPRO", + appVersion: "99.0", + appLocale: "en_US" + }; +}; + +/** + * Request to open a URL in the default browser. + */ +CSInterface.prototype.openURLInDefaultBrowser = function (url) { + if (typeof __adobe_cep__ !== "undefined" && __adobe_cep__.openURLInDefaultBrowser) { + __adobe_cep__.openURLInDefaultBrowser(url); + } +}; + +/** + * Get the current API version. + */ +CSInterface.prototype.getCurrentApiVersion = function () { + return { major: 11, minor: 2, micro: 0 }; +}; + +/* jshint ignore:end */ diff --git a/premiere_plugin/CSXS/manifest.xml b/premiere_plugin/CSXS/manifest.xml new file mode 100644 index 0000000..a6a6df7 --- /dev/null +++ b/premiere_plugin/CSXS/manifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + ./index.html + ./jsx/host_utils.jsx + + --allow-file-access-from-files + --enable-nodejs + + + + true + + + Panel + Vit + + + 600 + 320 + + + 400 + 280 + + + + ./icons/icon_normal.png + + + + + + + diff --git a/premiere_plugin/css/panel.css b/premiere_plugin/css/panel.css new file mode 100644 index 0000000..c5528e5 --- /dev/null +++ b/premiere_plugin/css/panel.css @@ -0,0 +1,311 @@ +/* panel.css — Dark theme matching Resolve Qt panel design */ + +/* -- Reset & base -------------------------------------------------------- */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background-color: #1C1C1C; + color: #D9D9D9; + font-family: "SF Pro Display", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + font-size: 13px; + line-height: 1.4; + overflow-x: hidden; + padding: 12px; +} + +/* -- Scrollbar ----------------------------------------------------------- */ + +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: #1C1C1C; +} +::-webkit-scrollbar-thumb { + background: #464646; + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: #666; +} + +/* -- Header -------------------------------------------------------------- */ + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid #464646; +} + +.panel-title { + font-size: 16px; + font-weight: 400; + color: #D9D9D9; + letter-spacing: 0.5px; +} + +#branch-label { + font-size: 12px; + color: #FFB463; + background-color: rgba(255, 180, 99, 0.15); + padding: 2px 10px; + border-radius: 10px; + font-weight: 500; +} + +/* -- Status -------------------------------------------------------------- */ + +#status-text { + font-size: 11px; + color: #4A4A4A; + min-height: 16px; + margin-bottom: 8px; + white-space: pre-wrap; + word-break: break-word; +} + +#status-text:empty { + display: none; +} + +/* -- Sections (collapsible) ---------------------------------------------- */ + +.section { + margin-bottom: 12px; +} + +.section-header { + display: flex; + align-items: center; + cursor: pointer; + padding: 6px 0; + user-select: none; +} + +.section-header:hover { + opacity: 0.8; +} + +.section-chevron { + width: 12px; + height: 12px; + margin-right: 6px; + transition: transform 0.15s ease; + transform: rotate(90deg); + color: #4A4A4A; + font-size: 10px; + display: flex; + align-items: center; + justify-content: center; +} + +.section-title { + font-size: 11px; + font-weight: 600; + color: #4A4A4A; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.section-content { + padding: 4px 0; +} + +/* -- Inputs & buttons ---------------------------------------------------- */ + +input[type="text"], +select { + background-color: #2C2C2C; + color: #D9D9D9; + border: 1px solid #464646; + border-radius: 4px; + padding: 6px 8px; + font-size: 12px; + font-family: inherit; + outline: none; + width: 100%; +} + +input[type="text"]:focus, +select:focus { + border-color: #FFB463; +} + +input[type="text"]::placeholder { + color: #4A4A4A; +} + +select { + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%234A4A4A' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + padding-right: 24px; + cursor: pointer; +} + +button { + background-color: #FFB463; + color: #000000; + border: none; + border-radius: 4px; + padding: 6px 14px; + font-size: 12px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: background-color 0.1s; +} + +button:hover { + background-color: #FFCA8A; +} + +button:active { + background-color: #E89F4A; +} + +button:disabled { + background-color: #464646; + color: #4A4A4A; + cursor: not-allowed; +} + +button.secondary { + background-color: #2C2C2C; + color: #D9D9D9; + border: 1px solid #464646; +} + +button.secondary:hover { + background-color: #3C3C3C; +} + +button.secondary:disabled { + background-color: #2C2C2C; + color: #4A4A4A; +} + +/* -- Actions section ----------------------------------------------------- */ + +.action-row { + display: flex; + gap: 6px; + margin-bottom: 6px; + align-items: center; +} + +.action-row input[type="text"], +.action-row select { + flex: 1; + min-width: 0; +} + +.action-row button { + flex-shrink: 0; + min-width: 64px; +} + +.btn-row { + display: flex; + gap: 6px; + justify-content: flex-end; + margin-top: 8px; +} + +.btn-row button { + flex: 1; + max-width: 80px; +} + +/* -- Changes section ----------------------------------------------------- */ + +.commit-row { + display: flex; + gap: 6px; + margin-bottom: 8px; +} + +.commit-row input[type="text"] { + flex: 1; +} + +.commit-buttons { + display: flex; + gap: 6px; + margin-bottom: 8px; +} + +.commit-buttons button { + flex: 1; +} + +.change-item { + display: flex; + align-items: center; + padding: 4px 0; + gap: 8px; +} + +.change-icon { + font-size: 12px; + color: #4A4A4A; + width: 16px; + text-align: center; + flex-shrink: 0; +} + +.change-name { + font-size: 12px; + color: #4A4A4A; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.changes-empty { + font-size: 11px; + color: #4A4A4A; + padding: 4px 0; + font-style: italic; +} + +/* -- History section ----------------------------------------------------- */ + +.graph-container { + overflow-y: auto; + max-height: 300px; + background-color: #1C1C1C; + border: 1px solid #464646; + border-radius: 4px; +} + +#graph-canvas { + display: block; +} + +/* -- Log area ------------------------------------------------------------ */ + +#log-area { + display: none; /* Hidden by default, toggle with dev tools */ + background-color: #2C2C2C; + color: #4A4A4A; + font-size: 10px; + font-family: "SF Mono", "Menlo", "Consolas", monospace; + padding: 8px; + border-radius: 4px; + max-height: 150px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; + margin-top: 12px; +} diff --git a/premiere_plugin/icons/icon_normal.png b/premiere_plugin/icons/icon_normal.png new file mode 100644 index 0000000..d26faea Binary files /dev/null and b/premiere_plugin/icons/icon_normal.png differ diff --git a/premiere_plugin/index.html b/premiere_plugin/index.html new file mode 100644 index 0000000..eccc791 --- /dev/null +++ b/premiere_plugin/index.html @@ -0,0 +1,106 @@ + + + + + Vit + + + + + +
+ vit + +
+ + +
+ + +
+
+ + Actions +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + +
+
+
+ + +
+
+ + Changes +
+
+ + +
+ +
+ + +
+ + +
+ + +
+
No changes
+
+
+
+ + +
+
+ + History +
+
+
+ +
+
+
+ + +

+
+    
+    
+
+    
+    
+    
+    
+    
+
+
+
diff --git a/premiere_plugin/install_premiere.sh b/premiere_plugin/install_premiere.sh
new file mode 100755
index 0000000..d18d222
--- /dev/null
+++ b/premiere_plugin/install_premiere.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# Install Vit CEP extension for Adobe Premiere Pro.
+# Creates a symlink in the CEP extensions directory.
+
+set -euo pipefail
+
+EXTENSION_ID="com.vit.premiere"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# Determine CEP extensions directory based on platform
+if [[ "$OSTYPE" == "darwin"* ]]; then
+    CEP_DIR="$HOME/Library/Application Support/Adobe/CEP/extensions"
+elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
+    CEP_DIR="$HOME/.local/share/Adobe/CEP/extensions"
+elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then
+    CEP_DIR="$APPDATA/Adobe/CEP/extensions"
+else
+    echo "Unsupported platform: $OSTYPE"
+    exit 1
+fi
+
+TARGET="$CEP_DIR/$EXTENSION_ID"
+
+# Create extensions directory if needed
+mkdir -p "$CEP_DIR"
+
+# Remove existing symlink or directory
+if [ -L "$TARGET" ]; then
+    rm "$TARGET"
+    echo "Removed existing symlink."
+elif [ -d "$TARGET" ]; then
+    echo "Warning: $TARGET exists and is a directory. Remove it manually."
+    exit 1
+fi
+
+# Create symlink
+ln -s "$SCRIPT_DIR" "$TARGET"
+echo "Symlinked: $TARGET -> $SCRIPT_DIR"
+
+# Enable unsigned extensions for development (PlayerDebugMode)
+if [[ "$OSTYPE" == "darwin"* ]]; then
+    defaults write com.adobe.CSXS.9 PlayerDebugMode 1
+    defaults write com.adobe.CSXS.10 PlayerDebugMode 1
+    defaults write com.adobe.CSXS.11 PlayerDebugMode 1
+    echo "Enabled PlayerDebugMode for CSXS 9/10/11."
+fi
+
+echo ""
+echo "Installation complete. Restart Premiere Pro, then open:"
+echo "  Window > Extensions > Vit"
diff --git a/premiere_plugin/js/file_writer.js b/premiere_plugin/js/file_writer.js
new file mode 100644
index 0000000..486596e
--- /dev/null
+++ b/premiere_plugin/js/file_writer.js
@@ -0,0 +1,249 @@
+/**
+ * file_writer.js — Domain-split JSON file writing via Node.js.
+ *
+ * Mirrors vit/json_writer.py: writes JSON with indent=2, sort_keys=true,
+ * trailing newline. Uses Node.js fs for file I/O and crypto for SHA256.
+ */
+
+/* global require */
+var fs = require("fs");
+var path = require("path");
+var crypto = require("crypto");
+
+var FileWriter = (function () {
+
+    /**
+     * JSON.stringify with sorted keys (matching Python's sort_keys=True).
+     */
+    function jsonStringifySorted(obj, indent) {
+        return JSON.stringify(obj, function (key, value) {
+            if (value && typeof value === "object" && !Array.isArray(value)) {
+                var sorted = {};
+                Object.keys(value).sort().forEach(function (k) {
+                    sorted[k] = value[k];
+                });
+                return sorted;
+            }
+            return value;
+        }, indent);
+    }
+
+    /**
+     * Write JSON to a file with consistent formatting.
+     * Creates parent directories as needed.
+     */
+    function writeJson(filepath, data) {
+        var dir = path.dirname(filepath);
+        fs.mkdirSync(dir, { recursive: true });
+        var content = jsonStringifySorted(data, 2) + "\n";
+        fs.writeFileSync(filepath, content, "utf8");
+    }
+
+    /**
+     * Write timeline/cuts.json
+     */
+    function writeCuts(projectDir, videoTracks) {
+        writeJson(path.join(projectDir, "timeline", "cuts.json"), {
+            video_tracks: videoTracks
+        });
+    }
+
+    /**
+     * Write timeline/audio.json
+     */
+    function writeAudio(projectDir, audioTracks) {
+        writeJson(path.join(projectDir, "timeline", "audio.json"), {
+            audio_tracks: audioTracks
+        });
+    }
+
+    /**
+     * Write timeline/color.json
+     */
+    function writeColor(projectDir, grades) {
+        writeJson(path.join(projectDir, "timeline", "color.json"), {
+            grades: grades
+        });
+    }
+
+    /**
+     * Write timeline/effects.json
+     */
+    function writeEffects(projectDir, effects) {
+        writeJson(path.join(projectDir, "timeline", "effects.json"), effects || {});
+    }
+
+    /**
+     * Write timeline/markers.json
+     */
+    function writeMarkers(projectDir, markers) {
+        writeJson(path.join(projectDir, "timeline", "markers.json"), {
+            markers: markers
+        });
+    }
+
+    /**
+     * Write timeline/metadata.json
+     */
+    function writeMetadata(projectDir, metadata) {
+        writeJson(path.join(projectDir, "timeline", "metadata.json"), metadata);
+    }
+
+    /**
+     * Compute SHA256 hash of a file (first 12 hex chars).
+     * Returns "sha256:" or path-based fallback.
+     */
+    function computeMediaHash(filepath) {
+        try {
+            var hash = crypto.createHash("sha256");
+            var buffer = fs.readFileSync(filepath);
+            hash.update(buffer);
+            return "sha256:" + hash.digest("hex").substring(0, 12);
+        } catch (e) {
+            // File may not be accessible — use path-based fallback
+            var pathHash = crypto.createHash("sha256").update(filepath).digest("hex").substring(0, 12);
+            return "sha256:" + pathHash;
+        }
+    }
+
+    /**
+     * Build and write assets/manifest.json from a list of media file paths.
+     * Returns the path->hash mapping for use as media_ref in cuts.json.
+     */
+    function writeManifest(projectDir, assetPaths) {
+        var assets = {};
+        var pathToRef = {};
+
+        // Deduplicate paths
+        var uniquePaths = [];
+        var seen = {};
+        for (var i = 0; i < assetPaths.length; i++) {
+            if (!seen[assetPaths[i]]) {
+                seen[assetPaths[i]] = true;
+                uniquePaths.push(assetPaths[i]);
+            }
+        }
+
+        for (var j = 0; j < uniquePaths.length; j++) {
+            var mediaPath = uniquePaths[j];
+            var ref = computeMediaHash(mediaPath);
+            pathToRef[mediaPath] = ref;
+
+            if (!assets[ref]) {
+                var filename = path.basename(mediaPath);
+                var durationFrames = 0;
+                var codec = "unknown";
+                var resolution = "unknown";
+
+                assets[ref] = {
+                    filename: filename,
+                    original_path: mediaPath,
+                    duration_frames: durationFrames,
+                    codec: codec,
+                    resolution: resolution
+                };
+            }
+        }
+
+        writeJson(path.join(projectDir, "assets", "manifest.json"), {
+            assets: assets
+        });
+
+        return pathToRef;
+    }
+
+    /**
+     * Write all domain-split JSON files from serialized data.
+     *
+     * @param {string} projectDir - Path to vit project
+     * @param {object} data - Parsed serialized data with keys:
+     *   metadata, cuts (with video_tracks + asset_paths), audio, color, markers
+     */
+    function writeAll(projectDir, data) {
+        // Parse sub-objects (they come as JSON strings from ExtendScript)
+        var metadata = typeof data.metadata === "string" ? JSON.parse(data.metadata) : data.metadata;
+        var cuts = typeof data.cuts === "string" ? JSON.parse(data.cuts) : data.cuts;
+        var audio = typeof data.audio === "string" ? JSON.parse(data.audio) : data.audio;
+        var color = typeof data.color === "string" ? JSON.parse(data.color) : data.color;
+        var markers = typeof data.markers === "string" ? JSON.parse(data.markers) : data.markers;
+
+        // Build asset manifest and get path->ref mapping
+        var assetPaths = cuts.asset_paths || [];
+        var pathToRef = writeManifest(projectDir, assetPaths);
+
+        // Replace media_ref paths with SHA256 hashes in video tracks
+        var videoTracks = cuts.video_tracks || [];
+        for (var ti = 0; ti < videoTracks.length; ti++) {
+            var items = videoTracks[ti].items || [];
+            for (var ci = 0; ci < items.length; ci++) {
+                var item = items[ci];
+                if (item.media_ref && pathToRef[item.media_ref]) {
+                    item.media_ref = pathToRef[item.media_ref];
+                }
+            }
+        }
+
+        // Replace media_ref paths in audio tracks
+        var audioTracks = (audio.audio_tracks) || [];
+        for (var ati = 0; ati < audioTracks.length; ati++) {
+            var aItems = audioTracks[ati].items || [];
+            for (var aci = 0; aci < aItems.length; aci++) {
+                var aItem = aItems[aci];
+                if (aItem.media_ref && pathToRef[aItem.media_ref]) {
+                    aItem.media_ref = pathToRef[aItem.media_ref];
+                }
+            }
+        }
+
+        // Write all domain files
+        writeMetadata(projectDir, metadata);
+        writeCuts(projectDir, videoTracks);
+        writeAudio(projectDir, audioTracks);
+        writeColor(projectDir, (color.grades) || {});
+        writeMarkers(projectDir, (markers.markers) || []);
+        writeEffects(projectDir, {});
+    }
+
+    /**
+     * Read all domain JSON files.
+     */
+    function readAll(projectDir) {
+        function readJson(filepath) {
+            try {
+                return JSON.parse(fs.readFileSync(filepath, "utf8"));
+            } catch (e) {
+                return {};
+            }
+        }
+
+        return {
+            metadata: readJson(path.join(projectDir, "timeline", "metadata.json")),
+            cuts: readJson(path.join(projectDir, "timeline", "cuts.json")),
+            audio: readJson(path.join(projectDir, "timeline", "audio.json")),
+            color: readJson(path.join(projectDir, "timeline", "color.json")),
+            effects: readJson(path.join(projectDir, "timeline", "effects.json")),
+            markers: readJson(path.join(projectDir, "timeline", "markers.json")),
+            manifest: readJson(path.join(projectDir, "assets", "manifest.json"))
+        };
+    }
+
+    return {
+        writeJson: writeJson,
+        writeCuts: writeCuts,
+        writeAudio: writeAudio,
+        writeColor: writeColor,
+        writeEffects: writeEffects,
+        writeMarkers: writeMarkers,
+        writeMetadata: writeMetadata,
+        writeManifest: writeManifest,
+        writeAll: writeAll,
+        readAll: readAll,
+        computeMediaHash: computeMediaHash,
+        jsonStringifySorted: jsonStringifySorted
+    };
+})();
+
+// Node.js module export (no-op in CEP browser context)
+if (typeof module !== "undefined" && module.exports) {
+    module.exports = FileWriter;
+}
diff --git a/premiere_plugin/js/graph.js b/premiere_plugin/js/graph.js
new file mode 100644
index 0000000..fd387e0
--- /dev/null
+++ b/premiere_plugin/js/graph.js
@@ -0,0 +1,294 @@
+/**
+ * graph.js — Canvas-based commit graph rendering.
+ *
+ * Ported from the Qt panel's CommitGraphWidget. Renders a git-log-style
+ * graph with branch lines and commit nodes onto an HTML .
+ */
+
+var CommitGraph = (function () {
+
+    // Layout constants (matching Qt panel)
+    var ROW_HEIGHT = 42;
+    var LANE_WIDTH = 30;
+    var FIRST_LANE_X = 15;
+    var NODE_SIZE = 10;
+
+    // Colors
+    var BRANCH_COLORS = {
+        0: "#F24E1E",   // Red
+        1: "#00C851",   // Green
+        2: "#1ABCFE",   // Blue
+        3: "#E07603"    // Orange (main)
+    };
+
+    var NODE_COLOR = "#FFBA6B";
+    var NODE_OPACITY = 0.86;
+    var LINE_OPACITY = 0.25;
+    var TEXT_COLOR = "#4A4A4A";
+    var PILL_BG = "#FFB463";
+    var PILL_TEXT = "#000000";
+
+    /**
+     * Assign lanes to commits using a standard git-graph algorithm.
+     *
+     * @param {Array} commits - Array of commit objects with hash, parents, is_main_commit
+     * @returns {Object} Map of commit hash -> lane index
+     */
+    function assignLanes(commits) {
+        var lanes = {}; // hash -> lane index
+        var activeLanes = []; // array of expected hash per lane (null = free)
+
+        for (var i = 0; i < commits.length; i++) {
+            var commit = commits[i];
+            var hash = commit.hash;
+
+            // Find which lane this commit should go in
+            var myLane = -1;
+            for (var l = 0; l < activeLanes.length; l++) {
+                if (activeLanes[l] === hash) {
+                    myLane = l;
+                    break;
+                }
+            }
+
+            if (myLane === -1) {
+                // New lane needed — find first free or append
+                var found = false;
+                for (var f = 0; f < activeLanes.length; f++) {
+                    if (activeLanes[f] === null) {
+                        myLane = f;
+                        activeLanes[f] = hash;
+                        found = true;
+                        break;
+                    }
+                }
+                if (!found) {
+                    myLane = activeLanes.length;
+                    activeLanes.push(hash);
+                }
+            }
+
+            lanes[hash] = myLane;
+
+            // Update active lanes: this lane now expects the first parent
+            var parents = commit.parents || [];
+            if (parents.length > 0) {
+                activeLanes[myLane] = parents[0];
+            } else {
+                activeLanes[myLane] = null; // Root commit
+            }
+
+            // Additional parents get new lanes
+            for (var p = 1; p < parents.length; p++) {
+                var parentHash = parents[p];
+                // Check if this parent already has a lane
+                var parentLane = -1;
+                for (var pl = 0; pl < activeLanes.length; pl++) {
+                    if (activeLanes[pl] === parentHash) {
+                        parentLane = pl;
+                        break;
+                    }
+                }
+                if (parentLane === -1) {
+                    // Assign a new lane
+                    var foundFree = false;
+                    for (var ff = 0; ff < activeLanes.length; ff++) {
+                        if (activeLanes[ff] === null) {
+                            activeLanes[ff] = parentHash;
+                            foundFree = true;
+                            break;
+                        }
+                    }
+                    if (!foundFree) {
+                        activeLanes.push(parentHash);
+                    }
+                }
+            }
+        }
+
+        return lanes;
+    }
+
+    /**
+     * Render the commit graph onto a canvas.
+     *
+     * @param {HTMLCanvasElement} canvas
+     * @param {Array} commits - Commit objects from get_commit_graph
+     * @param {Object} branchColors - branch name -> color index
+     * @param {string} headHash - Current HEAD hash
+     */
+    function render(canvas, commits, branchColors, headHash) {
+        if (!commits || commits.length === 0) return;
+
+        var dpr = window.devicePixelRatio || 1;
+        var lanes = assignLanes(commits);
+
+        // Calculate max lane for canvas width
+        var maxLane = 0;
+        for (var h in lanes) {
+            if (lanes[h] > maxLane) maxLane = lanes[h];
+        }
+
+        var graphWidth = FIRST_LANE_X + (maxLane + 1) * LANE_WIDTH + 20;
+        var textOffsetX = graphWidth;
+        var totalWidth = canvas.parentElement ? canvas.parentElement.clientWidth : 320;
+        var totalHeight = commits.length * ROW_HEIGHT;
+
+        canvas.width = totalWidth * dpr;
+        canvas.height = totalHeight * dpr;
+        canvas.style.width = totalWidth + "px";
+        canvas.style.height = totalHeight + "px";
+
+        var ctx = canvas.getContext("2d");
+        ctx.scale(dpr, dpr);
+        ctx.clearRect(0, 0, totalWidth, totalHeight);
+
+        // Build hash -> row index map
+        var hashToRow = {};
+        for (var i = 0; i < commits.length; i++) {
+            hashToRow[commits[i].hash] = i;
+        }
+
+        // --- Draw connections ---
+        for (var ci = 0; ci < commits.length; ci++) {
+            var commit = commits[ci];
+            var myLane = lanes[commit.hash] || 0;
+            var myX = FIRST_LANE_X + myLane * LANE_WIDTH;
+            var myY = ci * ROW_HEIGHT + ROW_HEIGHT / 2;
+
+            var parents = commit.parents || [];
+            for (var pi = 0; pi < parents.length; pi++) {
+                var parentHash = parents[pi];
+                var parentRow = hashToRow[parentHash];
+                if (parentRow === undefined) continue;
+
+                var parentLane = lanes[parentHash] || 0;
+                var parentX = FIRST_LANE_X + parentLane * LANE_WIDTH;
+                var parentY = parentRow * ROW_HEIGHT + ROW_HEIGHT / 2;
+
+                // Get branch color for line
+                var branchName = commit.branch || "main";
+                var colorIdx = branchColors[branchName];
+                if (colorIdx === undefined) colorIdx = 3;
+                var lineColor = BRANCH_COLORS[colorIdx] || BRANCH_COLORS[3];
+
+                ctx.save();
+                ctx.strokeStyle = lineColor;
+                ctx.globalAlpha = (myLane === 0 && parentLane === 0) ? NODE_OPACITY : LINE_OPACITY;
+                ctx.lineWidth = (myLane === 0 && parentLane === 0) ? 2 : 1;
+
+                if (myLane !== 0 || parentLane !== 0) {
+                    ctx.setLineDash([2, 2]);
+                }
+
+                ctx.beginPath();
+                if (myLane === parentLane) {
+                    // Straight line
+                    ctx.moveTo(myX, myY);
+                    ctx.lineTo(parentX, parentY);
+                } else {
+                    // Curved connection
+                    var midY = myY + (parentY - myY) * 0.5;
+                    ctx.moveTo(myX, myY);
+                    ctx.bezierCurveTo(myX, midY, parentX, midY, parentX, parentY);
+                }
+                ctx.stroke();
+                ctx.restore();
+            }
+        }
+
+        // --- Draw nodes ---
+        for (var ni = 0; ni < commits.length; ni++) {
+            var nodeCommit = commits[ni];
+            var nodeLane = lanes[nodeCommit.hash] || 0;
+            var nodeX = FIRST_LANE_X + nodeLane * LANE_WIDTH;
+            var nodeY = ni * ROW_HEIGHT + ROW_HEIGHT / 2;
+            var isHead = nodeCommit.is_head || nodeCommit.hash === headHash;
+
+            // Node circle
+            ctx.save();
+            ctx.fillStyle = NODE_COLOR;
+            ctx.globalAlpha = NODE_OPACITY;
+
+            if (isHead) {
+                // Ring node (outline only)
+                ctx.beginPath();
+                ctx.arc(nodeX, nodeY, NODE_SIZE / 2, 0, Math.PI * 2);
+                ctx.fill();
+                // Inner cutout
+                ctx.globalCompositeOperation = "destination-out";
+                ctx.beginPath();
+                ctx.arc(nodeX, nodeY, NODE_SIZE / 2 - 2, 0, Math.PI * 2);
+                ctx.fill();
+                ctx.globalCompositeOperation = "source-over";
+            } else {
+                // Filled circle
+                ctx.beginPath();
+                ctx.arc(nodeX, nodeY, NODE_SIZE / 2, 0, Math.PI * 2);
+                ctx.fill();
+            }
+            ctx.restore();
+
+            // --- Draw text ---
+            var textX = textOffsetX;
+            ctx.save();
+
+            // Branch pill for HEAD
+            if (isHead && nodeCommit.branch) {
+                ctx.font = "bold 10px 'SF Pro Display', 'Segoe UI', sans-serif";
+                var pillText = nodeCommit.branch;
+                var pillWidth = ctx.measureText(pillText).width + 12;
+                var pillHeight = 16;
+                var pillY = nodeY - pillHeight / 2;
+
+                ctx.fillStyle = PILL_BG;
+                ctx.globalAlpha = 1;
+                roundRect(ctx, textX, pillY, pillWidth, pillHeight, 3);
+                ctx.fill();
+
+                ctx.fillStyle = PILL_TEXT;
+                ctx.fillText(pillText, textX + 6, nodeY + 4);
+                textX += pillWidth + 8;
+            }
+
+            // Commit message
+            ctx.font = "11px 'SF Pro Display', 'Segoe UI', sans-serif";
+            ctx.fillStyle = TEXT_COLOR;
+            ctx.globalAlpha = 1;
+
+            var maxTextWidth = totalWidth - textX - 10;
+            var message = nodeCommit.message || "";
+            if (ctx.measureText(message).width > maxTextWidth) {
+                while (message.length > 3 && ctx.measureText(message + "...").width > maxTextWidth) {
+                    message = message.substring(0, message.length - 1);
+                }
+                message += "...";
+            }
+            ctx.fillText(message, textX, nodeY + 4);
+
+            ctx.restore();
+        }
+    }
+
+    /**
+     * Draw a rounded rectangle path.
+     */
+    function roundRect(ctx, x, y, w, h, r) {
+        ctx.beginPath();
+        ctx.moveTo(x + r, y);
+        ctx.lineTo(x + w - r, y);
+        ctx.arcTo(x + w, y, x + w, y + r, r);
+        ctx.lineTo(x + w, y + h - r);
+        ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
+        ctx.lineTo(x + r, y + h);
+        ctx.arcTo(x, y + h, x, y + h - r, r);
+        ctx.lineTo(x, y + r);
+        ctx.arcTo(x, y, x + r, y, r);
+        ctx.closePath();
+    }
+
+    return {
+        render: render,
+        ROW_HEIGHT: ROW_HEIGHT
+    };
+})();
diff --git a/premiere_plugin/js/ipc.js b/premiere_plugin/js/ipc.js
new file mode 100644
index 0000000..0c52d0b
--- /dev/null
+++ b/premiere_plugin/js/ipc.js
@@ -0,0 +1,191 @@
+/**
+ * ipc.js — Node.js <-> Python subprocess communication.
+ *
+ * Spawns premiere_bridge.py as a child process and communicates via
+ * newline-delimited JSON over stdin/stdout.
+ */
+
+/* global require */
+var child_process = require("child_process");
+var path = require("path");
+var os = require("os");
+
+var VitIPC = (function () {
+    var _process = null;
+    var _buffer = "";
+    var _pendingResolve = null;
+    var _onLog = null;
+
+    /**
+     * Find the system Python 3 binary.
+     */
+    function findPython() {
+        var candidates = [];
+        var platform = os.platform();
+        var homeDir = os.homedir();
+
+        // Check vit installer venv first
+        var vitVenvBin = platform === "win32" ? "Scripts\\python.exe" : "bin/python3";
+        var vitVenvPy = path.join(homeDir, ".vit", "venv", vitVenvBin);
+        candidates.push(vitVenvPy);
+
+        if (platform === "win32") {
+            candidates.push("py");
+            candidates.push("python3");
+            candidates.push("python");
+        } else {
+            candidates.push("/usr/local/bin/python3");
+            candidates.push("/opt/homebrew/bin/python3");
+            candidates.push("/usr/bin/python3");
+            candidates.push(path.join(homeDir, ".pyenv", "shims", "python3"));
+            candidates.push("python3");
+            candidates.push("python");
+        }
+
+        for (var i = 0; i < candidates.length; i++) {
+            try {
+                var result = child_process.spawnSync(candidates[i], ["--version"], {
+                    timeout: 5000,
+                    stdio: "pipe"
+                });
+                if (result.status === 0) {
+                    return candidates[i];
+                }
+            } catch (e) {
+                // skip
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Start the Python vit-core subprocess.
+     * @param {string} projectDir - Path to the vit project directory
+     * @param {function} logCallback - Optional callback for log messages
+     */
+    function start(projectDir, logCallback) {
+        if (_process) return;
+        _onLog = logCallback || function () {};
+
+        var python = findPython();
+        if (!python) {
+            _onLog("ERROR: Python 3 not found. Install Python 3 and ensure it's on PATH.");
+            return;
+        }
+
+        var bridgeScript = path.join(__dirname, "..", "premiere_bridge.py");
+
+        _onLog("Starting vit bridge: " + python + " " + bridgeScript);
+
+        _process = child_process.spawn(python, ["-u", bridgeScript, "--project-dir", projectDir], {
+            stdio: ["pipe", "pipe", "pipe"],
+            env: Object.assign({}, process.env, { PYTHONUNBUFFERED: "1" })
+        });
+
+        _process.stdout.on("data", function (data) {
+            _buffer += data.toString();
+            // Process complete JSON messages
+            var lines = _buffer.split("\n");
+            _buffer = lines.pop(); // Keep incomplete last line in buffer
+
+            for (var i = 0; i < lines.length; i++) {
+                var line = lines[i].trim();
+                if (!line) continue;
+                try {
+                    var response = JSON.parse(line);
+                    if (_pendingResolve) {
+                        var resolve = _pendingResolve;
+                        _pendingResolve = null;
+                        resolve(response);
+                    }
+                } catch (e) {
+                    _onLog("Bad JSON from bridge: " + line);
+                }
+            }
+        });
+
+        _process.stderr.on("data", function (data) {
+            _onLog(data.toString().trim());
+        });
+
+        _process.on("exit", function (code) {
+            _onLog("Bridge exited with code " + code);
+            _process = null;
+        });
+
+        _process.on("error", function (err) {
+            _onLog("Bridge error: " + err.message);
+            _process = null;
+        });
+    }
+
+    /**
+     * Send a request to the Python bridge.
+     * @param {string} action - The action name
+     * @param {object} params - Additional parameters
+     * @returns {Promise} Resolves with the response object
+     */
+    function sendRequest(action, params) {
+        return new Promise(function (resolve, reject) {
+            if (!_process || !_process.stdin.writable) {
+                reject(new Error("Bridge not running"));
+                return;
+            }
+
+            var request = Object.assign({ action: action }, params || {});
+            _pendingResolve = resolve;
+
+            try {
+                _process.stdin.write(JSON.stringify(request) + "\n");
+            } catch (e) {
+                _pendingResolve = null;
+                reject(e);
+            }
+
+            // Timeout after 30 seconds
+            setTimeout(function () {
+                if (_pendingResolve === resolve) {
+                    _pendingResolve = null;
+                    reject(new Error("Request timed out: " + action));
+                }
+            }, 30000);
+        });
+    }
+
+    /**
+     * Stop the Python subprocess.
+     */
+    function stop() {
+        if (_process) {
+            try {
+                _process.stdin.write(JSON.stringify({ action: "quit" }) + "\n");
+            } catch (e) {}
+            setTimeout(function () {
+                if (_process) {
+                    _process.kill();
+                    _process = null;
+                }
+            }, 2000);
+        }
+    }
+
+    /**
+     * Check if the bridge is running.
+     */
+    function isRunning() {
+        return _process !== null;
+    }
+
+    return {
+        start: start,
+        sendRequest: sendRequest,
+        stop: stop,
+        isRunning: isRunning,
+        findPython: findPython
+    };
+})();
+
+// Node.js module export (no-op in CEP browser context)
+if (typeof module !== "undefined" && module.exports) {
+    module.exports = VitIPC;
+}
diff --git a/premiere_plugin/js/main.js b/premiere_plugin/js/main.js
new file mode 100644
index 0000000..3fe6f07
--- /dev/null
+++ b/premiere_plugin/js/main.js
@@ -0,0 +1,554 @@
+/**
+ * main.js — Panel logic, UI events, and CSInterface bridge.
+ *
+ * This is the main entry point for the CEP panel. It:
+ *   1. Sets up CSInterface for ExtendScript communication
+ *   2. Starts the Python IPC bridge
+ *   3. Handles all UI events and orchestrates the serialize->write->commit flow
+ */
+
+/* global CSInterface, SystemPath, VitIPC, FileWriter, CommitGraph */
+
+(function () {
+    "use strict";
+
+    var csInterface = new CSInterface();
+    var projectDir = "";
+
+    // --- Initialization ---
+
+    function init() {
+        log("Vit panel loading...");
+
+        // Load ExtendScript files
+        // host_utils.jsx is auto-loaded via manifest ScriptPath;
+        // serializer + deserializer must be loaded explicitly.
+        var extPath = csInterface.getSystemPath(SystemPath.EXTENSION);
+        // Replace backslashes with forward slashes for ExtendScript compatibility
+        var jsxPath = extPath.replace(/\\/g, "/");
+        evalScript('$.evalFile("' + jsxPath + '/jsx/serializer.jsx")');
+        evalScript('$.evalFile("' + jsxPath + '/jsx/deserializer.jsx")');
+
+        // Determine project directory
+        // Use the Premiere project file location as the base
+        evalScript("app.project.path", function (projectPath) {
+            if (projectPath && projectPath !== "undefined" && projectPath !== "") {
+                var path = require("path");
+                var fs = require("fs");
+
+                // Project dir is the directory containing the .prproj file
+                var dir = path.dirname(projectPath);
+
+                // Look for existing .vit directory
+                var current = dir;
+                while (current) {
+                    if (fs.existsSync(path.join(current, ".vit"))) {
+                        projectDir = current;
+                        break;
+                    }
+                    var parent = path.dirname(current);
+                    if (parent === current) break;
+                    current = parent;
+                }
+
+                if (!projectDir) {
+                    // Default to project file directory
+                    projectDir = dir;
+                }
+
+                log("Project dir: " + projectDir);
+                startBridge();
+            } else {
+                log("No project open. Save your project first.");
+                setStatus("Save your Premiere project first, then reopen this panel.");
+            }
+        });
+
+        // Bind UI events
+        bindEvents();
+    }
+
+    // --- CSInterface helpers ---
+
+    function evalScript(script, callback) {
+        csInterface.evalScript(script, callback || function () {});
+    }
+
+    // --- Python bridge ---
+
+    function startBridge() {
+        VitIPC.start(projectDir, function (msg) {
+            log(msg);
+        });
+
+        // Ping to verify connection
+        setTimeout(function () {
+            VitIPC.sendRequest("ping").then(function (resp) {
+                if (resp.ok) {
+                    log("Bridge connected.");
+                    refreshBranch();
+                    refreshChanges();
+                }
+            }).catch(function (err) {
+                log("Bridge ping failed: " + err.message);
+                // May need to init the project first
+                checkOrInitProject();
+            });
+        }, 1000);
+    }
+
+    function checkOrInitProject() {
+        var fs = require("fs");
+        var vitDir = require("path").join(projectDir, ".vit");
+        if (!fs.existsSync(vitDir)) {
+            log("No vit project found. Initializing...");
+            VitIPC.sendRequest("init").then(function (resp) {
+                if (resp.ok) {
+                    log("Initialized vit project.");
+                    setStatus("Initialized new vit project.");
+                    refreshBranch();
+                } else {
+                    setStatus("Init failed: " + (resp.error || "unknown"));
+                }
+            }).catch(function (err) {
+                setStatus("Init failed: " + err.message);
+            });
+        }
+    }
+
+    // --- UI state ---
+
+    function setStatus(text) {
+        var el = document.getElementById("status-text");
+        if (el) el.textContent = text;
+    }
+
+    function setBranch(name) {
+        var el = document.getElementById("branch-label");
+        if (el) el.textContent = name;
+    }
+
+    function log(msg) {
+        console.log("[vit] " + msg);
+        var logEl = document.getElementById("log-area");
+        if (logEl) {
+            logEl.textContent += msg + "\n";
+            logEl.scrollTop = logEl.scrollHeight;
+        }
+    }
+
+    function setLoading(loading) {
+        var btns = document.querySelectorAll("button");
+        for (var i = 0; i < btns.length; i++) {
+            btns[i].disabled = loading;
+        }
+    }
+
+    // --- Actions ---
+
+    function refreshBranch() {
+        VitIPC.sendRequest("get_branch").then(function (resp) {
+            if (resp.ok) {
+                setBranch(resp.branch);
+            }
+        }).catch(function () {});
+
+        // Also refresh branch dropdowns
+        VitIPC.sendRequest("list_branches").then(function (resp) {
+            if (resp.ok) {
+                populateBranchDropdowns(resp.branches, resp.current);
+            }
+        }).catch(function () {});
+    }
+
+    function populateBranchDropdowns(branches, current) {
+        var switchSelect = document.getElementById("switch-branch-select");
+        var mergeSelect = document.getElementById("merge-branch-select");
+
+        [switchSelect, mergeSelect].forEach(function (select) {
+            if (!select) return;
+            select.innerHTML = "";
+            branches.forEach(function (b) {
+                var opt = document.createElement("option");
+                opt.value = b;
+                opt.textContent = b;
+                if (b === current) opt.selected = true;
+                select.appendChild(opt);
+            });
+        });
+    }
+
+    function refreshChanges() {
+        // Serialize current state, then ask bridge for changes
+        setStatus("Checking changes...");
+
+        evalScript("serializeTimeline()", function (result) {
+            if (!result || result === "undefined" || result === "EvalScript error.") {
+                setStatus("No active sequence.");
+                return;
+            }
+
+            try {
+                var data = JSON.parse(result);
+
+                // Write domain files via Node.js
+                FileWriter.writeAll(projectDir, data);
+
+                // Get changes from bridge
+                VitIPC.sendRequest("get_changes").then(function (resp) {
+                    if (resp.ok) {
+                        displayChanges(resp.changes);
+                        setStatus("");
+                    }
+                }).catch(function () {
+                    setStatus("");
+                });
+            } catch (e) {
+                log("Serialize error: " + e.message);
+                setStatus("Serialize error.");
+            }
+        });
+    }
+
+    function displayChanges(changes) {
+        var listEl = document.getElementById("changes-list");
+        if (!listEl) return;
+        listEl.innerHTML = "";
+
+        var categories = ["video", "audio", "color"];
+        var icons = {
+            video: "▶",  // play triangle
+            audio: "♪",  // music note
+            color: "◉"   // circle
+        };
+        var hasChanges = false;
+
+        categories.forEach(function (cat) {
+            var items = changes[cat] || [];
+            items.forEach(function (item) {
+                hasChanges = true;
+                var div = document.createElement("div");
+                div.className = "change-item";
+                div.innerHTML = '' + icons[cat] + '' +
+                    '' + escapeHtml(item) + '';
+                listEl.appendChild(div);
+            });
+        });
+
+        if (!hasChanges) {
+            var empty = document.createElement("div");
+            empty.className = "changes-empty";
+            empty.textContent = "No changes";
+            listEl.appendChild(empty);
+        }
+    }
+
+    function saveVersion() {
+        var msgInput = document.getElementById("commit-message");
+        var message = msgInput ? msgInput.value.trim() : "";
+        if (!message) message = "save version";
+
+        setLoading(true);
+        setStatus("Serializing...");
+
+        evalScript("serializeTimeline()", function (result) {
+            if (!result || result === "undefined" || result === "EvalScript error.") {
+                setStatus("No active sequence.");
+                setLoading(false);
+                return;
+            }
+
+            try {
+                var data = JSON.parse(result);
+                setStatus("Writing files...");
+                FileWriter.writeAll(projectDir, data);
+
+                setStatus("Committing...");
+                VitIPC.sendRequest("save", { message: message }).then(function (resp) {
+                    setLoading(false);
+                    if (resp.ok) {
+                        var statusMsg = resp.hash
+                            ? "Committed: " + resp.hash + " — " + resp.message
+                            : resp.message || "Saved.";
+                        setStatus(statusMsg);
+                        if (msgInput) msgInput.value = "";
+                        refreshChanges();
+                        refreshHistory();
+                    } else {
+                        setStatus("Error: " + (resp.error || "unknown"));
+                    }
+                }).catch(function (err) {
+                    setLoading(false);
+                    setStatus("Error: " + err.message);
+                });
+            } catch (e) {
+                setLoading(false);
+                setStatus("Serialize error: " + e.message);
+            }
+        });
+    }
+
+    function createBranch() {
+        var input = document.getElementById("new-branch-input");
+        var name = input ? input.value.trim() : "";
+        if (!name) {
+            setStatus("Enter a branch name.");
+            return;
+        }
+
+        setLoading(true);
+        VitIPC.sendRequest("new_branch", { name: name }).then(function (resp) {
+            setLoading(false);
+            if (resp.ok) {
+                setStatus("Created branch: " + resp.branch);
+                if (input) input.value = "";
+                refreshBranch();
+            } else {
+                setStatus("Error: " + (resp.error || "unknown"));
+            }
+        }).catch(function (err) {
+            setLoading(false);
+            setStatus("Error: " + err.message);
+        });
+    }
+
+    function switchBranch() {
+        var select = document.getElementById("switch-branch-select");
+        var target = select ? select.value : "";
+        if (!target) return;
+
+        setLoading(true);
+        setStatus("Switching to " + target + "...");
+
+        VitIPC.sendRequest("switch_branch", { branch: target }).then(function (resp) {
+            if (resp.ok) {
+                setBranch(resp.branch);
+                setStatus("Switched to: " + resp.branch + ". Restoring timeline...");
+
+                // Read the domain files and deserialize into Premiere
+                var data = FileWriter.readAll(projectDir);
+                var jsonStr = JSON.stringify(data);
+                evalScript('deserializeTimeline(' + quote(jsonStr) + ')', function (result) {
+                    setLoading(false);
+                    try {
+                        var r = JSON.parse(result);
+                        if (r.ok) {
+                            setStatus("Restored timeline from: " + resp.branch);
+                        } else {
+                            setStatus("Restore warning: " + (r.error || "partial"));
+                        }
+                    } catch (e) {
+                        setStatus("Switched to: " + resp.branch);
+                    }
+                    refreshChanges();
+                    refreshHistory();
+                });
+            } else {
+                setLoading(false);
+                setStatus("Error: " + (resp.error || "unknown"));
+            }
+        }).catch(function (err) {
+            setLoading(false);
+            setStatus("Error: " + err.message);
+        });
+    }
+
+    function mergeBranch() {
+        var select = document.getElementById("merge-branch-select");
+        var target = select ? select.value : "";
+        if (!target) return;
+
+        setLoading(true);
+        setStatus("Merging " + target + "...");
+
+        // Serialize first (auto-save if dirty)
+        evalScript("serializeTimeline()", function (result) {
+            if (result && result !== "undefined" && result !== "EvalScript error.") {
+                try {
+                    var data = JSON.parse(result);
+                    FileWriter.writeAll(projectDir, data);
+                } catch (e) {}
+            }
+
+            VitIPC.sendRequest("merge", { branch: target }).then(function (resp) {
+                if (resp.ok) {
+                    var msg = "Merged " + resp.branch + " into " + resp.current;
+                    if (resp.issues) msg += "\nIssues:\n" + resp.issues;
+                    setStatus(msg);
+
+                    // Deserialize merged state
+                    var mergedData = FileWriter.readAll(projectDir);
+                    var jsonStr = JSON.stringify(mergedData);
+                    evalScript('deserializeTimeline(' + quote(jsonStr) + ')', function () {
+                        setLoading(false);
+                        refreshBranch();
+                        refreshChanges();
+                        refreshHistory();
+                    });
+                } else {
+                    setLoading(false);
+                    setStatus("Merge failed: " + (resp.error || "unknown"));
+                }
+            }).catch(function (err) {
+                setLoading(false);
+                setStatus("Merge error: " + err.message);
+            });
+        });
+    }
+
+    function pushChanges() {
+        setLoading(true);
+        setStatus("Pushing...");
+        VitIPC.sendRequest("push").then(function (resp) {
+            setLoading(false);
+            if (resp.ok) {
+                setStatus("Pushed " + resp.branch + ". " + (resp.output || ""));
+            } else {
+                setStatus("Push failed: " + (resp.error || "unknown"));
+            }
+        }).catch(function (err) {
+            setLoading(false);
+            setStatus("Push error: " + err.message);
+        });
+    }
+
+    function pullChanges() {
+        setLoading(true);
+        setStatus("Pulling...");
+        VitIPC.sendRequest("pull").then(function (resp) {
+            if (resp.ok) {
+                setStatus("Pulled " + resp.branch + ". Restoring...");
+
+                var data = FileWriter.readAll(projectDir);
+                var jsonStr = JSON.stringify(data);
+                evalScript('deserializeTimeline(' + quote(jsonStr) + ')', function () {
+                    setLoading(false);
+                    setStatus("Pulled and restored: " + resp.branch);
+                    refreshChanges();
+                    refreshHistory();
+                });
+            } else {
+                setLoading(false);
+                setStatus("Pull failed: " + (resp.error || "unknown"));
+            }
+        }).catch(function (err) {
+            setLoading(false);
+            setStatus("Pull error: " + err.message);
+        });
+    }
+
+    function showStatus() {
+        VitIPC.sendRequest("status").then(function (resp) {
+            if (resp.ok) {
+                setStatus(resp.branch + ": " + resp.status);
+                log("Branch: " + resp.branch + "\n" + resp.status + "\n" + resp.log);
+            } else {
+                setStatus("Status error: " + (resp.error || "unknown"));
+            }
+        }).catch(function (err) {
+            setStatus("Status error: " + err.message);
+        });
+    }
+
+    function refreshHistory() {
+        VitIPC.sendRequest("get_commit_graph", { limit: 30 }).then(function (resp) {
+            if (resp.ok) {
+                var canvas = document.getElementById("graph-canvas");
+                if (canvas) {
+                    CommitGraph.render(canvas, resp.commits, resp.branch_colors, resp.head);
+                }
+            }
+        }).catch(function () {});
+    }
+
+    // --- Collapsible sections ---
+
+    function toggleSection(sectionId) {
+        var content = document.getElementById(sectionId + "-content");
+        var chevron = document.getElementById(sectionId + "-chevron");
+        if (!content) return;
+
+        var isHidden = content.style.display === "none";
+        content.style.display = isHidden ? "block" : "none";
+        if (chevron) {
+            chevron.style.transform = isHidden ? "rotate(90deg)" : "rotate(0deg)";
+        }
+    }
+
+    // --- Event binding ---
+
+    function bindEvents() {
+        // Commit
+        on("commit-btn", "click", saveVersion);
+        on("refresh-btn", "click", refreshChanges);
+
+        // Branches
+        on("create-branch-btn", "click", createBranch);
+        on("switch-branch-btn", "click", switchBranch);
+        on("merge-branch-btn", "click", mergeBranch);
+
+        // Push/Pull/Status
+        on("push-btn", "click", pushChanges);
+        on("pull-btn", "click", pullChanges);
+        on("status-btn", "click", showStatus);
+
+        // Collapsible sections
+        on("actions-header", "click", function () { toggleSection("actions"); });
+        on("changes-header", "click", function () { toggleSection("changes"); });
+        on("history-header", "click", function () { toggleSection("history"); });
+
+        // Enter key in commit message
+        var msgInput = document.getElementById("commit-message");
+        if (msgInput) {
+            msgInput.addEventListener("keydown", function (e) {
+                if (e.key === "Enter" && !e.shiftKey) {
+                    e.preventDefault();
+                    saveVersion();
+                }
+            });
+        }
+
+        // Enter key in new branch input
+        var branchInput = document.getElementById("new-branch-input");
+        if (branchInput) {
+            branchInput.addEventListener("keydown", function (e) {
+                if (e.key === "Enter") {
+                    e.preventDefault();
+                    createBranch();
+                }
+            });
+        }
+    }
+
+    function on(id, event, handler) {
+        var el = document.getElementById(id);
+        if (el) el.addEventListener(event, handler);
+    }
+
+    // --- Helpers ---
+
+    function escapeHtml(str) {
+        return String(str)
+            .replace(/&/g, "&")
+            .replace(//g, ">")
+            .replace(/"/g, """);
+    }
+
+    function quote(str) {
+        return "'" + str.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'";
+    }
+
+    // --- Panel lifecycle ---
+
+    // Clean up on panel close
+    csInterface.addEventListener("com.adobe.csxs.events.WindowVisibilityChanged", function (event) {
+        if (event.data === "false") {
+            VitIPC.stop();
+        }
+    });
+
+    // Start
+    init();
+
+})();
diff --git a/premiere_plugin/jsx/deserializer.jsx b/premiere_plugin/jsx/deserializer.jsx
new file mode 100644
index 0000000..91e326d
--- /dev/null
+++ b/premiere_plugin/jsx/deserializer.jsx
@@ -0,0 +1,537 @@
+/**
+ * deserializer.jsx — Restore a Premiere Pro timeline from vit JSON.
+ *
+ * Called from the JS panel via CSInterface.evalScript().
+ * Each function takes a JSON string argument, parses it, and applies changes.
+ *
+ * Depends on: host_utils.jsx (loaded via manifest ScriptPath).
+ */
+
+// Load host_utils if not already loaded
+if (typeof TICKS_PER_SECOND === "undefined") {
+    var scriptDir = (new File($.fileName)).parent.fsName;
+    $.evalFile(scriptDir + "/host_utils.jsx");
+}
+
+
+// Reverse mapping: vit composite_mode int -> Premiere blend mode index
+var VIT_TO_PREMIERE_BLEND = {
+    0: 1,     // Normal
+    1: 11,    // Add -> Linear Dodge
+    2: 22,    // Subtract
+    3: 20,    // Difference
+    4: 4,     // Multiply
+    5: 9,     // Screen
+    6: 13,    // Overlay
+    7: 15,    // Hard Light
+    8: 14,    // Soft Light
+    9: 3,     // Darken
+    10: 8,    // Lighten
+    11: 10,   // Color Dodge
+    12: 5,    // Color Burn
+    13: 21,   // Exclusion
+    14: 24,   // Hue
+    15: 25,   // Saturation
+    16: 26,   // Color
+    18: 23,   // Divide
+    19: 11,   // Linear Dodge (Add)
+    20: 6,    // Linear Burn
+    21: 17,   // Linear Light
+    22: 16,   // Vivid Light
+    23: 18,   // Pin Light
+    24: 19,   // Hard Mix
+    25: 12,   // Lighter Color
+    26: 7,    // Darker Color
+    30: 27    // Luminosity
+};
+
+
+/**
+ * Rename existing timeline to avoid conflicts.
+ * Returns the old timeline name.
+ */
+function renameOldTimeline() {
+    var seq = app.project.activeSequence;
+    if (!seq) return "";
+    var oldName = seq.name;
+    seq.name = oldName + ".vit-old";
+    return oldName;
+}
+
+
+/**
+ * Deserialize and rebuild the timeline from JSON domain data.
+ *
+ * Strategy:
+ *   1. Rename current timeline to .vit-old
+ *   2. Create a new sequence with the original name
+ *   3. Place clips according to cuts.json
+ *   4. Apply transforms, color, markers
+ *   5. Delete the .vit-old timeline
+ *
+ * This avoids Premiere's clip duplication issues when modifying in-place.
+ *
+ * @param {string} jsonStr - JSON object with keys: metadata, cuts, audio, color, markers
+ */
+function deserializeTimeline(jsonStr) {
+    try {
+        var data = JSON.parse(jsonStr);
+    } catch (e) {
+        return '{"ok": false, "error": "Invalid JSON: ' + jsonEscape(e.message) + '"}';
+    }
+
+    var metadata = data.metadata || {};
+    var cuts = data.cuts || {};
+    var audio = data.audio || {};
+    var color = data.color || {};
+    var markers = data.markers || {};
+
+    var seq = app.project.activeSequence;
+    if (!seq) {
+        return '{"ok": false, "error": "No active sequence"}';
+    }
+
+    var seqWidth = parseInt(seq.frameSizeHorizontal, 10) || 1920;
+    var seqHeight = parseInt(seq.frameSizeVertical, 10) || 1080;
+
+    // --- Phase 1: Clear existing clips ---
+    // Remove all clips from all tracks
+    clearAllTracks(seq);
+
+    // --- Phase 2: Place video clips ---
+    var videoTracks = (cuts.video_tracks) || [];
+    for (var ti = 0; ti < videoTracks.length; ti++) {
+        var trackData = videoTracks[ti];
+        var trackIdx = trackData.index - 1; // 0-based for Premiere API
+        if (trackIdx >= seq.videoTracks.numTracks) continue;
+        var track = seq.videoTracks[trackIdx];
+
+        var items = trackData.items || [];
+        for (var ci = 0; ci < items.length; ci++) {
+            var item = items[ci];
+            placeVideoClip(seq, track, item, seqWidth, seqHeight);
+        }
+    }
+
+    // --- Phase 3: Place audio clips ---
+    var audioTracks = (audio.audio_tracks) || [];
+    for (var ati = 0; ati < audioTracks.length; ati++) {
+        var aTrackData = audioTracks[ati];
+        var aTrackIdx = aTrackData.index - 1;
+        if (aTrackIdx >= seq.audioTracks.numTracks) continue;
+        var aTrack = seq.audioTracks[aTrackIdx];
+
+        var aItems = aTrackData.items || [];
+        for (var aci = 0; aci < aItems.length; aci++) {
+            placeAudioClip(seq, aTrack, aItems[aci]);
+        }
+    }
+
+    // --- Phase 4: Apply color grades ---
+    applyColorGrades(seq, color);
+
+    // --- Phase 5: Restore markers ---
+    restoreMarkers(seq, markers);
+
+    return '{"ok": true}';
+}
+
+
+/**
+ * Remove all clips from all tracks in a sequence.
+ */
+function clearAllTracks(seq) {
+    // Video tracks
+    for (var ti = 0; ti < seq.videoTracks.numTracks; ti++) {
+        var track = seq.videoTracks[ti];
+        // Remove clips in reverse order to avoid index shifting
+        for (var ci = track.clips.numItems - 1; ci >= 0; ci--) {
+            try {
+                track.clips[ci].remove(false, false);
+            } catch (e) {
+                // Some clips may not be removable; skip
+            }
+        }
+    }
+    // Audio tracks
+    for (var ati = 0; ati < seq.audioTracks.numTracks; ati++) {
+        var aTrack = seq.audioTracks[ati];
+        for (var aci = aTrack.clips.numItems - 1; aci >= 0; aci--) {
+            try {
+                aTrack.clips[aci].remove(false, false);
+            } catch (e) {}
+        }
+    }
+    // Markers
+    var seqMarkers = seq.markers;
+    if (seqMarkers) {
+        while (seqMarkers.numMarkers > 0) {
+            try {
+                var m = seqMarkers.getFirstMarker();
+                if (m) seqMarkers.deleteMarker(m);
+                else break;
+            } catch (e) { break; }
+        }
+    }
+}
+
+
+/**
+ * Find a project item by media path.
+ */
+function findProjectItemByPath(mediaPath) {
+    if (!mediaPath || mediaPath === "") return null;
+
+    // Search root items
+    var rootItems = app.project.rootItem.children;
+    for (var i = 0; i < rootItems.numItems; i++) {
+        var found = searchProjectItem(rootItems[i], mediaPath);
+        if (found) return found;
+    }
+    return null;
+}
+
+
+function searchProjectItem(item, mediaPath) {
+    if (!item) return null;
+
+    // Check if this item matches
+    try {
+        if (item.type === ProjectItemType.CLIP || item.type === ProjectItemType.FILE) {
+            var itemPath = item.getMediaPath();
+            if (itemPath === mediaPath) return item;
+        }
+    } catch (e) {}
+
+    // Recurse into bins
+    if (item.children) {
+        for (var i = 0; i < item.children.numItems; i++) {
+            var found = searchProjectItem(item.children[i], mediaPath);
+            if (found) return found;
+        }
+    }
+
+    return null;
+}
+
+
+/**
+ * Import a media file if not already in the project.
+ * Returns the project item.
+ */
+function importMediaIfNeeded(mediaPath) {
+    if (!mediaPath || mediaPath === "" || mediaPath.indexOf("generator:") === 0) return null;
+
+    // Check if already imported
+    var existing = findProjectItemByPath(mediaPath);
+    if (existing) return existing;
+
+    // Import the file
+    try {
+        var importResult = app.project.importFiles([mediaPath]);
+        if (importResult) {
+            // Return the newly imported item
+            return findProjectItemByPath(mediaPath);
+        }
+    } catch (e) {}
+
+    return null;
+}
+
+
+/**
+ * Place a single video clip on a track.
+ */
+function placeVideoClip(seq, track, item, seqWidth, seqHeight) {
+    var mediaRef = item.media_ref || "";
+    var itemType = item.item_type || "media";
+
+    // Skip generators for now (no equivalent automatic placement)
+    if (itemType === "generator" || itemType === "title" || mediaRef.indexOf("generator:") === 0) {
+        return;
+    }
+
+    // Find or import media
+    var projectItem = importMediaIfNeeded(mediaRef);
+    if (!projectItem) return;
+
+    // Calculate position in ticks
+    var startTicks = framesToTicks(item.record_start_frame, seq).toString();
+
+    // Insert clip on track
+    try {
+        track.insertClip(projectItem, startTicks);
+    } catch (e) {
+        return;
+    }
+
+    // Find the newly inserted clip (last clip on track at this position)
+    var placedClip = null;
+    for (var i = track.clips.numItems - 1; i >= 0; i--) {
+        var c = track.clips[i];
+        if (ticksToFrames(c.start.ticks, seq) === item.record_start_frame) {
+            placedClip = c;
+            break;
+        }
+    }
+
+    if (!placedClip) return;
+
+    // Set source in/out points
+    try {
+        var inTicks = framesToTicks(item.source_start_frame, seq).toString();
+        var outTicks = framesToTicks(item.source_end_frame, seq).toString();
+        placedClip.inPoint = new Time();
+        placedClip.inPoint.ticks = inTicks;
+        placedClip.outPoint = new Time();
+        placedClip.outPoint.ticks = outTicks;
+    } catch (e) {}
+
+    // Apply transform
+    var transform = item.transform || {};
+    applyTransform(placedClip, transform, seqWidth, seqHeight);
+
+    // Apply blend mode
+    if (item.composite_mode && item.composite_mode !== 0) {
+        var premiereBlend = VIT_TO_PREMIERE_BLEND[item.composite_mode];
+        if (premiereBlend !== undefined) {
+            var opacityComp = findComponent(placedClip, "Opacity");
+            if (opacityComp) {
+                setComponentPropertyValue(opacityComp, "Blend Mode", premiereBlend);
+            }
+        }
+    }
+
+    // Apply speed
+    if (item.speed && item.speed.speed_percent && item.speed.speed_percent !== 100) {
+        try {
+            placedClip.setSpeed(item.speed.speed_percent / 100);
+        } catch (e) {}
+    }
+
+    // Disabled state
+    if (item.clip_enabled === false) {
+        try {
+            placedClip.disabled = true;
+        } catch (e) {}
+    }
+}
+
+
+/**
+ * Apply transform properties to a placed clip.
+ */
+function applyTransform(clip, transform, seqWidth, seqHeight) {
+    var motion = findComponent(clip, "Motion");
+    if (motion) {
+        // Position (Pan/Tilt -> normalized Position)
+        var panPx = transform["Pan"] || 0;
+        var tiltPx = transform["Tilt"] || 0;
+        var posX = (panPx + seqWidth / 2) / seqWidth;
+        var posY = (tiltPx + seqHeight / 2) / seqHeight;
+        setComponentPropertyValue(motion, "Position", [posX, posY]);
+
+        // Scale (ZoomX -> percentage)
+        var zoomX = transform["ZoomX"];
+        if (zoomX === undefined) zoomX = 1.0;
+        var scalePercent = zoomX * 100;
+        if (transform["FlipX"]) scalePercent = -scalePercent;
+        setComponentPropertyValue(motion, "Scale", scalePercent);
+
+        // Non-uniform scale
+        var zoomY = transform["ZoomY"];
+        if (zoomY !== undefined && zoomY !== zoomX) {
+            setComponentPropertyValue(motion, "Scale Height", zoomY * 100);
+        }
+
+        // Rotation
+        if (transform["RotationAngle"]) {
+            setComponentPropertyValue(motion, "Rotation", transform["RotationAngle"]);
+        }
+
+        // Anchor point
+        if (transform["AnchorPointX"] || transform["AnchorPointY"]) {
+            var ax = ((transform["AnchorPointX"] || 0) + seqWidth / 2) / seqWidth;
+            var ay = ((transform["AnchorPointY"] || 0) + seqHeight / 2) / seqHeight;
+            setComponentPropertyValue(motion, "Anchor Point", [ax, ay]);
+        }
+    }
+
+    // Opacity
+    var opacityComp = findComponent(clip, "Opacity");
+    if (opacityComp && transform["Opacity"] !== undefined) {
+        setComponentPropertyValue(opacityComp, "Opacity", transform["Opacity"]);
+    }
+
+    // Crop (only if values present)
+    if (transform["CropLeft"] || transform["CropRight"] || transform["CropTop"] || transform["CropBottom"]) {
+        var cropEffect = findComponent(clip, "Crop");
+        if (cropEffect) {
+            if (transform["CropLeft"]) setComponentPropertyValue(cropEffect, "Left", transform["CropLeft"]);
+            if (transform["CropRight"]) setComponentPropertyValue(cropEffect, "Right", transform["CropRight"]);
+            if (transform["CropTop"]) setComponentPropertyValue(cropEffect, "Top", transform["CropTop"]);
+            if (transform["CropBottom"]) setComponentPropertyValue(cropEffect, "Bottom", transform["CropBottom"]);
+        }
+    }
+}
+
+
+/**
+ * Place a single audio clip on a track.
+ */
+function placeAudioClip(seq, track, item) {
+    var mediaRef = item.media_ref || "";
+    var projectItem = importMediaIfNeeded(mediaRef);
+    if (!projectItem) return;
+
+    var startTicks = framesToTicks(item.start_frame, seq).toString();
+    try {
+        track.insertClip(projectItem, startTicks);
+    } catch (e) {
+        return;
+    }
+
+    // Find the placed clip
+    var placedClip = null;
+    for (var i = track.clips.numItems - 1; i >= 0; i--) {
+        var c = track.clips[i];
+        if (ticksToFrames(c.start.ticks, seq) === item.start_frame) {
+            placedClip = c;
+            break;
+        }
+    }
+    if (!placedClip) return;
+
+    // Volume
+    if (item.volume !== undefined && item.volume !== 0) {
+        var volumeComp = findComponent(placedClip, "Volume");
+        if (volumeComp) {
+            setComponentPropertyValue(volumeComp, "Level", item.volume);
+        }
+    }
+
+    // Speed
+    if (item.speed && item.speed.speed_percent && item.speed.speed_percent !== 100) {
+        try {
+            placedClip.setSpeed(item.speed.speed_percent / 100);
+        } catch (e) {}
+    }
+}
+
+
+/**
+ * Apply Lumetri Color grades from color.json data.
+ */
+function applyColorGrades(seq, colorData) {
+    var grades = colorData.grades || {};
+
+    for (var ti = 0; ti < seq.videoTracks.numTracks; ti++) {
+        var track = seq.videoTracks[ti];
+        var trackIdx = ti + 1;
+
+        for (var ci = 0; ci < track.clips.numItems; ci++) {
+            var clip = track.clips[ci];
+            var itemId = "item_" + pad3(trackIdx) + "_" + pad3(ci);
+            var grade = grades[itemId];
+            if (!grade || !grade.nodes || grade.nodes.length === 0) continue;
+
+            var node = grade.nodes[0]; // Premiere has one Lumetri = one node
+
+            // Find or add Lumetri Color effect
+            var lumetri = findComponent(clip, "Lumetri Color");
+            if (!lumetri) {
+                // Cannot programmatically add Lumetri via ExtendScript easily;
+                // skip clips without existing Lumetri
+                continue;
+            }
+
+            // Apply properties
+            if (node.temperature !== undefined) {
+                setComponentPropertyValue(lumetri, "Temperature", node.temperature);
+            }
+            if (node.tint !== undefined) {
+                setComponentPropertyValue(lumetri, "Tint", node.tint);
+            }
+            if (node.contrast !== undefined) {
+                setComponentPropertyValue(lumetri, "Contrast", node.contrast);
+            }
+            if (node.saturation !== undefined) {
+                // Vit saturation is 0-2 multiplier, Premiere is 0-200 percentage
+                setComponentPropertyValue(lumetri, "Saturation", node.saturation * 100);
+            }
+            if (node.color_boost !== undefined) {
+                setComponentPropertyValue(lumetri, "Vibrance", node.color_boost);
+            }
+            if (node.sharpness !== undefined) {
+                setComponentPropertyValue(lumetri, "Sharpen", node.sharpness);
+            }
+            if (node.gain_m !== undefined) {
+                // gain_m mapped from Exposure
+                setComponentPropertyValue(lumetri, "Exposure", node.gain_m);
+            }
+            if (node.gain_r !== undefined) {
+                setComponentPropertyValue(lumetri, "Highlights", node.gain_r);
+            }
+            if (node.lift_m !== undefined) {
+                setComponentPropertyValue(lumetri, "Shadows", node.lift_m);
+            }
+            if (node.gain_g !== undefined) {
+                setComponentPropertyValue(lumetri, "Whites", node.gain_g);
+            }
+            if (node.lift_r !== undefined) {
+                setComponentPropertyValue(lumetri, "Blacks", node.lift_r);
+            }
+
+            // LUT
+            if (node.lut && node.lut !== "") {
+                setComponentPropertyValue(lumetri, "Input LUT", node.lut);
+            }
+        }
+    }
+}
+
+
+/**
+ * Restore sequence markers from markers.json data.
+ */
+function restoreMarkers(seq, markerData) {
+    var markerList = markerData.markers || [];
+
+    // Reverse marker color map
+    var colorNameToIndex = {
+        "Green": 0, "Red": 1, "Purple": 2, "Orange": 3,
+        "Yellow": 4, "White": 5, "Blue": 6, "Cyan": 7
+    };
+
+    for (var i = 0; i < markerList.length; i++) {
+        var m = markerList[i];
+        var frameTicks = framesToTicks(m.frame, seq);
+        var seconds = frameTicks / TICKS_PER_SECOND;
+
+        try {
+            var marker = seq.markers.createMarker(seconds);
+            if (marker) {
+                if (m.name) marker.name = m.name;
+                if (m.note) marker.comments = m.note;
+
+                // Duration
+                if (m.duration > 1) {
+                    var endTicks = framesToTicks(m.frame + m.duration, seq);
+                    var endSeconds = endTicks / TICKS_PER_SECOND;
+                    marker.end = new Time();
+                    marker.end.seconds = endSeconds;
+                }
+
+                // Color
+                var colorIdx = colorNameToIndex[m.color];
+                if (colorIdx !== undefined) {
+                    try {
+                        marker.colorIndex = colorIdx;
+                    } catch (e) {
+                        // colorIndex may not be settable in all versions
+                    }
+                }
+            }
+        } catch (e) {}
+    }
+}
diff --git a/premiere_plugin/jsx/host_utils.jsx b/premiere_plugin/jsx/host_utils.jsx
new file mode 100644
index 0000000..14d2cf5
--- /dev/null
+++ b/premiere_plugin/jsx/host_utils.jsx
@@ -0,0 +1,141 @@
+/**
+ * host_utils.jsx — Time conversion helpers for Premiere Pro ExtendScript.
+ *
+ * Premiere uses ticks internally (254016000000 ticks/sec).
+ * Vit uses frame numbers. These helpers convert between the two.
+ */
+
+// Premiere's internal tick rate (constant across all versions)
+var TICKS_PER_SECOND = 254016000000;
+
+/**
+ * Get the frame rate of a sequence as a float.
+ * Premiere stores timebase as ticks-per-frame.
+ */
+function getFps(sequence) {
+    var timebase = parseInt(sequence.timebase, 10);
+    if (!timebase || timebase <= 0) return 24.0;
+    return TICKS_PER_SECOND / timebase;
+}
+
+/**
+ * Get ticks-per-frame for a sequence.
+ */
+function getTicksPerFrame(sequence) {
+    return parseInt(sequence.timebase, 10) || Math.round(TICKS_PER_SECOND / 24);
+}
+
+/**
+ * Convert ticks to frame number.
+ */
+function ticksToFrames(ticks, sequence) {
+    var tpf = getTicksPerFrame(sequence);
+    return Math.round(parseInt(ticks, 10) / tpf);
+}
+
+/**
+ * Convert frame number to ticks.
+ */
+function framesToTicks(frames, sequence) {
+    var tpf = getTicksPerFrame(sequence);
+    return frames * tpf;
+}
+
+/**
+ * Convert ticks to timecode string (HH:MM:SS:FF).
+ */
+function ticksToTimecode(ticks, sequence) {
+    var fps = getFps(sequence);
+    var ifps = Math.round(fps);
+    var totalFrames = ticksToFrames(ticks, sequence);
+    if (totalFrames < 0) totalFrames = 0;
+
+    var ff = totalFrames % ifps;
+    var totalSecs = Math.floor(totalFrames / ifps);
+    var ss = totalSecs % 60;
+    var totalMins = Math.floor(totalSecs / 60);
+    var mm = totalMins % 60;
+    var hh = Math.floor(totalMins / 60);
+
+    return pad2(hh) + ":" + pad2(mm) + ":" + pad2(ss) + ":" + pad2(ff);
+}
+
+/**
+ * Zero-pad a number to 2 digits.
+ */
+function pad2(n) {
+    return (n < 10 ? "0" : "") + n;
+}
+
+/**
+ * Zero-pad a number to 3 digits.
+ */
+function pad3(n) {
+    if (n < 10) return "00" + n;
+    if (n < 100) return "0" + n;
+    return "" + n;
+}
+
+/**
+ * Safely read a component property value by name.
+ * Returns the static value (no keyframe support).
+ */
+function getComponentPropertyValue(component, propName) {
+    if (!component || !component.properties) return undefined;
+    for (var i = 0; i < component.properties.numItems; i++) {
+        var prop = component.properties[i];
+        if (prop.displayName === propName) {
+            return prop.getValue();
+        }
+    }
+    return undefined;
+}
+
+/**
+ * Set a component property value by name.
+ */
+function setComponentPropertyValue(component, propName, value) {
+    if (!component || !component.properties) return false;
+    for (var i = 0; i < component.properties.numItems; i++) {
+        var prop = component.properties[i];
+        if (prop.displayName === propName) {
+            prop.setValue(value, true);
+            return true;
+        }
+    }
+    return false;
+}
+
+/**
+ * Find a component (effect) by displayName on a clip.
+ */
+function findComponent(clip, componentName) {
+    if (!clip.components) return null;
+    for (var i = 0; i < clip.components.numItems; i++) {
+        if (clip.components[i].displayName === componentName) {
+            return clip.components[i];
+        }
+    }
+    return null;
+}
+
+/**
+ * Get the sequence's start timecode as a string.
+ */
+function getStartTimecode(sequence) {
+    var startTicks = parseInt(sequence.zeroPoint, 10);
+    return ticksToTimecode(Math.abs(startTicks), sequence);
+}
+
+/**
+ * Escape a string for safe JSON embedding.
+ */
+function jsonEscape(str) {
+    if (str === undefined || str === null) return "";
+    return String(str)
+        .replace(/\\/g, "\\\\")
+        .replace(/"/g, '\\"')
+        .replace(/\n/g, "\\n")
+        .replace(/\r/g, "\\r")
+        .replace(/\t/g, "\\t");
+}
diff --git a/premiere_plugin/jsx/serializer.jsx b/premiere_plugin/jsx/serializer.jsx
new file mode 100644
index 0000000..2fa1c8f
--- /dev/null
+++ b/premiere_plugin/jsx/serializer.jsx
@@ -0,0 +1,643 @@
+/**
+ * serializer.jsx — Serialize Premiere Pro timeline to vit JSON objects.
+ *
+ * Each serialize* function returns a JSON string that the JS panel parses.
+ * All output must match the structure defined in vit/models.py:to_dict().
+ *
+ * Depends on: host_utils.jsx (loaded via manifest ScriptPath).
+ */
+
+// Load host_utils if not already loaded
+if (typeof TICKS_PER_SECOND === "undefined") {
+    var scriptDir = (new File($.fileName)).parent.fsName;
+    $.evalFile(scriptDir + "/host_utils.jsx");
+}
+
+// --------------------------------------------------------------------------
+// Premiere -> Resolve composite mode mapping
+// Premiere blend modes are string-based; Resolve uses integer IDs.
+// --------------------------------------------------------------------------
+var PREMIERE_BLEND_TO_VIT = {
+    "Normal": 0,
+    "Add": 1,
+    "Subtract": 2,
+    "Difference": 3,
+    "Multiply": 4,
+    "Screen": 5,
+    "Overlay": 6,
+    "Hard Light": 7,
+    "Soft Light": 8,
+    "Darken": 9,
+    "Lighten": 10,
+    "Color Dodge": 11,
+    "Color Burn": 12,
+    "Exclusion": 13,
+    "Hue": 14,
+    "Saturation": 15,
+    "Color": 16,
+    "Luminosity": 30,
+    "Divide": 18,
+    "Linear Dodge (Add)": 19,
+    "Linear Burn": 20,
+    "Linear Light": 21,
+    "Vivid Light": 22,
+    "Pin Light": 23,
+    "Hard Mix": 24,
+    "Lighter Color": 25,
+    "Darker Color": 26
+};
+
+// Premiere marker color index -> vit color name
+var MARKER_COLOR_MAP = {
+    0: "Green",
+    1: "Red",
+    2: "Purple",
+    3: "Orange",
+    4: "Yellow",
+    5: "White",
+    6: "Blue",
+    7: "Cyan"
+};
+
+
+/**
+ * Serialize timeline metadata only.
+ * Returns JSON string matching TimelineMetadata.to_dict().
+ */
+function serializeMetadata() {
+    var seq = app.project.activeSequence;
+    if (!seq) return '{"error": "No active sequence"}';
+
+    var fps = getFps(seq);
+    var w = parseInt(seq.frameSizeHorizontal, 10) || 1920;
+    var h = parseInt(seq.frameSizeVertical, 10) || 1080;
+    var startTC = getStartTimecode(seq);
+    var videoTrackCount = seq.videoTracks.numTracks;
+    var audioTrackCount = seq.audioTracks.numTracks;
+    var projectName = app.project.name || "";
+    var timelineName = seq.name || "";
+
+    // Remove file extension from project name
+    projectName = projectName.replace(/\.prproj$/i, "");
+
+    return JSON.stringify({
+        "project_name": projectName,
+        "timeline_name": timelineName,
+        "frame_rate": Math.round(fps * 1000) / 1000,
+        "resolution": {"width": w, "height": h},
+        "start_timecode": startTC,
+        "track_count": {
+            "video": videoTrackCount,
+            "audio": audioTrackCount
+        }
+    });
+}
+
+
+/**
+ * Read transform properties from a video clip's Motion component.
+ * Returns an object matching Transform.to_dict().
+ */
+function readClipTransform(clip, seqWidth, seqHeight) {
+    var t = {
+        "Pan": 0.0,
+        "Tilt": 0.0,
+        "ZoomX": 1.0,
+        "ZoomY": 1.0,
+        "Opacity": 100.0
+    };
+
+    // Motion is always components[0] for video clips
+    var motion = findComponent(clip, "Motion");
+    if (motion) {
+        var pos = getComponentPropertyValue(motion, "Position");
+        if (pos !== undefined && pos.length >= 2) {
+            // Premiere Position is normalized 0-1 relative to sequence size
+            // Vit Pan/Tilt: offset from center in pixels
+            t["Pan"] = Math.round(((pos[0] * seqWidth) - (seqWidth / 2)) * 100) / 100;
+            t["Tilt"] = Math.round(((pos[1] * seqHeight) - (seqHeight / 2)) * 100) / 100;
+        }
+
+        var scale = getComponentPropertyValue(motion, "Scale");
+        if (scale !== undefined) {
+            // Premiere Scale is 0-100+ percentage, Vit ZoomX is multiplier
+            var scaleVal = (typeof scale === "number") ? scale : scale[0];
+            t["ZoomX"] = Math.round((scaleVal / 100) * 10000) / 10000;
+            t["ZoomY"] = t["ZoomX"];
+
+            // Negative scale = flip
+            if (scaleVal < 0) {
+                t["FlipX"] = true;
+                t["ZoomX"] = Math.abs(t["ZoomX"]);
+            }
+        }
+
+        // Check for non-uniform scale
+        var scaleHeight = getComponentPropertyValue(motion, "Scale Height");
+        if (scaleHeight !== undefined) {
+            t["ZoomY"] = Math.round((scaleHeight / 100) * 10000) / 10000;
+        }
+
+        var rotation = getComponentPropertyValue(motion, "Rotation");
+        if (rotation !== undefined && rotation !== 0) {
+            t["RotationAngle"] = Math.round(rotation * 100) / 100;
+        }
+
+        var anchor = getComponentPropertyValue(motion, "Anchor Point");
+        if (anchor !== undefined && anchor.length >= 2) {
+            var ax = Math.round(((anchor[0] * seqWidth) - (seqWidth / 2)) * 100) / 100;
+            var ay = Math.round(((anchor[1] * seqHeight) - (seqHeight / 2)) * 100) / 100;
+            if (ax !== 0) t["AnchorPointX"] = ax;
+            if (ay !== 0) t["AnchorPointY"] = ay;
+        }
+    }
+
+    // Opacity is its own component
+    var opacityComp = findComponent(clip, "Opacity");
+    if (opacityComp) {
+        var opVal = getComponentPropertyValue(opacityComp, "Opacity");
+        if (opVal !== undefined) {
+            t["Opacity"] = Math.round(opVal * 100) / 100;
+        }
+
+        // Blend mode
+        var blendMode = getComponentPropertyValue(opacityComp, "Blend Mode");
+        if (blendMode !== undefined) {
+            // blendMode is typically an index in Premiere
+            // We'll store it in the VideoItem, not in transform
+        }
+    }
+
+    // Crop effect (only present if user has applied it)
+    var cropEffect = findComponent(clip, "Crop");
+    if (cropEffect) {
+        var cl = getComponentPropertyValue(cropEffect, "Left");
+        var cr = getComponentPropertyValue(cropEffect, "Right");
+        var ct = getComponentPropertyValue(cropEffect, "Top");
+        var cb = getComponentPropertyValue(cropEffect, "Bottom");
+        if (cl !== undefined && cl !== 0) t["CropLeft"] = Math.round(cl * 100) / 100;
+        if (cr !== undefined && cr !== 0) t["CropRight"] = Math.round(cr * 100) / 100;
+        if (ct !== undefined && ct !== 0) t["CropTop"] = Math.round(ct * 100) / 100;
+        if (cb !== undefined && cb !== 0) t["CropBottom"] = Math.round(cb * 100) / 100;
+    }
+
+    return t;
+}
+
+
+/**
+ * Read the blend mode from a clip's Opacity component.
+ * Returns the vit composite_mode integer.
+ */
+function readBlendMode(clip) {
+    var opacityComp = findComponent(clip, "Opacity");
+    if (!opacityComp) return 0;
+    var blendMode = getComponentPropertyValue(opacityComp, "Blend Mode");
+    if (blendMode === undefined || blendMode === null) return 0;
+    // In Premiere, Blend Mode property returns an integer index
+    // 1=Normal, 2=Dissolve, etc. We map the common ones
+    // Premiere blend mode indices (varies by version, but generally):
+    var premiereBlendToVit = {
+        1: 0,    // Normal
+        3: 9,    // Darken
+        4: 4,    // Multiply
+        5: 12,   // Color Burn
+        6: 20,   // Linear Burn
+        7: 26,   // Darker Color
+        8: 10,   // Lighten
+        9: 5,    // Screen
+        10: 11,  // Color Dodge
+        11: 19,  // Linear Dodge (Add)
+        12: 25,  // Lighter Color
+        13: 6,   // Overlay
+        14: 8,   // Soft Light
+        15: 7,   // Hard Light
+        16: 22,  // Vivid Light
+        17: 21,  // Linear Light
+        18: 23,  // Pin Light
+        19: 24,  // Hard Mix
+        20: 3,   // Difference
+        21: 13,  // Exclusion
+        22: 2,   // Subtract
+        23: 18,  // Divide
+        24: 14,  // Hue
+        25: 15,  // Saturation
+        26: 16,  // Color
+        27: 30   // Luminosity
+    };
+    return premiereBlendToVit[blendMode] || 0;
+}
+
+
+/**
+ * Determine if a clip is a generator/title (no media file).
+ */
+function isGeneratorClip(clip) {
+    if (!clip.projectItem) return true;
+    try {
+        var path = clip.projectItem.getMediaPath();
+        if (!path || path === "") return true;
+    } catch (e) {
+        return true;
+    }
+    return false;
+}
+
+
+/**
+ * Serialize all video tracks.
+ * Returns JSON string matching {video_tracks: [VideoTrack.to_dict()], asset_paths: [...]}.
+ */
+function serializeVideoTracks() {
+    var seq = app.project.activeSequence;
+    if (!seq) return '{"error": "No active sequence"}';
+
+    var seqWidth = parseInt(seq.frameSizeHorizontal, 10) || 1920;
+    var seqHeight = parseInt(seq.frameSizeVertical, 10) || 1080;
+    var videoTracks = [];
+    var assetPaths = [];
+
+    for (var ti = 0; ti < seq.videoTracks.numTracks; ti++) {
+        var track = seq.videoTracks[ti];
+        var trackIdx = ti + 1;
+        var items = [];
+
+        for (var ci = 0; ci < track.clips.numItems; ci++) {
+            var clip = track.clips[ci];
+            var itemId = "item_" + pad3(trackIdx) + "_" + pad3(ci);
+            var clipName = clip.name || ("clip_" + trackIdx + "_" + ci);
+            var mediaRef = "";
+            var itemType = "media";
+            var generatorName = "";
+
+            if (isGeneratorClip(clip)) {
+                itemType = "generator";
+                generatorName = clipName;
+                mediaRef = "generator:" + itemId;
+                // Check if it's a title
+                var lowerName = clipName.toLowerCase();
+                if (lowerName.indexOf("text") >= 0 || lowerName.indexOf("title") >= 0 ||
+                    lowerName.indexOf("caption") >= 0 || lowerName.indexOf("subtitle") >= 0 ||
+                    lowerName.indexOf("lower third") >= 0) {
+                    itemType = "title";
+                }
+            } else {
+                var mediaPath = clip.projectItem.getMediaPath();
+                mediaRef = mediaPath || ("unknown_" + ci);
+                // Collect paths for asset manifest (hashing done in Node.js)
+                if (mediaPath && mediaPath !== "") {
+                    assetPaths.push(mediaPath);
+                }
+            }
+
+            var transform = readClipTransform(clip, seqWidth, seqHeight);
+            var compositeMode = readBlendMode(clip);
+
+            // Speed
+            var speedPercent = 100.0;
+            try {
+                var spd = clip.getSpeed();
+                if (spd !== undefined && spd !== null) {
+                    speedPercent = Math.round(spd * 100 * 10000) / 10000;
+                }
+            } catch (e) {}
+
+            // Enabled state
+            var clipEnabled = true;
+            try {
+                clipEnabled = !clip.disabled;
+            } catch (e) {}
+
+            // Build the VideoItem dict
+            var item = {
+                "id": itemId,
+                "name": clipName,
+                "media_ref": mediaRef,
+                "record_start_frame": ticksToFrames(clip.start.ticks, seq),
+                "record_end_frame": ticksToFrames(clip.end.ticks, seq),
+                "source_start_frame": ticksToFrames(clip.inPoint.ticks, seq),
+                "source_end_frame": ticksToFrames(clip.outPoint.ticks, seq),
+                "track_index": trackIdx,
+                "transform": transform
+            };
+
+            // Conditional fields (match models.py VideoItem.to_dict())
+            if (speedPercent !== 100.0) {
+                item["speed"] = {"speed_percent": speedPercent};
+            }
+            if (compositeMode !== 0) {
+                item["composite_mode"] = compositeMode;
+            }
+            if (!clipEnabled) {
+                item["clip_enabled"] = false;
+            }
+            if (itemType !== "media") {
+                item["item_type"] = itemType;
+            }
+            if (generatorName !== "") {
+                item["generator_name"] = generatorName;
+            }
+            // fusion_comp_file: Premiere doesn't have Fusion, use ""
+            // text_properties: could extract from Essential Graphics, future work
+
+            items.push(item);
+        }
+
+        videoTracks.push({
+            "index": trackIdx,
+            "items": items
+        });
+    }
+
+    return JSON.stringify({
+        "video_tracks": videoTracks,
+        "asset_paths": assetPaths
+    });
+}
+
+
+/**
+ * Serialize all audio tracks.
+ * Returns JSON string matching {audio_tracks: [AudioTrack.to_dict()]}.
+ */
+function serializeAudioTracks() {
+    var seq = app.project.activeSequence;
+    if (!seq) return '{"error": "No active sequence"}';
+
+    var audioTracks = [];
+
+    for (var ti = 0; ti < seq.audioTracks.numTracks; ti++) {
+        var track = seq.audioTracks[ti];
+        var trackIdx = ti + 1;
+        var items = [];
+
+        for (var ci = 0; ci < track.clips.numItems; ci++) {
+            var clip = track.clips[ci];
+            var audioId = "audio_" + pad3(trackIdx) + "_" + pad3(ci);
+
+            // Media ref
+            var mediaRef = "";
+            try {
+                var mediaPath = clip.projectItem.getMediaPath();
+                mediaRef = mediaPath || ("unknown_a" + ci);
+            } catch (e) {
+                mediaRef = "unknown_a" + ci;
+            }
+
+            // Volume and pan from audio components
+            var volume = 0.0;
+            var pan = 0.0;
+
+            // Audio clip's Volume component
+            var volumeComp = findComponent(clip, "Volume");
+            if (volumeComp) {
+                var volVal = getComponentPropertyValue(volumeComp, "Level");
+                if (volVal !== undefined) {
+                    volume = Math.round(volVal * 100) / 100;
+                }
+            }
+
+            // Channel Volume / Panner
+            var pannerComp = findComponent(clip, "Panner");
+            if (!pannerComp) {
+                pannerComp = findComponent(clip, "Channel Volume");
+            }
+            if (pannerComp) {
+                var panVal = getComponentPropertyValue(pannerComp, "Balance");
+                if (panVal === undefined) {
+                    panVal = getComponentPropertyValue(pannerComp, "Pan");
+                }
+                if (panVal !== undefined) {
+                    pan = Math.round(panVal * 100) / 100;
+                }
+            }
+
+            // Speed
+            var speedPercent = 100.0;
+            try {
+                var spd = clip.getSpeed();
+                if (spd !== undefined && spd !== null) {
+                    speedPercent = Math.round(spd * 100 * 10000) / 10000;
+                }
+            } catch (e) {}
+
+            var audioItem = {
+                "id": audioId,
+                "media_ref": mediaRef,
+                "start_frame": ticksToFrames(clip.start.ticks, seq),
+                "end_frame": ticksToFrames(clip.end.ticks, seq),
+                "volume": volume,
+                "pan": pan
+            };
+
+            if (speedPercent !== 100.0) {
+                audioItem["speed"] = {"speed_percent": speedPercent};
+            }
+
+            items.push(audioItem);
+        }
+
+        audioTracks.push({
+            "index": trackIdx,
+            "items": items
+        });
+    }
+
+    return JSON.stringify({"audio_tracks": audioTracks});
+}
+
+
+/**
+ * Serialize color grades (Lumetri Color) for all video clips.
+ * Returns JSON string matching {grades: {clip_id: ColorGrade.to_dict()}}.
+ *
+ * Advantage over Resolve: Lumetri properties are directly readable.
+ */
+function serializeColor() {
+    var seq = app.project.activeSequence;
+    if (!seq) return '{"error": "No active sequence"}';
+
+    var grades = {};
+
+    for (var ti = 0; ti < seq.videoTracks.numTracks; ti++) {
+        var track = seq.videoTracks[ti];
+        var trackIdx = ti + 1;
+
+        for (var ci = 0; ci < track.clips.numItems; ci++) {
+            var clip = track.clips[ci];
+            var itemId = "item_" + pad3(trackIdx) + "_" + pad3(ci);
+
+            // Find Lumetri Color effect
+            var lumetri = null;
+            if (clip.components) {
+                for (var ei = 0; ei < clip.components.numItems; ei++) {
+                    if (clip.components[ei].displayName === "Lumetri Color") {
+                        lumetri = clip.components[ei];
+                        break;
+                    }
+                }
+            }
+
+            if (!lumetri) {
+                // No Lumetri — single empty node
+                grades[itemId] = {
+                    "num_nodes": 1,
+                    "nodes": [{"index": 1, "label": "", "lut": ""}],
+                    "version_name": "",
+                    "drx_file": null,
+                    "lut_file": null
+                };
+                continue;
+            }
+
+            // Read Lumetri properties — single node = single Lumetri instance
+            var node = {
+                "index": 1,
+                "label": "Lumetri",
+                "lut": ""
+            };
+
+            // Basic Correction
+            var temp = getComponentPropertyValue(lumetri, "Temperature");
+            if (temp !== undefined && temp !== null) node["temperature"] = Math.round(temp * 1000) / 1000;
+
+            var tintVal = getComponentPropertyValue(lumetri, "Tint");
+            if (tintVal !== undefined && tintVal !== null) node["tint"] = Math.round(tintVal * 1000) / 1000;
+
+            var exposure = getComponentPropertyValue(lumetri, "Exposure");
+            // Map Exposure to gain_m (master gain) — closest equivalent
+            if (exposure !== undefined && exposure !== null && exposure !== 0) {
+                node["gain_m"] = Math.round(exposure * 1000) / 1000;
+            }
+
+            var contrast = getComponentPropertyValue(lumetri, "Contrast");
+            if (contrast !== undefined && contrast !== null) node["contrast"] = Math.round(contrast * 1000) / 1000;
+
+            var highlights = getComponentPropertyValue(lumetri, "Highlights");
+            if (highlights !== undefined && highlights !== null && highlights !== 0) {
+                node["gain_r"] = Math.round(highlights * 1000) / 1000;
+            }
+
+            var shadows = getComponentPropertyValue(lumetri, "Shadows");
+            if (shadows !== undefined && shadows !== null && shadows !== 0) {
+                node["lift_m"] = Math.round(shadows * 1000) / 1000;
+            }
+
+            var whites = getComponentPropertyValue(lumetri, "Whites");
+            if (whites !== undefined && whites !== null && whites !== 0) {
+                node["gain_g"] = Math.round(whites * 1000) / 1000;
+            }
+
+            var blacks = getComponentPropertyValue(lumetri, "Blacks");
+            if (blacks !== undefined && blacks !== null && blacks !== 0) {
+                node["lift_r"] = Math.round(blacks * 1000) / 1000;
+            }
+
+            var saturation = getComponentPropertyValue(lumetri, "Saturation");
+            if (saturation !== undefined && saturation !== null) {
+                node["saturation"] = Math.round((saturation / 100) * 10000) / 10000;
+            }
+
+            // Creative section
+            var vibrance = getComponentPropertyValue(lumetri, "Vibrance");
+            if (vibrance !== undefined && vibrance !== null && vibrance !== 0) {
+                node["color_boost"] = Math.round(vibrance * 1000) / 1000;
+            }
+
+            // Sharpness
+            var sharpness = getComponentPropertyValue(lumetri, "Sharpen");
+            if (sharpness !== undefined && sharpness !== null && sharpness !== 0) {
+                node["sharpness"] = Math.round(sharpness * 1000) / 1000;
+            }
+
+            // Check for input LUT
+            var lutPath = getComponentPropertyValue(lumetri, "Input LUT");
+            if (lutPath !== undefined && lutPath !== null && lutPath !== "") {
+                node["lut"] = String(lutPath);
+            }
+
+            grades[itemId] = {
+                "num_nodes": 1,
+                "nodes": [node],
+                "version_name": "",
+                "drx_file": null,
+                "lut_file": null
+            };
+        }
+    }
+
+    return JSON.stringify({"grades": grades});
+}
+
+
+/**
+ * Serialize timeline markers.
+ * Returns JSON string matching {markers: [Marker.to_dict()]}.
+ */
+function serializeMarkers() {
+    var seq = app.project.activeSequence;
+    if (!seq) return '{"error": "No active sequence"}';
+
+    var markers = [];
+    var seqMarkers = seq.markers;
+
+    if (seqMarkers && seqMarkers.numMarkers > 0) {
+        for (var i = 0; i < seqMarkers.numMarkers; i++) {
+            var marker;
+            if (i === 0) {
+                marker = seqMarkers.getFirstMarker();
+            } else {
+                marker = seqMarkers.getNextMarker(marker);
+            }
+            if (!marker) break;
+
+            var frame = ticksToFrames(marker.start.ticks, seq);
+            var endFrame = ticksToFrames(marker.end.ticks, seq);
+            var duration = endFrame - frame;
+            if (duration < 1) duration = 1;
+
+            // Map color
+            var colorName = "Blue";
+            var colorIdx = marker.colorIndex;
+            if (colorIdx !== undefined && MARKER_COLOR_MAP[colorIdx] !== undefined) {
+                colorName = MARKER_COLOR_MAP[colorIdx];
+            } else if (marker.type !== undefined) {
+                // Fallback: some versions expose type instead of colorIndex
+                if (MARKER_COLOR_MAP[marker.type] !== undefined) {
+                    colorName = MARKER_COLOR_MAP[marker.type];
+                }
+            }
+
+            markers.push({
+                "frame": frame,
+                "color": colorName,
+                "name": marker.name || "",
+                "note": marker.comments || "",
+                "duration": duration
+            });
+        }
+    }
+
+    return JSON.stringify({"markers": markers});
+}
+
+
+/**
+ * Serialize the complete timeline — calls all serialize functions.
+ * Returns a JSON string with all domain data.
+ */
+function serializeTimeline() {
+    var metadata = serializeMetadata();
+    var cuts = serializeVideoTracks();
+    var audio = serializeAudioTracks();
+    var color = serializeColor();
+    var markers = serializeMarkers();
+
+    return JSON.stringify({
+        "metadata": metadata,
+        "cuts": cuts,
+        "audio": audio,
+        "color": color,
+        "markers": markers
+    });
+}
diff --git a/premiere_plugin/premiere_bridge.py b/premiere_plugin/premiere_bridge.py
new file mode 100644
index 0000000..a33b062
--- /dev/null
+++ b/premiere_plugin/premiere_bridge.py
@@ -0,0 +1,264 @@
+"""Premiere Bridge — stdin/stdout JSON dispatcher to vit.core.
+
+Spawned as a subprocess by the CEP panel's Node.js layer.
+Reads newline-delimited JSON from stdin, writes JSON responses to stdout.
+
+Unlike the Resolve launcher, this bridge does NOT call serialize/deserialize —
+that's handled by ExtendScript in the CEP panel. This bridge only handles
+git operations via vit.core.
+
+Usage: python -u premiere_bridge.py --project-dir /path/to/project
+"""
+import json
+import os
+import sys
+import traceback
+
+# Bootstrap: find the vit package
+_script_dir = os.path.dirname(os.path.abspath(__file__))
+_root = os.path.dirname(_script_dir)
+if os.path.isdir(os.path.join(_root, "vit")) and _root not in sys.path:
+    sys.path.insert(0, _root)
+
+
+def _log(msg):
+    """Log to stderr (stdout is reserved for IPC)."""
+    sys.stderr.write(f"[vit-bridge] {msg}\n")
+    sys.stderr.flush()
+
+
+def handle_request(request, project_dir):
+    """Handle a JSON request from the Node.js layer.
+
+    Returns a JSON-serializable response dict.
+    Modeled after resolve_plugin/vit_panel_launcher.py:handle_request(),
+    but without Resolve-specific serialize/deserialize calls.
+    """
+    action = request.get("action")
+
+    try:
+        if action == "ping":
+            return {"ok": True}
+
+        elif action == "get_branch":
+            from vit.core import git_current_branch
+            branch = git_current_branch(project_dir)
+            return {"ok": True, "branch": branch}
+
+        elif action == "save":
+            from vit.core import git_add, git_commit, GitError
+
+            msg = request.get("message", "save version")
+            # Files are already written by the Node.js layer
+            git_add(project_dir, ["timeline/", "assets/", ".vit/", ".gitignore"])
+            try:
+                hash_val = git_commit(project_dir, f"vit: {msg}")
+                return {"ok": True, "hash": hash_val, "message": msg}
+            except GitError as e:
+                if "nothing to commit" in str(e):
+                    return {"ok": True, "message": "Nothing to commit — unchanged."}
+                return {"ok": False, "error": str(e)}
+
+        elif action == "new_branch":
+            from vit.core import git_branch
+            name = request.get("name", "").strip()
+            if not name:
+                return {"ok": False, "error": "No branch name provided."}
+            git_branch(project_dir, name)
+            return {"ok": True, "branch": name}
+
+        elif action == "list_branches":
+            from vit.core import git_list_branches, git_current_branch
+            branches = git_list_branches(project_dir)
+            current = git_current_branch(project_dir)
+            return {"ok": True, "branches": branches, "current": current}
+
+        elif action == "switch_branch":
+            from vit.core import git_checkout, git_current_branch
+
+            target = request.get("branch", "")
+            current = git_current_branch(project_dir)
+            if target and target != current:
+                git_checkout(project_dir, target)
+            return {"ok": True, "branch": target}
+
+        elif action == "merge":
+            from vit.core import (
+                git_add, git_commit, git_merge, git_is_clean,
+                git_current_branch, git_list_conflicted_files,
+                git_checkout_theirs, GitError,
+            )
+            from vit.validator import validate_project, format_issues
+
+            target = request.get("branch", "")
+            current = git_current_branch(project_dir)
+
+            # Auto-save if dirty (files already written by Node.js layer)
+            if not git_is_clean(project_dir):
+                git_add(project_dir, ["timeline/", "assets/", ".vit/", ".gitignore"])
+                try:
+                    git_commit(project_dir, f"vit: auto-save before merging '{target}'")
+                except GitError as e:
+                    if "nothing to commit" not in str(e):
+                        return {"ok": False, "error": str(e)}
+
+            success, output = git_merge(project_dir, target)
+            if not success:
+                conflicted = git_list_conflicted_files(project_dir)
+                auto_resolvable = [
+                    f for f in conflicted
+                    if f.startswith("timeline/") or f.startswith("assets/")
+                ]
+                non_resolvable = [f for f in conflicted if f not in auto_resolvable]
+                if auto_resolvable and not non_resolvable:
+                    try:
+                        git_checkout_theirs(project_dir, auto_resolvable)
+                        git_add(project_dir, auto_resolvable)
+                        git_commit(project_dir, f"vit: merged '{target}' (auto-resolved)")
+                        success = True
+                    except GitError as e:
+                        return {"ok": False, "error": f"Auto-resolve failed: {e}"}
+
+            if success:
+                issues = validate_project(project_dir)
+                issue_text = format_issues(issues) if issues else ""
+                return {"ok": True, "branch": target, "current": current, "issues": issue_text}
+            else:
+                return {"ok": False, "error": f"Merge conflicts. Use terminal: vit merge {target}"}
+
+        elif action == "push":
+            from vit.core import git_current_branch, git_push, GitError
+            branch = git_current_branch(project_dir)
+            try:
+                output = git_push(project_dir, "origin", branch)
+                return {"ok": True, "branch": branch, "output": output.strip()}
+            except GitError as e:
+                return {"ok": False, "error": str(e)}
+
+        elif action == "pull":
+            from vit.core import git_current_branch, git_pull, GitError
+            branch = git_current_branch(project_dir)
+            try:
+                output = git_pull(project_dir, "origin", branch)
+            except GitError as e:
+                return {"ok": False, "error": str(e)}
+            return {"ok": True, "branch": branch, "output": output.strip()}
+
+        elif action == "status":
+            from vit.core import git_current_branch, git_status, git_log
+            branch = git_current_branch(project_dir)
+            status = git_status(project_dir)
+            log_out = git_log(project_dir, max_count=5)
+            return {
+                "ok": True,
+                "branch": branch,
+                "status": status.strip() if status else "Working tree clean",
+                "log": log_out or "",
+            }
+
+        elif action == "get_changes":
+            from vit.differ import get_changes_by_category
+            try:
+                changes = get_changes_by_category(project_dir, "HEAD")
+                return {"ok": True, "changes": changes}
+            except Exception:
+                return {"ok": True, "changes": {"audio": [], "video": [], "color": []}}
+
+        elif action == "get_commit_history":
+            from vit.core import git_log_with_changes, categorize_commit
+            limit = request.get("limit", 10)
+            commits = git_log_with_changes(project_dir, max_count=limit)
+            for commit in commits:
+                commit["category"] = categorize_commit(commit.get("files_changed", []))
+            return {"ok": True, "commits": commits}
+
+        elif action == "compare_branches":
+            from vit.differ import get_branch_diff_by_category
+            branch_a = request.get("branch_a", "")
+            branch_b = request.get("branch_b", "")
+            if not branch_a or not branch_b:
+                return {"ok": False, "error": "Both branch_a and branch_b required"}
+            changes_a, changes_b = get_branch_diff_by_category(project_dir, branch_a, branch_b)
+            return {
+                "ok": True,
+                "branch_a": branch_a,
+                "branch_b": branch_b,
+                "changes_a": changes_a,
+                "changes_b": changes_b,
+            }
+
+        elif action == "get_commit_graph":
+            from vit.core import git_log_with_topology
+            limit = request.get("limit", 30)
+            try:
+                data = git_log_with_topology(project_dir, max_count=limit)
+                branch_colors = {}
+                color_idx = 0
+                for branch in data.get("branches", []):
+                    if branch in ("main", "master"):
+                        branch_colors[branch] = 3  # orange
+                    else:
+                        branch_colors[branch] = color_idx % 3
+                        color_idx += 1
+                return {
+                    "ok": True,
+                    "commits": data.get("commits", []),
+                    "branches": data.get("branches", []),
+                    "branch_colors": branch_colors,
+                    "head": data.get("head", ""),
+                }
+            except Exception as e:
+                return {"ok": False, "error": str(e)}
+
+        elif action == "init":
+            from vit.core import git_init
+            git_init(project_dir, nle="premiere")
+            return {"ok": True}
+
+        elif action == "quit":
+            return {"ok": True, "quit": True}
+
+        else:
+            return {"ok": False, "error": f"Unknown action: {action}"}
+
+    except Exception as e:
+        _log(f"Error handling {action}: {traceback.format_exc()}")
+        return {"ok": False, "error": str(e)}
+
+
+def main():
+    import argparse
+    parser = argparse.ArgumentParser(description="Vit Premiere Bridge")
+    parser.add_argument("--project-dir", required=True, help="Path to vit project directory")
+    args = parser.parse_args()
+
+    project_dir = os.path.abspath(args.project_dir)
+    _log(f"Bridge started. Project: {project_dir}")
+
+    # Main loop: read JSON from stdin, write responses to stdout
+    for line in sys.stdin:
+        line = line.strip()
+        if not line:
+            continue
+
+        try:
+            request = json.loads(line)
+        except json.JSONDecodeError as e:
+            _log(f"Bad JSON: {e}")
+            response = {"ok": False, "error": f"Invalid JSON: {e}"}
+            sys.stdout.write(json.dumps(response) + "\n")
+            sys.stdout.flush()
+            continue
+
+        response = handle_request(request, project_dir)
+        sys.stdout.write(json.dumps(response) + "\n")
+        sys.stdout.flush()
+
+        if response.get("quit"):
+            break
+
+    _log("Bridge exiting.")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/resolve_plugin/vit_panel.py b/resolve_plugin/vit_panel.py
index 47172c6..25861dc 100644
--- a/resolve_plugin/vit_panel.py
+++ b/resolve_plugin/vit_panel.py
@@ -42,6 +42,7 @@
     main()
 except Exception:
     # Fallback to tkinter panel
+    sys.stderr.write("[vit] Qt panel unavailable, falling back to Tkinter.\n")
     try:
         from resolve_plugin.vit_panel_tkinter import main as tkinter_main
         tkinter_main()
diff --git a/setup.py b/setup.py
index 9ef721a..4ba0bdd 100644
--- a/setup.py
+++ b/setup.py
@@ -11,7 +11,12 @@
         "rich",
         "PySide6",
     ],
-    extras_require={},
+    extras_require={
+        "dev": [
+            "pytest",
+        ],
+        "qt": [],
+    },
     entry_points={
         "console_scripts": [
             "vit=vit.cli:main",
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..0665288
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,49 @@
+"""Tests for CLI entry points and installer helpers."""
+
+import os
+from types import SimpleNamespace
+
+import pytest
+
+from vit import cli
+
+
+def test_default_clone_dest_removes_literal_git_suffix():
+    assert cli._default_clone_dest("https://github.com/org/repo.git") == "repo"
+    assert cli._default_clone_dest("https://github.com/org/widget") == "widget"
+
+
+def test_install_premiere_rejects_non_macos(monkeypatch, capsys):
+    monkeypatch.setattr(cli.sys, "platform", "linux")
+
+    with pytest.raises(SystemExit) as excinfo:
+        cli.cmd_install_premiere(SimpleNamespace())
+
+    assert excinfo.value.code == 1
+    assert "macOS only" in capsys.readouterr().out
+
+
+def test_install_premiere_uses_plugin_dir_and_saves_package_path(tmp_path, monkeypatch, capsys):
+    plugin_dir = tmp_path / "premiere_plugin"
+    plugin_dir.mkdir()
+    cep_dir = tmp_path / "cep"
+    calls = []
+
+    monkeypatch.setattr(cli.sys, "platform", "darwin")
+    monkeypatch.setattr(cli, "PREMIERE_CEP_DIR", str(cep_dir))
+    monkeypatch.setattr(cli, "_find_plugin_dir", lambda name: (str(tmp_path), str(plugin_dir)))
+    monkeypatch.setattr(cli, "_save_package_path", lambda package_dir: calls.append(("save", package_dir)))
+    monkeypatch.setattr(
+        cli.subprocess,
+        "run",
+        lambda args, capture_output, text: calls.append(("defaults", tuple(args))),
+    )
+
+    cli.cmd_install_premiere(SimpleNamespace())
+
+    dest = cep_dir / cli.PREMIERE_EXTENSION_ID
+    assert dest.is_symlink()
+    assert os.readlink(dest) == str(plugin_dir)
+    assert ("save", str(tmp_path)) in calls
+    assert sum(1 for call in calls if call[0] == "defaults") == 3
+    assert "Installed Premiere extension" in capsys.readouterr().out
diff --git a/tests/test_core.py b/tests/test_core.py
index 7dbe0c3..c0f2ca5 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -14,16 +14,19 @@
     git_checkout,
     git_commit,
     git_current_branch,
+    git_default_branch,
     git_diff,
     git_init,
     git_list_branches,
     git_log,
     git_merge,
     git_merge_base,
+    git_clone,
     git_revert,
     git_show_file,
     git_status,
     is_git_repo,
+    read_nle,
 )
 from vit.json_writer import _write_json
 
@@ -55,6 +58,28 @@ def test_init_creates_structure(project_dir):
     assert os.path.isdir(os.path.join(project_dir, "timeline"))
     assert os.path.isdir(os.path.join(project_dir, "assets"))
     assert os.path.isfile(os.path.join(project_dir, ".vit", "config.json"))
+    assert git_current_branch(project_dir) == "main"
+
+
+def test_init_with_nle_premiere():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        git_init(tmpdir, nle="premiere")
+        with open(os.path.join(tmpdir, ".vit", "config.json")) as f:
+            config = json.load(f)
+        assert config["nle"] == "premiere"
+
+
+def test_read_nle_defaults_to_resolve_for_missing_config():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        assert read_nle(tmpdir) == "resolve"
+
+
+def test_gitignore_contains_both_nle_patterns(project_dir):
+    with open(os.path.join(project_dir, ".gitignore")) as f:
+        gitignore = f.read()
+    assert "*.drp" in gitignore
+    assert "*.prproj" in gitignore
+    assert "*.prpref" in gitignore
 
 
 def test_is_git_repo(project_dir):
@@ -168,6 +193,10 @@ def test_find_project_root(project_dir):
     assert found == project_dir
 
 
+def test_read_nle_returns_config_value(project_dir):
+    assert read_nle(project_dir) == "resolve"
+
+
 def test_revert(project_dir):
     """git_revert should undo the last commit."""
     _write_cuts(project_dir, [{"id": "item_001", "name": "Before"}])
@@ -182,3 +211,30 @@ def test_revert(project_dir):
 
     log = git_log(project_dir)
     assert "Revert" in log
+
+
+def test_git_clone_writes_requested_nle_when_config_missing(monkeypatch):
+    with tempfile.TemporaryDirectory() as tmpdir:
+        dest_dir = os.path.join(tmpdir, "clone")
+
+        def fake_run(args, capture_output, text):
+            os.makedirs(dest_dir, exist_ok=True)
+
+            class Result:
+                returncode = 0
+                stdout = ""
+                stderr = ""
+
+            return Result()
+
+        monkeypatch.setattr("vit.core.subprocess.run", fake_run)
+
+        git_clone("https://example.com/repo.git", dest_dir, nle="premiere")
+
+        with open(os.path.join(dest_dir, ".vit", "config.json")) as f:
+            config = json.load(f)
+        assert config["nle"] == "premiere"
+
+
+def test_git_default_branch_prefers_existing_main(project_dir):
+    assert git_default_branch(project_dir) == "main"
diff --git a/vit/cli.py b/vit/cli.py
index 1139e30..231ec11 100644
--- a/vit/cli.py
+++ b/vit/cli.py
@@ -4,6 +4,7 @@
 import json
 import os
 import shutil
+import subprocess
 import sys
 
 from . import __version__
@@ -18,6 +19,7 @@
     git_config_get,
     git_config_set,
     git_current_branch,
+    git_default_branch,
     git_diff,
     git_init,
     git_list_branches,
@@ -50,6 +52,23 @@ def _require_project() -> str:
     return root
 
 
+def _warn_optional(feature: str, exc: Exception) -> None:
+    """Emit a concise warning for optional features that fail open."""
+    detail = str(exc).strip()
+    if detail:
+        print(f"  Warning: {feature} unavailable: {detail}", file=sys.stderr)
+    else:
+        print(f"  Warning: {feature} unavailable.", file=sys.stderr)
+
+
+def _default_clone_dest(url: str) -> str:
+    """Derive a local directory name from a git remote URL."""
+    name = os.path.basename(url.rstrip("/"))
+    if name.endswith(".git"):
+        name = name[:-4]
+    return name
+
+
 def cmd_init(args):
     """Initialize a new vit project."""
     project_dir = args.path or os.getcwd()
@@ -58,7 +77,7 @@ def cmd_init(args):
         print(f"Error: '{project_dir}' is already a vit project.")
         sys.exit(1)
 
-    git_init(project_dir)
+    git_init(project_dir, nle=args.nle)
 
     # Write empty domain files for initial commit
     from .models import Timeline
@@ -124,8 +143,8 @@ def cmd_commit(args):
                     else:
                         # User typed a custom message
                         message = response
-        except Exception:
-            pass  # Fall through to default
+        except Exception as exc:
+            _warn_optional("AI commit suggestion", exc)
         if not message:
             message = "vit: save version"
 
@@ -213,8 +232,8 @@ def cmd_merge(args):
                     if response in ("n", "no"):
                         print("  Merge cancelled.")
                         return
-        except Exception:
-            pass  # Skip analysis on any failure
+        except Exception as exc:
+            _warn_optional("AI merge analysis", exc)
 
     print(f"  Merging '{branch}' into '{current}'...")
 
@@ -399,7 +418,8 @@ def cmd_log(args):
                     print(f"\n  AI Summary: {summary}")
                 else:
                     print("\n  AI summary unavailable (check GEMINI_API_KEY).")
-            except Exception:
+            except Exception as exc:
+                _warn_optional("AI log summary", exc)
                 print("\n  AI summary unavailable.")
     else:
         print("  No commits yet.")
@@ -495,24 +515,52 @@ def _resolve_menu_name(script_name: str) -> str:
     return _RESOLVE_MENU_NAMES.get(script_name, script_name)
 
 
+PREMIERE_EXTENSION_ID = "com.vit.premiere"
+if sys.platform == "darwin":
+    PREMIERE_CEP_DIR = os.path.expanduser("~/Library/Application Support/Adobe/CEP/extensions")
+else:
+    PREMIERE_CEP_DIR = ""
+
+
+def _find_plugin_dir(plugin_name: str):
+    """Locate a top-level plugin directory from the repo or installer checkout."""
+    package_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    plugin_dir = os.path.join(package_dir, plugin_name)
+    if os.path.isdir(plugin_dir):
+        return package_dir, plugin_dir
+
+    vit_src = os.path.join(os.path.expanduser("~"), ".vit", "vit-src")
+    fallback_dir = os.path.join(vit_src, plugin_name)
+    if os.path.isdir(fallback_dir):
+        return vit_src, fallback_dir
+
+    return package_dir, plugin_dir
+
+
+def _save_package_path(package_dir: str) -> None:
+    """Save the repo root so NLE plugins can find the vit package."""
+    vit_user_dir = os.path.expanduser("~/.vit")
+    os.makedirs(vit_user_dir, exist_ok=True)
+    with open(os.path.join(vit_user_dir, "package_path"), "w") as f:
+        f.write(package_dir)
+    print(f"  Saved package path: {package_dir}")
+
+
+def _print_missing_plugin_dir(plugin_name: str, package_dir: str, plugin_dir: str) -> None:
+    """Explain the supported install layouts for plugin assets."""
+    print(f"  Error: {plugin_name}/ directory not found.")
+    print(f"  Checked: {os.path.join(package_dir, plugin_name)}")
+    print(f"  Checked: {plugin_dir}")
+    print("  Install vit from a source checkout, or use the curl installer so ~/.vit/vit-src is available.")
+
+
 def cmd_install_resolve(args):
     """Install Resolve plugin scripts via symlink."""
     # Find the resolve_plugin directory — check multiple locations
-    package_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-    plugin_dir = os.path.join(package_dir, "resolve_plugin")
-
-    if not os.path.isdir(plugin_dir):
-        # Fallback: check ~/.vit/vit-src/ (curl installer location)
-        # Also update package_dir so package_path gets the correct value
-        vit_src = os.path.join(os.path.expanduser("~"), ".vit", "vit-src")
-        if os.path.isdir(os.path.join(vit_src, "resolve_plugin")):
-            package_dir = vit_src
-            plugin_dir = os.path.join(package_dir, "resolve_plugin")
+    package_dir, plugin_dir = _find_plugin_dir("resolve_plugin")
 
     if not os.path.isdir(plugin_dir):
-        print(f"  Error: resolve_plugin/ directory not found.")
-        print(f"  Checked: {os.path.join(package_dir, 'resolve_plugin')}")
-        print(f"  Checked: {plugin_dir}")
+        _print_missing_plugin_dir("resolve_plugin", package_dir, plugin_dir)
         sys.exit(1)
 
     os.makedirs(RESOLVE_SCRIPTS_DIR, exist_ok=True)
@@ -536,22 +584,49 @@ def cmd_install_resolve(args):
             os.symlink(source, dest)
         print(f"  Linked: {menu_name} → {source}")
 
-    # Save the repo root path so Resolve scripts can find the vit package
-    # even if __file__ or symlink resolution fails in Resolve's Python
-    vit_user_dir = os.path.expanduser("~/.vit")
-    os.makedirs(vit_user_dir, exist_ok=True)
-    with open(os.path.join(vit_user_dir, "package_path"), "w") as f:
-        f.write(package_dir)
-    print(f"  Saved package path: {package_dir}")
+    _save_package_path(package_dir)
 
     print(f"\n  Installed {len(RESOLVE_SCRIPT_NAMES)} script(s) to Resolve.")
     print("  Restart Resolve, then run Workspace > Scripts > Vit for the unified panel.")
 
 
+def cmd_install_premiere(args):
+    """Install the Premiere CEP extension on macOS."""
+    if sys.platform != "darwin":
+        print("  Premiere install is currently supported on macOS only.")
+        sys.exit(1)
+
+    package_dir, plugin_dir = _find_plugin_dir("premiere_plugin")
+    if not os.path.isdir(plugin_dir):
+        _print_missing_plugin_dir("premiere_plugin", package_dir, plugin_dir)
+        sys.exit(1)
+
+    os.makedirs(PREMIERE_CEP_DIR, exist_ok=True)
+    dest = os.path.join(PREMIERE_CEP_DIR, PREMIERE_EXTENSION_ID)
+    if os.path.islink(dest) or os.path.exists(dest):
+        os.remove(dest)
+
+    os.symlink(plugin_dir, dest)
+    print(f"  Linked: {PREMIERE_EXTENSION_ID} → {plugin_dir}")
+
+    for version in range(9, 12):
+        subprocess.run(
+            ["defaults", "write", f"com.adobe.CSXS.{version}", "PlayerDebugMode", "1"],
+            capture_output=True,
+            text=True,
+        )
+    print("  Enabled PlayerDebugMode for CSXS 9/10/11.")
+
+    _save_package_path(package_dir)
+
+    print("\n  Installed Premiere extension.")
+    print("  Restart Premiere, then run Window > Extensions > Vit.")
+
+
 def cmd_clone(args):
     """Clone a remote vit repo to a local directory."""
     url = args.url
-    dest = args.directory or os.path.basename(url.rstrip("/").rstrip(".git"))
+    dest = args.directory or _default_clone_dest(url)
     if os.path.exists(dest):
         print(f"  Error: '{dest}' already exists.")
         sys.exit(1)
@@ -561,9 +636,10 @@ def cmd_clone(args):
     except GitError as e:
         print(f"  Error: {e}")
         sys.exit(1)
+    default_branch = git_default_branch(dest)
     print(f"  Cloned into '{dest}'")
-    print(f"  Note: Media files are not included. Open the project in Resolve and relink any offline clips.")
-    print(f"  Run 'vit checkout main' inside '{dest}' to restore the latest timeline.")
+    print(f"  Note: Media files are not included. Open the project in your NLE and relink any offline clips.")
+    print(f"  Run 'vit checkout {default_branch}' inside '{dest}' to restore the latest timeline.")
 
 
 def cmd_remote(args):
@@ -639,7 +715,7 @@ def cmd_collab_setup(args):
     print()
     print("  Each collaborator should:")
     print("    1. Run: vit clone ")
-    print("    2. Open the project folder in DaVinci Resolve")
+    print("    2. Open the project folder in your NLE (Resolve or Premiere)")
     print("    3. Relink any offline media files")
     print("    4. Create their own branch: vit branch ")
 
@@ -672,6 +748,20 @@ def cmd_uninstall_resolve(args):
         print("  No vit scripts found in Resolve.")
 
 
+def cmd_uninstall_premiere(args):
+    """Remove the Premiere CEP extension on macOS."""
+    if sys.platform != "darwin":
+        print("  Premiere uninstall is currently supported on macOS only.")
+        return
+
+    dest = os.path.join(PREMIERE_CEP_DIR, PREMIERE_EXTENSION_ID)
+    if os.path.islink(dest) or os.path.exists(dest):
+        os.remove(dest)
+        print(f"  Removed: {PREMIERE_EXTENSION_ID}")
+    else:
+        print("  No vit Premiere extension found.")
+
+
 def main():
     parser = argparse.ArgumentParser(
         prog="vit",
@@ -683,6 +773,8 @@ def main():
 
     # init
     p_init = subparsers.add_parser("init", help="Initialize a new vit project")
+    p_init.add_argument("--nle", choices=["resolve", "premiere"], default="resolve",
+                        help="Target NLE (default: resolve)")
     p_init.add_argument("path", nargs="?", help="Project directory (default: current)")
     p_init.set_defaults(func=cmd_init)
 
@@ -782,6 +874,14 @@ def main():
     p_uninstall = subparsers.add_parser("uninstall-resolve", help="Remove scripts from DaVinci Resolve")
     p_uninstall.set_defaults(func=cmd_uninstall_resolve)
 
+    # install-premiere
+    p_install_pr = subparsers.add_parser("install-premiere", help="Install Vit extension for Adobe Premiere Pro")
+    p_install_pr.set_defaults(func=cmd_install_premiere)
+
+    # uninstall-premiere
+    p_uninstall_pr = subparsers.add_parser("uninstall-premiere", help="Remove Vit extension from Adobe Premiere Pro")
+    p_uninstall_pr.set_defaults(func=cmd_uninstall_premiere)
+
     args = parser.parse_args()
 
     if not args.command:
diff --git a/vit/core.py b/vit/core.py
index bf570bc..56c7140 100644
--- a/vit/core.py
+++ b/vit/core.py
@@ -1,5 +1,6 @@
 """Git wrapper — all git operations go through subprocess."""
 
+import json
 import os
 import subprocess
 from typing import List, Optional, Tuple
@@ -50,8 +51,10 @@ def _run(args: List[str], cwd: str, check: bool = True) -> subprocess.CompletedP
 Render/
 Deliver/
 
-# DaVinci Resolve project files (managed by Resolve, not vit)
+# NLE project files (managed by the NLE, not vit)
 *.drp
+*.prproj
+*.prpref
 
 # Environment / secrets
 .env
@@ -63,17 +66,24 @@ def _run(args: List[str], cwd: str, check: bool = True) -> subprocess.CompletedP
 """
 
 
-def git_init(project_dir: str) -> None:
+def git_init(project_dir: str, nle: str = "resolve") -> None:
     """Initialize a new git repo and create .vit/ config."""
     os.makedirs(project_dir, exist_ok=True)
-    _run(["init"], cwd=project_dir)
+    init_result = subprocess.run(
+        ["git", "init", "-b", "main"],
+        cwd=project_dir,
+        capture_output=True,
+        text=True,
+    )
+    if init_result.returncode != 0:
+        _run(["init"], cwd=project_dir)
+        _run(["branch", "-M", "main"], cwd=project_dir)
 
     # Create .vit config directory
     vit_dir = os.path.join(project_dir, ".vit")
     os.makedirs(vit_dir, exist_ok=True)
 
-    import json
-    config = {"version": "0.1.0", "nle": "resolve"}
+    config = {"version": "0.1.0", "nle": nle}
     config_path = os.path.join(vit_dir, "config.json")
     with open(config_path, "w") as f:
         json.dump(config, f, indent=2, sort_keys=True)
@@ -187,8 +197,11 @@ def git_pull(project_dir: str, remote: str = "origin", branch: Optional[str] = N
 
 def git_current_branch(project_dir: str) -> str:
     """Get current branch name."""
-    result = _run(["rev-parse", "--abbrev-ref", "HEAD"], cwd=project_dir)
-    return result.stdout.strip()
+    result = _run(["symbolic-ref", "--short", "HEAD"], cwd=project_dir, check=False)
+    branch = result.stdout.strip()
+    if result.returncode == 0 and branch:
+        return branch
+    return git_default_branch(project_dir)
 
 
 def git_list_branches(project_dir: str) -> List[str]:
@@ -255,6 +268,16 @@ def find_project_root(start_dir: Optional[str] = None) -> Optional[str]:
         current = parent
 
 
+def read_nle(project_dir: str) -> str:
+    """Read the configured NLE type. Defaults to Resolve for older projects."""
+    config_path = os.path.join(project_dir, ".vit", "config.json")
+    try:
+        with open(config_path) as f:
+            return json.load(f).get("nle", "resolve")
+    except (FileNotFoundError, json.JSONDecodeError, OSError):
+        return "resolve"
+
+
 def git_remote_add(project_dir: str, name: str, url: str) -> None:
     """Add a remote."""
     _run(["remote", "add", name, url], cwd=project_dir)
@@ -276,7 +299,7 @@ def git_remote_remove(project_dir: str, name: str) -> None:
     _run(["remote", "remove", name], cwd=project_dir)
 
 
-def git_clone(url: str, dest_dir: str) -> None:
+def git_clone(url: str, dest_dir: str, nle: str = "resolve") -> None:
     """Clone a remote vit repo."""
     result = subprocess.run(
         ["git", "clone", url, dest_dir],
@@ -288,16 +311,29 @@ def git_clone(url: str, dest_dir: str) -> None:
         raise GitError(f"git clone failed: {detail}")
 
     # Ensure .vit/config.json exists so the cloned dir is recognized as a vit project
-    import json
     vit_dir = os.path.join(dest_dir, ".vit")
     config_path = os.path.join(vit_dir, "config.json")
     if not os.path.exists(config_path):
         os.makedirs(vit_dir, exist_ok=True)
-        config = {"version": "0.1.0", "nle": "resolve"}
+        config = {"version": "0.1.0", "nle": nle}
         with open(config_path, "w") as f:
             json.dump(config, f, indent=2, sort_keys=True)
 
 
+def git_default_branch(project_dir: str) -> str:
+    """Best-effort default branch name for a local repo."""
+    for candidate in ("main", "master"):
+        result = _run(["rev-parse", "--verify", candidate], cwd=project_dir, check=False)
+        if result.returncode == 0:
+            return candidate
+
+    current = _run(["rev-parse", "--abbrev-ref", "HEAD"], cwd=project_dir, check=False)
+    branch = current.stdout.strip()
+    if current.returncode == 0 and branch and branch != "HEAD":
+        return branch
+    return "main"
+
+
 def git_config_get(project_dir: str, key: str) -> Optional[str]:
     """Get a git config value for the project."""
     result = _run(["config", key], cwd=project_dir, check=False)