Browse Source

Added playwright e2e tests for cine studio (#268)

* Add Cine Studio, a Premiere-like timeline video editor webapp

A new dark macOS-style video editor under src/web/Cine Studio with:

- Media bin importing from the ArozOS file system (file selector +
  File Manager drag-drop) and from the local device (uploaded to
  user:/Cine Studio/Media so projects stay portable), with probed
  durations, filmstrip thumbnails and decoded waveform peaks
- Multi-track timeline (video/audio) with drag-move, edge trimming,
  blade splitting, snapping, track visibility/mute toggles, zoomable
  timecode ruler and undo/redo history
- Canvas compositing preview player with per-clip transform, crop
  mode, opacity, color controls (exposure/contrast/saturation/presets)
  and a WebAudio mix bus for synced audio playback
- .cine project format (open/save/save-as via ao_module file selector,
  registered as a file association with its own icon)
- Export pipeline: real-time MediaRecorder render to WebM uploaded to
  the ArozOS storage, with optional server-side MP4 conversion through
  the AGI ffmpeg library when the host has ffmpeg installed

Backend AGI scripts create the per-user app folders and expose the
ffmpeg availability check / conversion / cleanup actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Remove macOS traffic lights and implement all stubbed Cine Studio features

Top bar: drop the decorative macOS traffic-light buttons.

Effects (new js/effects.js): per-clip effect stacks (Black & White,
Sepia, Invert, Hue Shift, Blur, Pixelate, Mirror, Vignette, Film Grain,
Fade In/Out) applied by the compositor so preview and export match.
Fades also ramp audio-clip volume. Gallery panel with rendered preview
cards plus an inspector Effects tab with amount sliders, add/remove and
an fx badge on timeline clips.

Titles and Elements (new js/titles.js): generated clips with no backing
media - text titles (content, size, color, weight, align, vertical
position, plain/lower-third/caption styles) and solid/gradient color
boards. They render through a cached offscreen frame so transforms,
color controls, effects and transitions all apply. Inserted from the
Titles/Elements panels, the T tool or the T key onto a free video track
(auto-created when needed).

Transitions (new js/transitions.js): Cross Dissolve, Fade and Wipe
stored on the incoming clip with adjustable duration; the previous
clip's last frame is frozen and blended, with a from-black fallback and
a marker on the clip. Applied from the gallery or the inspector
Transition tab.

Also implemented: Filters panel applying color-preset looks, Libraries
panel listing saved .cine projects (new backend/listprojects.js), media
bin list view toggle, and crop/audio toolbar buttons that jump to the
matching inspector section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add preview direct manipulation, auto track creation and .pxs/.asproj import

Preview (new js/previewctl.js): an overlay canvas on the preview lets
the user click a clip to select it, drag it to reposition and pull a
corner handle to scale it - rotation aware, works for video, image,
title and color clips, with a selection outline and handles drawn over
the frame. Timeline drags now listen on the window because Chrome drops
element pointer capture when the clip is reparented across lanes.

Unlimited tracks: dragging a clip past the outermost lanes creates a
new video/audio track on drop, and dropping bin media onto the empty
timeline area below the lanes does the same, in addition to the
existing add-track button.

Pixel Studio import (.pxs): the layered project is flattened in the
browser - raster layers honoring visibility, opacity and blend mode,
text layers re-rendered from their text data - and used as image media.

Audio Studio import (.asproj): prefers an embedded or referenced
rendered mixdown; otherwise decodes the project's track/clip references
(tolerant field names) and mixes them with an OfflineAudioContext into
a WAV. Both importers keep the original virtual path in the project
file and re-composite when a saved project is reopened.

Both extensions join the import filters, the local-device picker and
the module's SupportedExt list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add the most critical everyday editing features to Cine Studio

Safety and workflow:
- Auto-save (20s, localStorage) with crash-recovery prompt on launch,
  cleared once the project is saved to disk (new js/session.js)
- Recent projects list in the project menu (dedup, last 8)

Editing fundamentals:
- Multi-select (shift-click) with group move, group delete, group copy
- Copy / paste at playhead / duplicate clips (Ctrl+C / V / D)
- Ripple delete closing the gap (Shift+Del, context menu)
- Timeline markers: add/remove at playhead (M), flags on the ruler,
  jump to next (Shift+M), saved in the project, act as snap targets
- Snapping on/off toggle (magnet button)
- Clip speed 25-400%: timeline length, element sync, trimming and
  splitting are all speed aware; audio pitch follows rate

Compositing and playback:
- Blend modes (multiply, screen, overlay, add, soft light, difference)
- Flip horizontal / vertical toggles
- JKL shuttle (reverse scrub / pause / forward up to 8x) and loop
  playback toggle; export temporarily disables looping

Output and audio:
- Export current frame as PNG from the export menu
- Detach audio: video clip audio moves to its own audio-track clip
- Audio track solo via the track header context menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add Playwright E2E suite for Cine Studio + GitHub Actions job

Ports the browser-driven tests used to verify Cine Studio into the
codebase under test/e2e/playwright (outside src/ so they never touch
the Go module). The tests drive the real app in headless Chromium,
generating their own media in-page (canvas -> MediaRecorder WebM,
synthesized WAV) and asserting on rendered pixels, so they exercise the
full compositor / playback / export pipeline rather than mocking it.

Structure:
- lib/static-server.js  minimal static server for the ArozOS web root
- lib/harness.js        browser launch + app navigation + ok()/fail();
                        resolves Playwright's own Chromium (no hardcoded
                        path), overridable via PW_CHROMIUM_PATH
- run.js                serves src/web, runs each spec in its own process,
                        exits non-zero on any failure
- specs/functional.js   media probe, playback, edit, round-trip, WebM export
- specs/features.js     effects, titles, transitions, elements, filters
- specs/interaction.js  preview drag/resize, auto tracks, .pxs/.asproj import
- specs/editing.js      multi-select, copy/paste, speed, JKL, markers, autosave

41 assertions across the four specs, all passing locally.

CI: .github/workflows/e2e-playwright.yml installs Node + Playwright
Chromium and runs `npm test`, scoped to changes under the app or the
harness so unrelated commits don't trigger it. Front-end only - runs
independently of the Go build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung 1 month ago
parent
commit
45b719da31

+ 45 - 0
.github/workflows/e2e-playwright.yml

@@ -0,0 +1,45 @@
+name: E2E (Cine Studio)
+
+# Browser-driven end-to-end tests for the Cine Studio WebApp. These are
+# front-end only (Node + Playwright + a static file server) and do not
+# touch the Go build, so they run as an independent job. Scoped to changes
+# under the app or the test harness to avoid running on unrelated commits.
+
+on:
+  push:
+    paths:
+      - "src/web/Cine Studio/**"
+      - "test/e2e/playwright/**"
+      - ".github/workflows/e2e-playwright.yml"
+  pull_request:
+    paths:
+      - "src/web/Cine Studio/**"
+      - "test/e2e/playwright/**"
+      - ".github/workflows/e2e-playwright.yml"
+
+permissions:
+  contents: read
+
+jobs:
+  playwright:
+    runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: test/e2e/playwright
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: "20"
+
+      - name: Install dependencies
+        run: npm install --no-audit --no-fund
+
+      - name: Install Playwright Chromium (with OS deps)
+        run: npx playwright install --with-deps chromium
+
+      - name: Run E2E tests
+        run: npm test

+ 4 - 0
test/e2e/playwright/.gitignore

@@ -0,0 +1,4 @@
+node_modules/
+package-lock.json
+test-results/
+*.png

+ 57 - 0
test/e2e/playwright/README.md

@@ -0,0 +1,57 @@
+# Cine Studio — end-to-end tests
+
+Browser-driven Playwright tests for the **Cine Studio** WebApp
+(`src/web/Cine Studio`). They drive the real app in headless Chromium,
+generating their own media in-page (canvas → MediaRecorder WebM, synthesized
+WAV) and asserting on actual rendered pixels, so they exercise the full
+compositor/playback/export pipeline rather than mocking it.
+
+These tests are front-end only. They do **not** build or run any Go code and
+live outside `src/` so they never interfere with the Go module.
+
+## Layout
+
+```
+test/e2e/playwright/
+├── package.json          Playwright dependency + `npm test`
+├── run.js                orchestrator: serves src/web, runs each spec
+├── lib/
+│   ├── static-server.js  minimal static server for the ArozOS web root
+│   └── harness.js        browser launch, app navigation, ok()/fail()
+└── specs/
+    ├── functional.js     media probe, playback, edit, round-trip, WebM export
+    ├── features.js       effects, titles, transitions, elements, filters
+    ├── interaction.js    preview drag/resize, auto tracks, .pxs/.asproj import
+    └── editing.js        multi-select, copy/paste, speed, JKL, markers, autosave…
+```
+
+## Running locally
+
+```bash
+cd test/e2e/playwright
+npm install
+npx playwright install chromium
+npm test
+```
+
+`run.js` starts a static server for `src/web` (default port 8123) and runs
+every spec in `specs/` in its own process, exiting non-zero if any fails.
+
+### Useful env vars
+
+| Var | Purpose |
+| --- | --- |
+| `CS_BASE_URL` | Point a spec at an already-running server instead of starting one |
+| `WEB_PORT` | Port for the built-in static server (default `8123`) |
+| `PW_CHROMIUM_PATH` | Use a preinstalled Chromium binary instead of Playwright's own |
+
+Run a single spec against the running server:
+
+```bash
+CS_BASE_URL=http://127.0.0.1:8123 node specs/functional.js
+```
+
+## CI
+
+`.github/workflows/e2e-playwright.yml` runs the suite on pushes and pull
+requests that touch the app or this harness.

+ 63 - 0
test/e2e/playwright/lib/harness.js

@@ -0,0 +1,63 @@
+/*
+    Shared harness for the Cine Studio E2E specs.
+
+    Each spec is a standalone runnable Node script (so it works both via
+    `node run.js` and directly, e.g. `CS_BASE_URL=... node specs/functional.js`).
+    The harness centralizes browser launch, app navigation and the
+    pass/fail reporting helpers.
+
+    Env:
+      CS_BASE_URL       base URL the app is served from (default :8123)
+      PW_CHROMIUM_PATH  optional explicit Chromium binary; otherwise
+                        Playwright resolves its own installed browser
+*/
+"use strict";
+
+const { chromium } = require("playwright");
+
+const BASE = process.env.CS_BASE_URL || "http://127.0.0.1:8123";
+const APP_URL = BASE + "/Cine%20Studio/index.html";
+
+function ok(msg) { console.log("  PASS: " + msg); }
+
+function fail(msg) {
+    console.error("  FAIL: " + msg);
+    process.exit(1);
+}
+
+function launch() {
+    return chromium.launch({
+        // Undefined lets Playwright resolve its installed browser (CI);
+        // set PW_CHROMIUM_PATH to point at a preinstalled binary locally.
+        executablePath: process.env.PW_CHROMIUM_PATH || undefined,
+        args: ["--autoplay-policy=no-user-gesture-required"]
+    });
+}
+
+// Open the app and wait until the editor global (CS) has booted.
+async function openApp(browser, viewport) {
+    const page = await browser.newPage({ viewport: viewport || { width: 1280, height: 853 } });
+    page.on("pageerror", function (e) { console.log("  [pageerror] " + e.message); });
+    await page.goto(APP_URL, { waitUntil: "networkidle" });
+    await page.waitForFunction(function () { return window.CS && CS.project; });
+    return page;
+}
+
+// Wrap a spec body: run it, report a banner, exit non-zero on throw.
+function run(name, body) {
+    (async function () {
+        const browser = await launch();
+        try {
+            const page = await openApp(browser);
+            await body(page, browser);
+            console.log("ALL " + name + " TESTS PASSED");
+        } finally {
+            await browser.close();
+        }
+    })().catch(function (e) {
+        console.error(e);
+        process.exit(1);
+    });
+}
+
+module.exports = { BASE, APP_URL, ok, fail, launch, openApp, run };

+ 83 - 0
test/e2e/playwright/lib/static-server.js

@@ -0,0 +1,83 @@
+/*
+    Minimal static file server for the Cine Studio E2E tests.
+
+    Serves the ArozOS web root (src/web) so the app and its shared
+    scripts (../script/*) load exactly as they do in production. The
+    tests seed their own media as in-memory blobs and stub the ArozOS
+    backend, so server-side AGI endpoints (/system/*, /media) are not
+    needed here and simply 404 - which the app already handles.
+*/
+"use strict";
+
+const http = require("http");
+const fs = require("fs");
+const path = require("path");
+
+const MIME = {
+    ".html": "text/html; charset=utf-8",
+    ".js": "text/javascript; charset=utf-8",
+    ".css": "text/css; charset=utf-8",
+    ".json": "application/json; charset=utf-8",
+    ".png": "image/png",
+    ".jpg": "image/jpeg",
+    ".jpeg": "image/jpeg",
+    ".gif": "image/gif",
+    ".webp": "image/webp",
+    ".svg": "image/svg+xml",
+    ".ico": "image/x-icon",
+    ".woff": "font/woff",
+    ".woff2": "font/woff2",
+    ".ttf": "font/ttf",
+    ".map": "application/json; charset=utf-8"
+};
+
+// Create (but do not start) a server rooted at webRoot.
+function createServer(webRoot) {
+    const root = path.resolve(webRoot);
+    return http.createServer(function (req, res) {
+        let urlPath;
+        try {
+            urlPath = decodeURIComponent(req.url.split("?")[0].split("#")[0]);
+        } catch (e) {
+            res.writeHead(400);
+            res.end("bad request");
+            return;
+        }
+        if (urlPath.endsWith("/")) { urlPath += "index.html"; }
+
+        // Resolve within the web root, rejecting path traversal.
+        const target = path.join(root, urlPath);
+        if (target !== root && !target.startsWith(root + path.sep)) {
+            res.writeHead(403);
+            res.end("forbidden");
+            return;
+        }
+
+        fs.stat(target, function (err, stat) {
+            if (err || !stat.isFile()) {
+                res.writeHead(404);
+                res.end("not found");
+                return;
+            }
+            res.writeHead(200, {
+                "Content-Type": MIME[path.extname(target).toLowerCase()] || "application/octet-stream",
+                "Cache-Control": "no-store"
+            });
+            fs.createReadStream(target).pipe(res);
+        });
+    });
+}
+
+// Start a server and resolve with { server, port, baseURL }.
+function start(webRoot, port) {
+    return new Promise(function (resolve, reject) {
+        const server = createServer(webRoot);
+        server.on("error", reject);
+        server.listen(port || 0, "127.0.0.1", function () {
+            const actual = server.address().port;
+            resolve({ server: server, port: actual, baseURL: "http://127.0.0.1:" + actual });
+        });
+    });
+}
+
+module.exports = { createServer, start };

+ 12 - 0
test/e2e/playwright/package.json

@@ -0,0 +1,12 @@
+{
+  "name": "cinestudio-e2e",
+  "version": "1.0.0",
+  "private": true,
+  "description": "Playwright end-to-end tests for the Cine Studio ArozOS WebApp",
+  "scripts": {
+    "test": "node run.js"
+  },
+  "devDependencies": {
+    "playwright": "1.56.1"
+  }
+}

+ 64 - 0
test/e2e/playwright/run.js

@@ -0,0 +1,64 @@
+/*
+    Cine Studio E2E runner.
+
+    Starts a static server for the ArozOS web root, then runs each spec
+    in its own Node process against it. Exits non-zero if any spec fails,
+    so CI turns red on the first regression. The banner from each spec's
+    final assertion is printed inline.
+*/
+"use strict";
+
+const path = require("path");
+const fs = require("fs");
+const { spawn } = require("child_process");
+const staticServer = require("./lib/static-server");
+
+const WEB_ROOT = path.resolve(__dirname, "../../../src/web");
+const SPECS_DIR = path.join(__dirname, "specs");
+
+function runSpec(specPath, baseURL) {
+    return new Promise(function (resolve) {
+        const child = spawn(process.execPath, [specPath], {
+            stdio: "inherit",
+            env: Object.assign({}, process.env, { CS_BASE_URL: baseURL })
+        });
+        child.on("exit", function (code) { resolve(code || 0); });
+    });
+}
+
+(async function () {
+    if (!fs.existsSync(path.join(WEB_ROOT, "Cine Studio", "index.html"))) {
+        console.error("Cannot find Cine Studio web app under " + WEB_ROOT);
+        process.exit(1);
+    }
+
+    const specs = fs.readdirSync(SPECS_DIR)
+        .filter(function (f) { return f.endsWith(".js"); })
+        .sort();
+    if (!specs.length) {
+        console.error("No specs found in " + SPECS_DIR);
+        process.exit(1);
+    }
+
+    const { server, baseURL } = await staticServer.start(WEB_ROOT, Number(process.env.WEB_PORT) || 8123);
+    console.log("Serving " + WEB_ROOT + " at " + baseURL + "\n");
+
+    let failures = 0;
+    for (const spec of specs) {
+        console.log("── " + spec + " ──────────────────────────────────");
+        const code = await runSpec(path.join(SPECS_DIR, spec), baseURL);
+        if (code !== 0) { failures++; console.log("  spec exited with code " + code); }
+        console.log("");
+    }
+
+    server.close();
+
+    if (failures) {
+        console.error(failures + " of " + specs.length + " spec file(s) failed.");
+        process.exit(1);
+    }
+    console.log("All " + specs.length + " spec file(s) passed.");
+})().catch(function (e) {
+    console.error(e);
+    process.exit(1);
+});

+ 284 - 0
test/e2e/playwright/specs/editing.js

@@ -0,0 +1,284 @@
+/*
+    Iteration 4 tests: autosave/recovery, recents, copy/paste/duplicate,
+    multi-select, ripple delete, markers, snap toggle, clip speed,
+    blend modes, flip, JKL shuttle, loop, export frame, detach audio,
+    audio track solo.
+*/
+"use strict";
+
+const { ok, fail, run } = require("../lib/harness");
+
+run("EDITING", async (page, browser) => {
+    await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} });
+
+    /* ---- seed real media ---- */
+    await page.evaluate(async () => {
+        function makeVideo(seconds, hue) {
+            return new Promise((resolve) => {
+                const cv = document.createElement("canvas");
+                cv.width = 320; cv.height = 180;
+                const ctx = cv.getContext("2d");
+                const stream = cv.captureStream(15);
+                const rec = new MediaRecorder(stream, { mimeType: "video/webm" });
+                const chunks = [];
+                rec.ondataavailable = e => e.data.size && chunks.push(e.data);
+                rec.onstop = () => resolve(new Blob(chunks, { type: "video/webm" }));
+                const t0 = performance.now();
+                (function draw() {
+                    ctx.fillStyle = `hsl(${hue}, 65%, 45%)`;
+                    ctx.fillRect(0, 0, 320, 180);
+                    if ((performance.now() - t0) / 1000 < seconds) { requestAnimationFrame(draw); } else { rec.stop(); }
+                })();
+                rec.start(200);
+            });
+        }
+        const v = await makeVideo(3, 210);
+        window.__vid = CS.media.register({ name: "Base.webm", blobUrl: URL.createObjectURL(v), type: "video" });
+        // asymmetric test image: left red, right blue
+        const ic = document.createElement("canvas");
+        ic.width = 320; ic.height = 180;
+        const ictx = ic.getContext("2d");
+        ictx.fillStyle = "#c62828"; ictx.fillRect(0, 0, 160, 180);
+        ictx.fillStyle = "#1e63c9"; ictx.fillRect(160, 0, 160, 180);
+        window.__img = CS.media.register({ name: "Split.png", blobUrl: ic.toDataURL("image/png"), type: "image" });
+    });
+    await page.waitForFunction(() => window.__vid.probed && window.__img.probed, { timeout: 30000 });
+
+    const px = (fx, fy) => page.evaluate(([fx, fy]) => {
+        const cv = document.getElementById("preview-canvas");
+        const d = cv.getContext("2d").getImageData(Math.floor(cv.width * fx), Math.floor(cv.height * fy), 1, 1).data;
+        return [d[0], d[1], d[2]];
+    }, [fx, fy]);
+
+    /* ---- 1. copy / paste / duplicate / multi-select / ripple ---- */
+    const editing = await page.evaluate(() => {
+        const c1 = CS.addClipToTimeline(window.__vid, "V1", 0);
+        const c2 = CS.addClipToTimeline(window.__vid, "V1", CS.clipDuration(c1));
+        CS.commit("seed");
+        window.__c1 = c1; window.__c2 = c2;
+
+        // multi-select both
+        CS.selectClip(c1.id);
+        CS.toggleSelectClip(c2.id);
+        const multi = CS.state.selectedClipIds.length;
+
+        // copy + paste at playhead 10
+        CS.copySelectedClips();
+        CS.state.playhead = 10; //seek() clamps to timeline duration
+        CS.pasteClipsAtPlayhead();
+        const afterPaste = CS.project.clips.length;
+
+        // duplicate the pasted pair
+        CS.duplicateSelectedClips();
+        const afterDup = CS.project.clips.length;
+
+        // group delete the 4 new ones
+        CS.deleteSelectedClip(); // deletes duplicated selection (2)
+        CS.selectClip(CS.project.clips.filter(c => c.start >= 9)[0].id);
+        CS.toggleSelectClip(CS.project.clips.filter(c => c.start >= 9)[1].id);
+        CS.deleteSelectedClip();
+        const afterCleanup = CS.project.clips.length;
+
+        // ripple delete c1: c2 shifts to 0
+        CS.selectClip(window.__c1.id);
+        CS.rippleDeleteSelected();
+        return {
+            multi, afterPaste, afterDup, afterCleanup,
+            c2start: CS.getClip(window.__c2.id).start,
+            n: CS.project.clips.length
+        };
+    });
+    if (editing.multi !== 2) fail("multi-select failed");
+    if (editing.afterPaste !== 4) fail("paste failed: " + editing.afterPaste);
+    if (editing.afterDup !== 6) fail("duplicate failed: " + editing.afterDup);
+    if (editing.afterCleanup !== 2) fail("group delete failed: " + editing.afterCleanup);
+    if (editing.n !== 1 || editing.c2start > 0.01) fail("ripple delete did not close gap: " + JSON.stringify(editing));
+    ok(`multi-select/copy/paste/duplicate/group-delete/ripple all work (c2 rippled to ${editing.c2start})`);
+
+    /* ---- 2. markers ---- */
+    const markers = await page.evaluate(() => {
+        CS.player.seek(1.0); CS.toggleMarkerAtPlayhead();
+        CS.player.seek(2.0); CS.toggleMarkerAtPlayhead();
+        CS.player.seek(0);
+        CS.gotoMarker(1);
+        const jumped = CS.state.playhead;
+        CS.player.seek(2.0);
+        CS.toggleMarkerAtPlayhead(); // remove marker at 2.0
+        return { n: CS.project.markers.length, jumped };
+    });
+    if (Math.abs(markers.jumped - 1.0) > 0.02) fail("goto marker failed: " + markers.jumped);
+    if (markers.n !== 1) fail("marker toggle-remove failed: " + markers.n);
+    ok("markers: add, jump, remove");
+
+    /* ---- 3. snap toggle ---- */
+    const snap = await page.evaluate(() => {
+        const clip = CS.project.clips[0];
+        CS.state.snap = true;
+        const snapped = CS.timeline.applySnap(CS.state.playhead + 0.05, clip, "trim");
+        CS.state.snap = false;
+        const unsnapped = CS.timeline.applySnap(CS.state.playhead + 0.05, clip, "trim");
+        CS.state.snap = true;
+        return { snapped, unsnapped, ph: CS.state.playhead };
+    });
+    if (Math.abs(snap.snapped - snap.ph) > 0.001) fail("snap did not snap to playhead");
+    if (Math.abs(snap.unsnapped - snap.ph) < 0.001) fail("snap-off still snapped");
+    ok("snapping toggle works");
+
+    /* ---- 4. clip speed ---- */
+    const speed = await page.evaluate(() => {
+        const clip = CS.project.clips[0];
+        window.__c = clip;
+        const durBefore = CS.clipDuration(clip);
+        clip.props.speed = 2;
+        CS.commit("speed");
+        const durAfter = CS.clipDuration(clip);
+        CS.player.seek(0.5);
+        const el = CS.player.pool[clip.id];
+        return { durBefore, durAfter, elTime: el ? el.currentTime : -1, expect: clip.in + 0.5 * 2 };
+    });
+    if (Math.abs(speed.durAfter - speed.durBefore / 2) > 0.01) fail("2x speed did not halve duration");
+    if (Math.abs(speed.elTime - speed.expect) > 0.1) fail("speed-aware seek wrong: " + JSON.stringify(speed));
+    ok(`clip speed 2x: duration ${speed.durBefore.toFixed(2)}s -> ${speed.durAfter.toFixed(2)}s, source seek correct`);
+    await page.evaluate(() => { window.__c.props.speed = 1; CS.commit("speed reset"); });
+
+    /* ---- 5. flip horizontal (asymmetric image) ---- */
+    await page.evaluate(() => {
+        CS.project.clips = [];
+        const c = CS.addClipToTimeline(window.__img, "V1", 0);
+        c.props.crop = "fill";
+        window.__ic = c;
+        CS.commit("img");
+        CS.player.seek(0.5);
+    });
+    await page.waitForTimeout(300);
+    const left0 = await px(0.25, 0.5);
+    await page.evaluate(() => { window.__ic.props.flipH = true; CS.player.render(); });
+    const left1 = await px(0.25, 0.5);
+    if (!(left0[0] > 140 && left0[2] < 90)) fail("baseline left not red: " + left0);
+    if (!(left1[2] > 120 && left1[0] < 90)) fail("flipH left not blue: " + left1);
+    ok(`flip horizontal verified (left rgb ${left0} -> ${left1})`);
+
+    /* ---- 6. blend mode (additive over color boards) ---- */
+    const blend = await page.evaluate(() => {
+        CS.project.clips = [];
+        CS.player.seek(0.5);
+        CS.titles.insertElement("red");    // lands on V1 (empty)
+        const red = CS.selectedClip();
+        red.start = 0;
+        CS.titles.insertElement("blue");   // V1 busy -> V2
+        const blue = CS.selectedClip();
+        blue.start = 0;
+        blue.props.blend = "lighter";
+        CS.commit("blend");
+        CS.player.render();
+        const cv = document.getElementById("preview-canvas");
+        const d = cv.getContext("2d").getImageData(Math.floor(cv.width / 2), Math.floor(cv.height / 2), 1, 1).data;
+        return [d[0], d[1], d[2]];
+    });
+    if (!(blend[0] > 140 && blend[2] > 140)) fail("additive blend not magenta-ish: " + blend);
+    ok(`blend mode Add composites (rgb ${blend})`);
+
+    /* ---- 7. JKL + loop ---- */
+    await page.evaluate(() => {
+        CS.project.clips = [];
+        const c = CS.addClipToTimeline(window.__vid, "V1", 0);
+        c.out = Math.min(c.out, 1.2);
+        CS.commit("loop seed");
+        CS.player.seek(0);
+    });
+    await page.keyboard.press("l");
+    await page.keyboard.press("l");
+    const rate = await page.evaluate(() => CS.player.rate);
+    if (rate !== 2) fail("LL did not reach 2x: " + rate);
+    await page.keyboard.press("k");
+    const paused = await page.evaluate(() => !CS.state.playing);
+    if (!paused) fail("K did not pause");
+    await page.evaluate(() => { CS.state.loop = true; CS.player.seek(0); });
+    await page.click("#btn-play");
+    await page.waitForTimeout(1900); // > clip length: must have looped
+    const loopState = await page.evaluate(() => ({ playing: CS.state.playing, ph: CS.state.playhead, dur: CS.timelineDuration() }));
+    if (!loopState.playing) fail("loop did not keep playing: " + JSON.stringify(loopState));
+    if (loopState.ph >= loopState.dur) fail("loop did not wrap");
+    await page.evaluate(() => { CS.player.pause(); CS.state.loop = false; });
+    ok(`JKL shuttle + loop playback (wrapped to ${loopState.ph.toFixed(2)}s)`);
+
+    /* ---- 8. export current frame (PNG) ---- */
+    const frameSize = await page.evaluate(() => new Promise((resolve) => {
+        CS._forceStandalone = true; // use the download path in the test env
+        CS.fileio.downloadBlob = function (blob) { resolve(blob.size); };
+        CS.player.seek(0.4);
+        CS.exporter.exportFrame();
+    }));
+    await page.evaluate(() => { CS._forceStandalone = false; });
+    if (frameSize < 2000) fail("frame PNG too small: " + frameSize);
+    ok(`export current frame produced PNG of ${frameSize} bytes`);
+
+    /* ---- 9. detach audio ---- */
+    const detach = await page.evaluate(() => {
+        CS.project.clips = [];
+        const c = CS.addClipToTimeline(window.__vid, "V1", 0);
+        CS.commit("detach seed");
+        CS.detachAudio(c);
+        const audioClips = CS.project.clips.filter(cl => CS.getTrack(cl.trackId).kind === "audio");
+        return {
+            n: CS.project.clips.length,
+            audio: audioClips.length,
+            origVol: c.props.volume,
+            flagged: c.props.audioDetached,
+            track: audioClips.length ? audioClips[0].trackId : ""
+        };
+    });
+    if (detach.n !== 2 || detach.audio !== 1) fail("detach did not create audio clip: " + JSON.stringify(detach));
+    if (detach.origVol !== 0 || !detach.flagged) fail("original clip audio not silenced");
+    ok(`detach audio -> ${detach.track}, original muted`);
+
+    /* ---- 10. track solo ---- */
+    const solo = await page.evaluate(() => {
+        const a1 = CS.getTrack("A1");
+        a1.solo = true;
+        const det = CS.project.clips.filter(cl => CS.getTrack(cl.trackId).kind === "audio")[0];
+        // move detached clip to A2 so it should be muted by the solo
+        det.trackId = "A2";
+        CS.player.syncElements();
+        const el = CS.player.pool[det.id];
+        const mutedWhenSolo = el.muted;
+        a1.solo = false;
+        CS.player.syncElements();
+        return { mutedWhenSolo, mutedAfter: el.muted };
+    });
+    if (!solo.mutedWhenSolo) fail("solo did not mute other audio track");
+    if (solo.mutedAfter) fail("unsolo did not restore");
+    ok("audio track solo mutes other audio tracks");
+
+    /* ---- 11. autosave + recovery ---- */
+    await page.evaluate(() => {
+        CS.project.name = "RecoverMe";
+        CS.markDirty();
+        CS.session.saveSnapshot();
+    });
+    const hasSnap = await page.evaluate(() => !!localStorage.getItem("cinestudio_autosave"));
+    if (!hasSnap) fail("autosave snapshot missing");
+    await page.reload({ waitUntil: "networkidle" });
+    await page.waitForFunction(() => window.CS && CS.project);
+    await page.waitForSelector(".modal", { timeout: 5000 }).catch(() => fail("recovery modal did not appear"));
+    const modalText = await page.evaluate(() => document.querySelector(".modal").textContent);
+    if (modalText.indexOf("RecoverMe") < 0) fail("recovery modal missing project name");
+    await page.click(".modal-btn.primary"); // Restore
+    const restored = await page.evaluate(() => ({
+        name: CS.project.name,
+        clips: CS.project.clips.length
+    }));
+    if (restored.name !== "RecoverMe") fail("restore did not recover name: " + restored.name);
+    if (restored.clips < 2) fail("restore did not recover clips: " + restored.clips);
+    ok(`autosave recovery restored "${restored.name}" with ${restored.clips} clips`);
+
+    /* ---- 12. recent projects list ---- */
+    const recents = await page.evaluate(() => {
+        CS.session.recordRecent("Alpha", "user:/Cine Studio/Projects/Alpha.cine");
+        CS.session.recordRecent("Beta", "user:/Cine Studio/Projects/Beta.cine");
+        CS.session.recordRecent("Alpha", "user:/Cine Studio/Projects/Alpha.cine"); // dedupe to front
+        return CS.session.recents().map(r => r.name);
+    });
+    if (recents.length !== 2 || recents[0] !== "Alpha") fail("recents list wrong: " + recents);
+    ok("recent projects list records and dedupes");
+});

+ 226 - 0
test/e2e/playwright/specs/features.js

@@ -0,0 +1,226 @@
+/*
+    Feature test for the second iteration: effects, titles, transitions,
+    elements, filters, list view - verified end to end with real
+    in-browser generated media and pixel sampling.
+*/
+"use strict";
+
+const { ok, fail, run } = require("../lib/harness");
+
+run("FEATURE", async (page, browser) => {
+
+    // traffic lights must be gone
+    const hasTL = await page.evaluate(() => !!document.getElementById("traffic-lights"));
+    if (hasTL) fail("traffic lights still present");
+    ok("macOS traffic lights removed");
+
+    /* ---- media: one real video clip ---- */
+    await page.evaluate(async () => {
+        function makeVideo(seconds, hue) {
+            return new Promise((resolve) => {
+                const cv = document.createElement("canvas");
+                cv.width = 320; cv.height = 180;
+                const ctx = cv.getContext("2d");
+                const stream = cv.captureStream(15);
+                const rec = new MediaRecorder(stream, { mimeType: "video/webm" });
+                const chunks = [];
+                rec.ondataavailable = e => e.data.size && chunks.push(e.data);
+                rec.onstop = () => resolve(new Blob(chunks, { type: "video/webm" }));
+                const t0 = performance.now();
+                (function draw() {
+                    ctx.fillStyle = `hsl(${hue}, 65%, 45%)`;
+                    ctx.fillRect(0, 0, 320, 180);
+                    if ((performance.now() - t0) / 1000 < seconds) { requestAnimationFrame(draw); } else { rec.stop(); }
+                })();
+                rec.start(200);
+            });
+        }
+        const blob = await makeVideo(3, 210);
+        window.__m = CS.media.register({ name: "Base.webm", blobUrl: URL.createObjectURL(blob), type: "video" });
+    });
+    await page.waitForFunction(() => window.__m.probed, { timeout: 30000 });
+
+    const samplePx = () => page.evaluate(() => {
+        const cv = document.getElementById("preview-canvas");
+        const d = cv.getContext("2d").getImageData(Math.floor(cv.width / 2), Math.floor(cv.height / 2), 1, 1).data;
+        return [d[0], d[1], d[2]];
+    });
+
+    /* ---- effects ---- */
+    await page.evaluate(() => {
+        const c = CS.addClipToTimeline(window.__m, "V1", 0);
+        window.__clip = c;
+        CS.commit("seed");
+        CS.selectClip(c.id);
+        CS.player.seek(1.0);
+    });
+    await page.waitForTimeout(700);
+    const base = await samplePx();
+    if (base[0] + base[1] + base[2] < 20) fail("baseline frame is black");
+
+    await page.evaluate(() => CS.effects.applyToClip(window.__clip, "bw"));
+    await page.waitForTimeout(250);
+    const bw = await samplePx();
+    if (Math.abs(bw[0] - bw[1]) > 6 || Math.abs(bw[1] - bw[2]) > 6) fail("B&W effect not applied: " + bw);
+    ok(`B&W effect desaturates (rgb ${bw})`);
+
+    await page.evaluate(() => {
+        CS.effects.removeFromClip(window.__clip, "bw");
+        CS.effects.applyToClip(window.__clip, "invert");
+    });
+    await page.waitForTimeout(250);
+    const inv = await samplePx();
+    if (Math.abs(inv[0] - base[0]) < 30 && Math.abs(inv[2] - base[2]) < 30) fail("invert changed nothing: " + inv + " vs " + base);
+    ok(`invert flips colors (rgb ${base} -> ${inv})`);
+
+    await page.evaluate(() => {
+        CS.effects.removeFromClip(window.__clip, "invert");
+        CS.effects.applyToClip(window.__clip, "fadein"); // 1s default
+        CS.player.seek(0.06);
+    });
+    await page.waitForTimeout(500);
+    const faded = await samplePx();
+    if (faded[0] + faded[1] + faded[2] > base[0] + base[1] + base[2] * 0.5) fail("fade-in start not dark: " + faded);
+    await page.evaluate(() => CS.player.seek(2.0));
+    await page.waitForTimeout(400);
+    const unfaded = await samplePx();
+    if (unfaded[0] + unfaded[1] + unfaded[2] < 20) fail("frame after fade window is dark");
+    ok(`fade-in ramps alpha (start rgb ${faded}, later rgb ${unfaded})`);
+
+    await page.evaluate(() => {
+        CS.effects.applyToClip(window.__clip, "pixelate");
+        CS.effects.applyToClip(window.__clip, "vignette");
+        CS.effects.applyToClip(window.__clip, "grain");
+        CS.player.seek(1.5);
+    });
+    await page.waitForTimeout(300);
+    const fxCount = await page.evaluate(() => window.__clip.props.effects.length);
+    if (fxCount !== 4) fail("expected 4 effects on clip, got " + fxCount);
+    const fxBadge = await page.evaluate(() => !!document.querySelector(".tl-clip .clip-fx"));
+    if (!fxBadge) fail("fx badge missing on timeline clip");
+    ok("pixelate + vignette + grain stack renders, fx badge shown");
+
+    /* ---- undo restores effect stack ---- */
+    const undoCount = await page.evaluate(() => { CS.undo(); return window.__clip ? CS.getClip(window.__clip.id).props.effects.length : -1; });
+    if (undoCount !== 3) fail("undo did not pop effect stack: " + undoCount);
+    await page.evaluate(() => CS.redo());
+    ok("undo/redo covers effects");
+
+    /* ---- titles ---- */
+    await page.evaluate(() => {
+        CS.player.seek(1.5);
+        CS.titles.insertPreset("title");
+        const t = CS.selectedClip();
+        t.props.text.content = "BIG";
+        t.props.text.size = 400;
+        CS.titles.invalidate(t);
+        window.__title = t;
+        CS.player.render();
+    });
+    await page.waitForTimeout(300);
+    const titleInfo = await page.evaluate(() => ({
+        kind: window.__title.kind,
+        track: window.__title.trackId,
+        tracks: CS.project.tracks.filter(t => t.kind === "video").length
+    }));
+    if (titleInfo.kind !== "title") fail("title clip not created");
+    if (titleInfo.track === "V1") fail("title landed on the busy V1 track");
+    const titlePx = await samplePx();
+    if (titlePx[0] < 180 || titlePx[1] < 180 || titlePx[2] < 180) fail("title text not visible at center: " + titlePx);
+    ok(`title clip renders on ${titleInfo.track} (center rgb ${titlePx})`);
+
+    /* ---- elements (color board) ---- */
+    await page.evaluate(() => {
+        CS.deleteSelectedClip(); // remove title again
+        CS.player.seek(10);
+        CS.titles.insertElement("red");
+        CS.player.render();
+    });
+    await page.waitForTimeout(200);
+    const redPx = await samplePx();
+    if (!(redPx[0] > 130 && redPx[1] < 90)) fail("color element not red: " + redPx);
+    await page.evaluate(() => { CS.deleteSelectedClip(); });
+    ok(`color element renders (rgb ${redPx})`);
+
+    /* ---- transitions ---- */
+    await page.evaluate(() => {
+        // second clip right after the first, dissolve into it
+        const c2 = CS.addClipToTimeline(window.__m, "V1", CS.clipEnd(window.__clip));
+        window.__clip2 = c2;
+        CS.selectClip(c2.id);
+        CS.transitions.applyToSelected("dissolve");
+        window.__clip2 = CS.getClip(c2.id);
+    });
+    const trInfo = await page.evaluate(() => ({
+        type: window.__clip2.props.transition && window.__clip2.props.transition.type,
+        frozen: Object.keys(CS.transitions.frozenTargets(window.__clip2.start + 0.3)).length,
+        marker: !!document.querySelector(".tl-clip .clip-tr")
+    }));
+    if (trInfo.type !== "dissolve") fail("transition not stored");
+    if (trInfo.frozen !== 1) fail("frozen predecessor not detected");
+    if (!trInfo.marker) fail("transition marker missing on clip");
+    await page.evaluate(() => CS.player.seek(window.__clip2.start + 0.3));
+    await page.waitForTimeout(600);
+    const trPx = await samplePx();
+    if (trPx[0] + trPx[1] + trPx[2] < 20) fail("transition window renders black: " + trPx);
+    ok(`dissolve transition renders mid-window (rgb ${trPx})`);
+
+    /* ---- filters panel + galleries render ---- */
+    const galleries = await page.evaluate(() => {
+        CS.panels.show("fx");
+        const fx = document.querySelectorAll("#fx-grid .fx-card").length;
+        CS.panels.show("titles");
+        const ti = document.querySelectorAll("#titles-grid .fx-card").length;
+        CS.panels.show("transitions");
+        const tr = document.querySelectorAll("#transitions-grid .fx-card").length;
+        CS.panels.show("elements");
+        const el = document.querySelectorAll("#elements-grid .fx-card").length;
+        CS.panels.show("filters");
+        const fi = document.querySelectorAll("#filters-grid .fx-card").length;
+        CS.panels.show("media");
+        return { fx, ti, tr, el, fi };
+    });
+    if (galleries.fx < 10) fail("effects gallery incomplete: " + galleries.fx);
+    if (galleries.ti < 4 || galleries.tr < 3 || galleries.el < 8 || galleries.fi < 5) {
+        fail("gallery counts wrong: " + JSON.stringify(galleries));
+    }
+    ok(`galleries render (${galleries.fx} effects, ${galleries.ti} titles, ${galleries.tr} transitions, ${galleries.el} elements, ${galleries.fi} filters)`);
+
+    /* ---- filter look via inspector.applyPreset ---- */
+    await page.evaluate(() => {
+        CS.selectClip(window.__clip.id);
+        CS.inspector.applyPreset(CS.getClip(window.__clip.id), "mono");
+    });
+    const preset = await page.evaluate(() => CS.getClip(window.__clip.id).props.preset);
+    if (preset !== "mono") fail("filter preset not applied");
+    ok("filter look applies color preset");
+
+    /* ---- list view toggle ---- */
+    await page.click("#btn-bin-view");
+    const listMode = await page.evaluate(() => document.getElementById("bin-grid").classList.contains("list-mode"));
+    if (!listMode) fail("list view did not activate");
+    await page.click("#btn-bin-view");
+    ok("media bin list view toggles");
+
+    /* ---- serialization round trip with generated clips ---- */
+    const rt = await page.evaluate(() => {
+        CS.player.seek(1.5);
+        CS.titles.insertPreset("lowerthird");
+        const before = CS.project.clips.length;
+        const beforeTitles = CS.project.clips.filter(c => c.kind === "title").length;
+        const json = CS.fileio.serializeProject();
+        CS.fileio.loadProject(JSON.parse(json), "", "rt.cine");
+        return {
+            before, beforeTitles,
+            after: CS.project.clips.length,
+            afterTitles: CS.project.clips.filter(c => c.kind === "title").length,
+            transition: CS.project.clips.some(c => c.props.transition && c.props.transition.type === "dissolve"),
+            effects: CS.project.clips.some(c => (c.props.effects || []).length > 0)
+        };
+    });
+    if (rt.after !== rt.before) fail("clips lost in round trip: " + JSON.stringify(rt));
+    if (rt.afterTitles !== rt.beforeTitles) fail("title clips lost in round trip");
+    if (!rt.transition) fail("transition lost in round trip");
+    if (!rt.effects) fail("effects lost in round trip");
+    ok("round trip preserves titles, transitions and effect stacks");
+});

+ 187 - 0
test/e2e/playwright/specs/functional.js

@@ -0,0 +1,187 @@
+/*
+    Functional test: generate real webm/wav media in the browser, import
+    them into Cine Studio, then exercise playback, editing (split/undo/
+    drag), project serialization round-trip and the WebM export pipeline.
+*/
+"use strict";
+
+const { ok, fail, run } = require("../lib/harness");
+
+run("FUNCTIONAL", async (page, browser) => {
+
+    /* ---- 1. generate + import real media ---- */
+    await page.evaluate(async () => {
+        function makeVideo(name, seconds, hue) {
+            return new Promise((resolve) => {
+                const cv = document.createElement("canvas");
+                cv.width = 320; cv.height = 180;
+                const ctx = cv.getContext("2d");
+                const stream = cv.captureStream(15);
+                const rec = new MediaRecorder(stream, { mimeType: "video/webm" });
+                const chunks = [];
+                rec.ondataavailable = e => e.data.size && chunks.push(e.data);
+                rec.onstop = () => resolve(new Blob(chunks, { type: "video/webm" }));
+                const t0 = performance.now();
+                (function draw() {
+                    const t = (performance.now() - t0) / 1000;
+                    ctx.fillStyle = `hsl(${hue}, 60%, 40%)`;
+                    ctx.fillRect(0, 0, 320, 180);
+                    ctx.fillStyle = "#fff";
+                    ctx.fillRect((t * 60) % 320, 60, 40, 60);
+                    if (t < seconds) { requestAnimationFrame(draw); } else { rec.stop(); }
+                })();
+                rec.start(200);
+            });
+        }
+        function makeWav(seconds) {
+            const sr = 22050, n = sr * seconds;
+            const buf = new ArrayBuffer(44 + n * 2);
+            const dv = new DataView(buf);
+            function ws(o, s) { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); }
+            ws(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); ws(8, "WAVEfmt ");
+            dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
+            dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true);
+            dv.setUint16(32, 2, true); dv.setUint16(34, 16, true);
+            ws(36, "data"); dv.setUint32(40, n * 2, true);
+            for (let i = 0; i < n; i++) {
+                dv.setInt16(44 + i * 2, Math.sin(2 * Math.PI * 330 * i / sr) * 12000, true);
+            }
+            return new Blob([buf], { type: "audio/wav" });
+        }
+
+        const v1 = await makeVideo("ClipA.webm", 3, 200);
+        const v2 = await makeVideo("ClipB.webm", 2.5, 30);
+        window.__testMedia = {
+            v1: CS.media.register({ name: "ClipA.webm", blobUrl: URL.createObjectURL(v1), type: "video" }),
+            v2: CS.media.register({ name: "ClipB.webm", blobUrl: URL.createObjectURL(v2), type: "video" }),
+            a1: CS.media.register({ name: "Tone.wav", blobUrl: URL.createObjectURL(makeWav(4)), type: "audio" })
+        };
+    });
+
+    await page.waitForFunction(() => {
+        const m = window.__testMedia;
+        return m.v1.probed && m.v2.probed && m.a1.probed;
+    }, { timeout: 30000 });
+
+    const probe = await page.evaluate(() => {
+        const m = window.__testMedia;
+        return {
+            d1: m.v1.duration, d2: m.v2.duration, da: m.a1.duration,
+            thumbs: m.v1.thumbs.length, peaks: (m.a1.peaks || []).length,
+            offline: m.v1.offline || m.v2.offline || m.a1.offline,
+            binItems: document.querySelectorAll("#bin-grid .bin-item").length
+        };
+    });
+    if (probe.offline) fail("media marked offline");
+    if (!(probe.d1 > 2 && probe.d1 < 4.5)) fail("ClipA duration wrong: " + probe.d1);
+    if (!(probe.da > 3.5 && probe.da < 4.5)) fail("wav duration wrong: " + probe.da);
+    if (probe.thumbs < 1) fail("no video thumbnails");
+    if (probe.peaks < 100) fail("no audio peaks");
+    if (probe.binItems !== 3) fail("bin should show 3 items");
+    ok(`probe: video ${probe.d1.toFixed(2)}s + ${probe.d2.toFixed(2)}s, wav ${probe.da.toFixed(2)}s, ${probe.thumbs} thumbs, ${probe.peaks} peaks`);
+
+    /* ---- 2. build a timeline ---- */
+    await page.evaluate(() => {
+        const m = window.__testMedia;
+        const c1 = CS.addClipToTimeline(m.v1, "V1", 0);
+        CS.addClipToTimeline(m.v2, "V1", CS.clipDuration(c1));
+        CS.addClipToTimeline(m.a1, "A1", 0);
+        CS.commit("Test Build");
+    });
+    const clipCount = await page.evaluate(() => CS.project.clips.length);
+    if (clipCount !== 3) fail("expected 3 clips, got " + clipCount);
+    ok("3 clips placed on timeline");
+
+    /* ---- 3. playback ---- */
+    await page.click("#btn-play");
+    await page.waitForTimeout(1600);
+    const playState = await page.evaluate(() => {
+        const cv = document.getElementById("preview-canvas");
+        const ctx = cv.getContext("2d");
+        const px = ctx.getImageData(Math.floor(cv.width / 2), Math.floor(cv.height / 2), 1, 1).data;
+        return { playhead: CS.state.playhead, playing: CS.state.playing, px: Array.from(px) };
+    });
+    if (!playState.playing) fail("player is not playing");
+    if (playState.playhead < 1.0) fail("playhead did not advance: " + playState.playhead);
+    if (playState.px[0] + playState.px[1] + playState.px[2] < 20) fail("preview canvas looks black during playback");
+    ok(`playback: playhead=${playState.playhead.toFixed(2)}s, center pixel rgb(${playState.px.slice(0, 3)})`);
+    await page.click("#btn-play"); // pause
+
+    /* ---- 4. split / undo / redo ---- */
+    const split = await page.evaluate(() => {
+        CS.player.seek(1.2);
+        CS.selectClip(CS.project.clips[0].id);
+        const before = CS.project.clips.length;
+        CS.splitAtPlayhead();
+        const after = CS.project.clips.length;
+        CS.undo();
+        const undone = CS.project.clips.length;
+        CS.redo();
+        const redone = CS.project.clips.length;
+        return { before, after, undone, redone };
+    });
+    if (split.after !== split.before + 1) fail("split did not add a clip");
+    if (split.undone !== split.before) fail("undo did not restore");
+    if (split.redone !== split.before + 1) fail("redo did not reapply");
+    ok(`split/undo/redo: ${split.before} -> ${split.after} -> ${split.undone} -> ${split.redone}`);
+
+    /* ---- 5. drag a clip with the mouse ---- */
+    const dragged = await page.evaluate(() => {
+        window.__dragTarget = CS.project.clips.find(c => c.trackId === "A1");
+        return { id: window.__dragTarget.id, start: window.__dragTarget.start };
+    });
+    const box = await page.locator(`.tl-clip[data-clip-id="${dragged.id}"]`).boundingBox();
+    if (!box) fail("audio clip element not found");
+    await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+    await page.mouse.down();
+    await page.mouse.move(box.x + box.width / 2 + 120, box.y + box.height / 2, { steps: 8 });
+    await page.mouse.up();
+    const newStart = await page.evaluate(() => CS.getClip(window.__dragTarget.id).start);
+    if (Math.abs(newStart - dragged.start) < 0.5) fail("mouse drag did not move the clip: " + newStart);
+    ok(`mouse drag moved audio clip ${dragged.start.toFixed(2)}s -> ${newStart.toFixed(2)}s`);
+
+    /* ---- 6. project serialization round trip ---- */
+    const roundTrip = await page.evaluate(() => {
+        const json = CS.fileio.serializeProject();
+        const beforeClips = CS.project.clips.length;
+        const beforeTracks = CS.project.tracks.length;
+        CS.fileio.loadProject(JSON.parse(json), "", "roundtrip.cine");
+        return {
+            beforeClips, beforeTracks,
+            afterClips: CS.project.clips.length,
+            afterTracks: CS.project.tracks.length,
+            name: CS.project.name,
+            parses: !!json.length
+        };
+    });
+    if (roundTrip.afterClips !== roundTrip.beforeClips) fail("clips lost in round trip");
+    if (roundTrip.afterTracks !== roundTrip.beforeTracks) fail("tracks lost in round trip");
+    ok(`project round trip: ${roundTrip.afterClips} clips, ${roundTrip.afterTracks} tracks preserved`);
+
+    /* ---- 7. export to WebM (fresh short timeline) ---- */
+    await page.evaluate(() => {
+        const m = window.__testMedia;
+        CS.newProject({ name: "ExportTest", width: 640, height: 360, fps: 30 });
+        CS.player.applyProjectSize();
+        // media pool was cleared by newProject: re-register the blobs
+        const v = CS.media.register({ name: m.v1.name, blobUrl: m.v1.blobUrl, type: "video" });
+        const a = CS.media.register({ name: m.a1.name, blobUrl: m.a1.blobUrl, type: "audio" });
+        window.__exportSeed = { v, a };
+    });
+    await page.waitForFunction(() => window.__exportSeed.v.probed && window.__exportSeed.a.probed, { timeout: 20000 });
+    await page.evaluate(() => {
+        const s = window.__exportSeed;
+        const c = CS.addClipToTimeline(s.v, "V1", 0);
+        c.out = Math.min(c.out, 2.5);
+        const ca = CS.addClipToTimeline(s.a, "A1", 0);
+        ca.out = Math.min(ca.out, 2.5);
+        CS.commit("Export Seed");
+        window.__exportSize = -1;
+        CS.fileio.downloadBlob = function (blob) { window.__exportSize = blob.size; };
+        CS.exporter.start({ base: "test_export", format: "webm", destDir: "", toDevice: true });
+    });
+    await page.waitForFunction(() => window.__exportSize >= 0, { timeout: 30000 });
+    const exportSize = await page.evaluate(() => window.__exportSize);
+    if (exportSize < 5000) fail("export produced a suspiciously small file: " + exportSize);
+    ok(`export produced WebM of ${exportSize} bytes`);
+});

+ 236 - 0
test/e2e/playwright/specs/interaction.js

@@ -0,0 +1,236 @@
+/*
+    Iteration 3 tests: preview drag/resize overlay, auto track creation,
+    Pixel Studio (.pxs) and Audio Studio (.asproj) imports.
+*/
+"use strict";
+
+const { ok, fail, run } = require("../lib/harness");
+
+run("INTERACTION", async (page, browser) => {
+
+    /* ---- seed one real video + audio ---- */
+    await page.evaluate(async () => {
+        function makeVideo(seconds, hue) {
+            return new Promise((resolve) => {
+                const cv = document.createElement("canvas");
+                cv.width = 320; cv.height = 180;
+                const ctx = cv.getContext("2d");
+                const stream = cv.captureStream(15);
+                const rec = new MediaRecorder(stream, { mimeType: "video/webm" });
+                const chunks = [];
+                rec.ondataavailable = e => e.data.size && chunks.push(e.data);
+                rec.onstop = () => resolve(new Blob(chunks, { type: "video/webm" }));
+                const t0 = performance.now();
+                (function draw() {
+                    ctx.fillStyle = `hsl(${hue}, 65%, 45%)`;
+                    ctx.fillRect(0, 0, 320, 180);
+                    if ((performance.now() - t0) / 1000 < seconds) { requestAnimationFrame(draw); } else { rec.stop(); }
+                })();
+                rec.start(200);
+            });
+        }
+        window.makeWavDataUrl = function (seconds, freq) {
+            const sr = 22050, n = Math.floor(sr * seconds);
+            const buf = new ArrayBuffer(44 + n * 2);
+            const dv = new DataView(buf);
+            function ws(o, s) { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); }
+            ws(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); ws(8, "WAVEfmt ");
+            dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
+            dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true);
+            dv.setUint16(32, 2, true); dv.setUint16(34, 16, true);
+            ws(36, "data"); dv.setUint32(40, n * 2, true);
+            for (let i = 0; i < n; i++) {
+                dv.setInt16(44 + i * 2, Math.sin(2 * Math.PI * freq * i / sr) * 12000, true);
+            }
+            let bin = "";
+            const u8 = new Uint8Array(buf);
+            for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i]);
+            return "data:audio/wav;base64," + btoa(bin);
+        };
+        const v = await makeVideo(3, 210);
+        window.__vid = CS.media.register({ name: "Base.webm", blobUrl: URL.createObjectURL(v), type: "video" });
+        const wavResp = await fetch(window.makeWavDataUrl(3, 220));
+        window.__aud = CS.media.register({ name: "Tone.wav", blobUrl: URL.createObjectURL(await wavResp.blob()), type: "audio" });
+    });
+    await page.waitForFunction(() => window.__vid.probed && window.__aud.probed, { timeout: 30000 });
+
+    await page.evaluate(() => {
+        window.__vclip = CS.addClipToTimeline(window.__vid, "V1", 0);
+        window.__aclip = CS.addClipToTimeline(window.__aud, "A1", 0);
+        CS.commit("seed");
+        CS.selectClip(window.__vclip.id);
+        CS.player.seek(1.0);
+    });
+    await page.waitForTimeout(600);
+
+    /* ---- 1. preview overlay shows for selected clip ---- */
+    const overlayBox = await page.evaluate(() => {
+        const box = CS.previewctl.clipBox(window.__vclip);
+        const ov = document.getElementById("preview-overlay");
+        return { box: !!box, w: ov.clientWidth, h: ov.clientHeight };
+    });
+    if (!overlayBox.box || overlayBox.w < 100) fail("overlay not sized/positioned: " + JSON.stringify(overlayBox));
+    ok("preview overlay active over canvas");
+
+    /* ---- 2. drag clip in preview moves it ---- */
+    const ovEl = page.locator("#preview-overlay");
+    const ovRect = await ovEl.boundingBox();
+    const cx = ovRect.x + ovRect.width / 2;
+    const cy = ovRect.y + ovRect.height / 2;
+    await page.mouse.move(cx, cy);
+    await page.mouse.down();
+    await page.mouse.move(cx + 60, cy + 30, { steps: 6 });
+    await page.mouse.up();
+    const moved = await page.evaluate(() => ({ x: window.__vclip.props.x, y: window.__vclip.props.y }));
+    if (moved.x < 30 || moved.y < 15) fail("preview drag did not move clip: " + JSON.stringify(moved));
+    ok(`preview drag moved clip to x=${moved.x}, y=${moved.y}`);
+
+    /* ---- 3. corner handle resize changes scale ---- */
+    await page.evaluate(() => {
+        // bring the clip back fully on-screen so its handles are grabbable
+        window.__vclip.props.x = 0;
+        window.__vclip.props.y = 0;
+        window.__vclip.props.scale = 60;
+        CS.player.render();
+    });
+    const corner = await page.evaluate(() => {
+        const box = CS.previewctl.clipBox(window.__vclip);
+        const pts = CS.previewctl.corners(box);
+        const rect = CS.player.canvas.getBoundingClientRect();
+        const s = rect.width / CS.project.settings.width;
+        return { x: rect.left + pts[2].x * s, y: rect.top + pts[2].y * s };
+    });
+    await page.mouse.move(corner.x, corner.y);
+    await page.mouse.down();
+    await page.mouse.move(corner.x - 80, corner.y - 45, { steps: 6 });
+    await page.mouse.up();
+    const scaled = await page.evaluate(() => window.__vclip.props.scale);
+    if (scaled >= 60) fail("corner drag did not shrink clip: scale=" + scaled);
+    ok(`corner resize scaled clip to ${scaled}%`);
+
+    /* ---- 4. click other clip in preview selects it, empty deselects ---- */
+    await page.evaluate(() => {
+        window.__vclip.props.x = 0; window.__vclip.props.y = 0; window.__vclip.props.scale = 40;
+        CS.player.render();
+    });
+    await page.mouse.click(ovRect.x + 20, ovRect.y + 20); // outside 40% box
+    const sel = await page.evaluate(() => CS.state.selectedClipId);
+    if (sel !== null) fail("click on empty preview did not deselect: " + sel);
+    await page.mouse.click(cx, cy); // inside box again
+    const sel2 = await page.evaluate(() => CS.state.selectedClipId);
+    if (sel2 !== (await page.evaluate(() => window.__vclip.id))) fail("click on clip did not select it");
+    ok("preview click selects / deselects clips");
+
+    /* ---- 5. dragging audio clip below lanes creates a new audio track ---- */
+    const beforeTracks = await page.evaluate(() => CS.project.tracks.length);
+    const aBox = await page.locator(`.tl-clip[data-clip-id="${await page.evaluate(() => window.__aclip.id)}"]`).boundingBox();
+    const tlRect = await page.locator("#tl-tracks").boundingBox();
+    await page.mouse.move(aBox.x + aBox.width / 2, aBox.y + aBox.height / 2);
+    await page.mouse.down();
+    await page.mouse.move(aBox.x + aBox.width / 2 + 10, tlRect.y + tlRect.height + 30, { steps: 6 });
+    await page.mouse.up();
+    const trackInfo = await page.evaluate(() => ({
+        n: CS.project.tracks.length,
+        clipTrack: CS.getClip(window.__aclip.id).trackId,
+        audioTracks: CS.project.tracks.filter(t => t.kind === "audio").length
+    }));
+    if (trackInfo.n !== beforeTracks + 1) fail("no new track created: " + JSON.stringify(trackInfo));
+    if (trackInfo.clipTrack !== "A3") fail("clip not moved to new track: " + trackInfo.clipTrack);
+    ok(`drag below lanes created ${trackInfo.clipTrack} (${trackInfo.audioTracks} audio tracks now)`);
+
+    /* ---- 6. .pxs import (raster + text layers, blend/opacity) ---- */
+    await page.evaluate(() => {
+        const lc = document.createElement("canvas");
+        lc.width = 400; lc.height = 300;
+        const lctx = lc.getContext("2d");
+        lctx.fillStyle = "#c62828";
+        lctx.fillRect(0, 0, 400, 300);
+        const pxs = {
+            app: "PixelStudio", version: 1, width: 400, height: 300, active: 0,
+            layers: [
+                { name: "bg", visible: true, opacity: 1, blend: "source-over", type: "raster", data: lc.toDataURL("image/png") },
+                { name: "txt", visible: true, opacity: 1, blend: "source-over", type: "text",
+                  text: { content: "Hi", x: 20, y: 20, size: 60, color: "#ffffff", font: "Arial", bold: true, italic: false } },
+                { name: "hidden", visible: false, opacity: 1, blend: "source-over", type: "raster", data: lc.toDataURL("image/png") }
+            ]
+        };
+        const blob = new Blob([JSON.stringify(pxs)], { type: "application/json" });
+        window.__pxs = CS.media.register({
+            name: "Poster.pxs", blobUrl: URL.createObjectURL(blob), type: "image", srcKind: "pxs"
+        });
+    });
+    await page.waitForFunction(() => window.__pxs.probed, { timeout: 15000 });
+    const pxsInfo = await page.evaluate(() => ({
+        offline: window.__pxs.offline, w: window.__pxs.width, h: window.__pxs.height,
+        thumbs: window.__pxs.thumbs.length, composite: !!window.__pxs.compositeUrl
+    }));
+    if (pxsInfo.offline) fail(".pxs marked offline");
+    if (pxsInfo.w !== 400 || pxsInfo.h !== 300) fail(".pxs dimensions wrong: " + JSON.stringify(pxsInfo));
+    if (!pxsInfo.thumbs || !pxsInfo.composite) fail(".pxs not composited");
+    // place on timeline and verify the red pixel shows
+    await page.evaluate(() => {
+        CS.project.clips = CS.project.clips.filter(c => c.trackId !== "V1");
+        const c = CS.addClipToTimeline(window.__pxs, "V1", 0);
+        c.props.crop = "fill";
+        CS.commit("pxs clip");
+        CS.player.seek(0.5);
+    });
+    await page.waitForTimeout(400);
+    const pxsPx = await page.evaluate(() => {
+        const cv = document.getElementById("preview-canvas");
+        const d = cv.getContext("2d").getImageData(Math.floor(cv.width * 0.7), Math.floor(cv.height * 0.7), 1, 1).data;
+        return [d[0], d[1], d[2]];
+    });
+    if (!(pxsPx[0] > 140 && pxsPx[1] < 90)) fail(".pxs frame not red: " + pxsPx);
+    ok(`.pxs imported, composited 400x300, renders red (rgb ${pxsPx})`);
+
+    /* ---- 7. .asproj with rendered mixdown ---- */
+    await page.evaluate(() => {
+        const asproj = { app: "AudioStudio", version: 1, name: "Mix", mixdown: window.makeWavDataUrl(2, 440) };
+        const blob = new Blob([JSON.stringify(asproj)], { type: "application/json" });
+        window.__as1 = CS.media.register({
+            name: "Song.asproj", blobUrl: URL.createObjectURL(blob), type: "audio", srcKind: "asproj"
+        });
+    });
+    await page.waitForFunction(() => window.__as1.probed, { timeout: 15000 });
+    const as1 = await page.evaluate(() => ({
+        offline: window.__as1.offline, dur: window.__as1.duration, peaks: (window.__as1.peaks || []).length
+    }));
+    if (as1.offline) fail("mixdown .asproj offline");
+    if (!(as1.dur > 1.8 && as1.dur < 2.2)) fail("mixdown duration wrong: " + as1.dur);
+    ok(`.asproj mixdown imported (${as1.dur.toFixed(2)}s, ${as1.peaks} peaks)`);
+
+    /* ---- 8. .asproj with tracks/clips mixed offline ---- */
+    await page.evaluate(() => {
+        const asproj = {
+            app: "AudioStudio", version: 1,
+            tracks: [
+                { name: "T1", volume: 1, clips: [{ src: window.makeWavDataUrl(1.5, 330), start: 0 }] },
+                { name: "T2", volume: 0.6, clips: [{ src: window.makeWavDataUrl(1.5, 550), start: 1.0 }] }
+            ]
+        };
+        const blob = new Blob([JSON.stringify(asproj)], { type: "application/json" });
+        window.__as2 = CS.media.register({
+            name: "Session.asproj", blobUrl: URL.createObjectURL(blob), type: "audio", srcKind: "asproj"
+        });
+    });
+    await page.waitForFunction(() => window.__as2.probed, { timeout: 20000 });
+    const as2 = await page.evaluate(() => ({
+        offline: window.__as2.offline, dur: window.__as2.duration, peaks: (window.__as2.peaks || []).length
+    }));
+    if (as2.offline) fail("track-mix .asproj offline");
+    if (!(as2.dur > 2.3 && as2.dur < 2.7)) fail("mixed duration wrong (want ~2.5): " + as2.dur);
+    ok(`.asproj track mix rendered offline (${as2.dur.toFixed(2)}s, ${as2.peaks} peaks)`);
+
+    /* ---- 9. audio playback of imported asproj on the timeline ---- */
+    await page.evaluate(() => {
+        CS.addClipToTimeline(window.__as1, "A1", 0);
+        CS.commit("asproj clip");
+    });
+    await page.click("#btn-play");
+    await page.waitForTimeout(900);
+    const playing = await page.evaluate(() => CS.state.playing && CS.state.playhead > 0.4);
+    if (!playing) fail("timeline with imported media does not play");
+    await page.click("#btn-play");
+    ok("imported project media plays on the timeline");
+});