Просмотр исходного кода

Updated Video Editor app to have more functionalities (#266)

* 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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung 3 месяцев назад
Родитель
Сommit
3f18b9337d

+ 29 - 0
src/web/Cine Studio/backend/listprojects.js

@@ -0,0 +1,29 @@
+/*
+	Cine Studio - list saved projects
+
+	Returns the .cine project files stored in the per-user
+	user:/Cine Studio/Projects folder as a JSON array of
+	{ filename, vpath } objects (newest first by user sort).
+*/
+
+requirelib("filelib");
+
+var dir = "user:/Cine Studio/Projects";
+var out = [];
+
+if (filelib.fileExists(dir)) {
+	var files = filelib.aglob(dir + "/*.cine", "mostRecent");
+	if (files === false || files === null) {
+		files = [];
+	}
+	for (var i = 0; i < files.length; i++) {
+		var vpath = files[i];
+		var parts = vpath.split("/");
+		out.push({
+			filename: parts[parts.length - 1],
+			vpath: vpath
+		});
+	}
+}
+
+sendJSONResp(JSON.stringify(out));

+ 176 - 12
src/web/Cine Studio/css/style.css

@@ -91,18 +91,6 @@ svg {
 
 .tb-left, .tb-right { display: flex; align-items: center; gap: 6px; }
 
-#traffic-lights { display: flex; align-items: center; gap: 8px; margin-right: 14px; }
-.tl {
-    width: 12px; height: 12px;
-    border-radius: 50%;
-    display: inline-block;
-    cursor: pointer;
-}
-.tl-close { background: #ff5f57; }
-.tl-min   { background: #febc2e; }
-.tl-max   { background: #28c840; }
-#traffic-lights:hover .tl { filter: brightness(0.85); }
-
 .tb-btn {
     height: 30px;
     min-width: 32px;
@@ -364,6 +352,167 @@ svg {
     border-top: 1px solid var(--border-soft);
 }
 
+/* ---------- side panels (swap in for the media bin) ---------- */
+
+.side-panel {
+    display: none;
+    flex: 1;
+    flex-direction: column;
+    min-height: 0;
+}
+#mediabin[data-panel]:not([data-panel="media"]) .bin-header,
+#mediabin[data-panel]:not([data-panel="media"]) .bin-subheader,
+#mediabin[data-panel]:not([data-panel="media"]) #bin-grid,
+#mediabin[data-panel]:not([data-panel="media"]) .bin-footer { display: none; }
+#mediabin[data-panel="fx"] #panel-fx,
+#mediabin[data-panel="titles"] #panel-titles,
+#mediabin[data-panel="transitions"] #panel-transitions,
+#mediabin[data-panel="elements"] #panel-elements,
+#mediabin[data-panel="filters"] #panel-filters,
+#mediabin[data-panel="libraries"] #panel-libraries { display: flex; }
+
+.sp-title {
+    padding: 14px 14px 2px 14px;
+    font-size: 13.5px;
+    font-weight: 600;
+}
+.sp-hint {
+    padding: 2px 14px 10px 14px;
+    font-size: 11.5px;
+    color: var(--text-faint);
+    line-height: 1.5;
+}
+.sp-grid {
+    flex: 1;
+    overflow-y: auto;
+    padding: 2px 12px 12px 12px;
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 12px 10px;
+    align-content: start;
+}
+.fx-card { cursor: pointer; }
+.fx-thumb {
+    position: relative;
+    width: 100%;
+    aspect-ratio: 16 / 10;
+    border-radius: 8px;
+    overflow: hidden;
+    background: #000;
+    border: 1px solid transparent;
+}
+.fx-thumb canvas { width: 100%; height: 100%; display: block; }
+.fx-card:hover .fx-thumb { border-color: #3a3a44; }
+.fx-card.applied .fx-thumb { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
+.fx-card.applied .fx-thumb::after {
+    content: "";
+    position: absolute;
+    right: 5px;
+    top: 5px;
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    background: var(--accent);
+    box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
+}
+.fx-name {
+    margin-top: 5px;
+    font-size: 11.5px;
+    color: var(--text-dim);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    text-align: center;
+}
+.fx-card.applied .fx-name { color: var(--text); }
+
+/* libraries list */
+#libraries-list {
+    flex: 1;
+    overflow-y: auto;
+    padding: 2px 10px 12px 10px;
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+}
+.lib-item {
+    display: flex;
+    align-items: center;
+    gap: 9px;
+    padding: 8px 9px;
+    border-radius: 7px;
+    color: var(--text);
+    font-size: 12.5px;
+    text-align: left;
+    min-width: 0;
+}
+.lib-item:hover { background: var(--bg-hover); }
+.lib-item [data-icon] { color: var(--text-dim); }
+.lib-item .lib-name {
+    flex: 1;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+.lib-empty {
+    padding: 30px 14px;
+    text-align: center;
+    color: var(--text-faint);
+    font-size: 12px;
+    line-height: 1.6;
+}
+
+/* media bin list view */
+#bin-grid.list-mode { grid-template-columns: 1fr; gap: 4px; }
+#bin-grid.list-mode .bin-item {
+    display: flex;
+    align-items: center;
+    gap: 9px;
+    padding: 3px 4px;
+    border-radius: 7px;
+}
+#bin-grid.list-mode .bin-item:hover { background: var(--bg-hover); }
+#bin-grid.list-mode .bin-thumb {
+    width: 72px;
+    min-width: 72px;
+    aspect-ratio: 16 / 10;
+    border-radius: 5px;
+}
+#bin-grid.list-mode .bin-name {
+    margin-top: 0;
+    text-align: left;
+    flex: 1;
+}
+
+/* clip markers */
+.tl-clip .clip-fx {
+    position: absolute;
+    left: 5px;
+    bottom: 4px;
+    padding: 0 5px;
+    border-radius: 4px;
+    background: rgba(0, 0, 0, 0.6);
+    color: #9ec5ff;
+    font-size: 9.5px;
+    font-weight: 700;
+    letter-spacing: 0.4px;
+    pointer-events: none;
+    z-index: 1;
+}
+.tl-clip .clip-tr {
+    position: absolute;
+    left: 0;
+    top: 0;
+    bottom: 0;
+    width: 14px;
+    background: linear-gradient(105deg, rgba(255,255,255,0.5), rgba(255,255,255,0) 80%);
+    pointer-events: none;
+    z-index: 1;
+}
+.tl-clip.title-clip { background: #6d5ae0; }
+.tl-clip.title-clip .clip-label { top: 50%; transform: translateY(-50%); }
+.tl-clip.color-clip .clip-label { top: 50%; transform: translateY(-50%); }
+
 .bin-empty-hint {
     grid-column: 1 / -1;
     padding: 34px 10px;
@@ -392,6 +541,14 @@ svg {
     overflow: hidden;
     padding: 18px;
 }
+#canvas-wrap {
+    position: relative;
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
 #preview-canvas {
     max-width: 100%;
     max-height: 100%;
@@ -399,6 +556,12 @@ svg {
     border-radius: 6px;
     box-shadow: 0 0 0 1px rgba(255,255,255,0.05);
 }
+#preview-overlay {
+    position: absolute;
+    left: 0;
+    top: 0;
+    touch-action: none;
+}
 #preview-empty {
     position: absolute;
     inset: 0;
@@ -698,6 +861,7 @@ input[type="range"].insp-slider::-moz-range-thumb, #tl-zoom::-moz-range-thumb {
 }
 .track-header .th-toggle:hover { background: var(--bg-hover); color: var(--text); }
 .track-header .th-toggle.off { color: var(--text-faint); }
+.track-header.solo .th-name { color: var(--accent); font-weight: 600; }
 .track-header .th-toggle svg { width: 14px; height: 14px; }
 
 #tl-scroll {

+ 47 - 7
src/web/Cine Studio/index.html

@@ -14,11 +14,6 @@
     <!-- ======================= Top bar ======================= -->
     <div id="topbar">
         <div class="tb-left">
-            <div id="traffic-lights">
-                <span class="tl tl-close" title="Close"></span>
-                <span class="tl tl-min" title="Minimize"></span>
-                <span class="tl tl-max" title="Zoom"></span>
-            </div>
             <button class="tb-btn" id="btn-open-project" title="Open project">
                 <span data-icon="folder"></span><span data-icon="chevron-down" class="mini"></span>
             </button>
@@ -94,12 +89,47 @@
             </div>
             <div id="bin-grid"></div>
             <div class="bin-footer"><span id="bin-count">0 items</span></div>
+
+            <!-- Side panels swapped in by the nav rail -->
+            <div class="side-panel" id="panel-fx">
+                <div class="sp-title">Effects</div>
+                <div class="sp-hint">Click an effect to apply it to the selected clip</div>
+                <div class="sp-grid" id="fx-grid"></div>
+            </div>
+            <div class="side-panel" id="panel-titles">
+                <div class="sp-title">Titles</div>
+                <div class="sp-hint">Click a style to add a title at the playhead</div>
+                <div class="sp-grid" id="titles-grid"></div>
+            </div>
+            <div class="side-panel" id="panel-transitions">
+                <div class="sp-title">Transitions</div>
+                <div class="sp-hint">Click to apply to the selected clip; it blends from the previous clip on the same track</div>
+                <div class="sp-grid" id="transitions-grid"></div>
+            </div>
+            <div class="side-panel" id="panel-elements">
+                <div class="sp-title">Elements</div>
+                <div class="sp-hint">Solid and gradient backgrounds for titles and overlays</div>
+                <div class="sp-grid" id="elements-grid"></div>
+            </div>
+            <div class="side-panel" id="panel-filters">
+                <div class="sp-title">Filters</div>
+                <div class="sp-hint">Click a look to apply it to the selected clip</div>
+                <div class="sp-grid" id="filters-grid"></div>
+            </div>
+            <div class="side-panel" id="panel-libraries">
+                <div class="sp-title">Libraries</div>
+                <div class="sp-hint">Projects saved in Cine Studio/Projects</div>
+                <div id="libraries-list"></div>
+            </div>
         </div>
 
         <!-- Preview player -->
         <div id="preview">
             <div id="preview-stage">
-                <canvas id="preview-canvas" width="1920" height="1080"></canvas>
+                <div id="canvas-wrap">
+                    <canvas id="preview-canvas" width="1920" height="1080"></canvas>
+                    <canvas id="preview-overlay"></canvas>
+                </div>
                 <div id="preview-empty">
                     <span data-icon="nav-media" class="big"></span>
                     <p>Import media and drag it to the timeline</p>
@@ -115,6 +145,7 @@
                     <button class="pb-btn" id="btn-prev-edit" title="Previous edit point"><span data-icon="skip-back"></span></button>
                     <button class="pb-btn play" id="btn-play" title="Play / Pause (Space)"><span data-icon="play" id="play-icon"></span></button>
                     <button class="pb-btn" id="btn-next-edit" title="Next edit point"><span data-icon="skip-fwd"></span></button>
+                    <button class="pb-btn" id="btn-loop" title="Loop playback"><span data-icon="loop"></span></button>
                 </div>
                 <div class="pb-right">
                     <button class="pb-btn wide" id="btn-preview-zoom"><span id="preview-zoom-label">Fit</span><span data-icon="chevron-down" class="mini"></span></button>
@@ -144,6 +175,9 @@
             <button class="tlt-btn" id="tool-crop" title="Crop"><span data-icon="crop"></span></button>
             <button class="tlt-btn" id="tool-text" title="Text"><span data-icon="nav-text"></span></button>
             <button class="tlt-btn" id="tool-audio" title="Audio mixer"><span data-icon="waveform"></span></button>
+            <span class="tlt-sep"></span>
+            <button class="tlt-btn active" id="btn-snap" title="Toggle snapping"><span data-icon="magnet"></span></button>
+            <button class="tlt-btn" id="btn-marker" title="Add / remove marker at playhead (M)"><span data-icon="marker"></span></button>
         </div>
         <div class="tlt-right">
             <button class="tlt-btn" id="btn-undo" title="Undo (Ctrl+Z)"><span data-icon="undo"></span></button>
@@ -174,7 +208,7 @@
     </div>
 
     <!-- Hidden helpers -->
-    <input type="file" id="local-file-input" multiple accept="video/*,audio/*,image/*" style="display:none;">
+    <input type="file" id="local-file-input" multiple accept="video/*,audio/*,image/*,.pxs,.asproj" style="display:none;">
     <div id="element-pool" style="display:none;"></div>
     <div id="toast-holder"></div>
     <div id="modal-holder"></div>
@@ -184,7 +218,13 @@
     <script src="js/ui.js"></script>
     <script src="js/main.js"></script>
     <script src="js/media.js"></script>
+    <script src="js/effects.js"></script>
+    <script src="js/titles.js"></script>
+    <script src="js/transitions.js"></script>
+    <script src="js/panels.js"></script>
+    <script src="js/session.js"></script>
     <script src="js/player.js"></script>
+    <script src="js/previewctl.js"></script>
     <script src="js/timeline.js"></script>
     <script src="js/inspector.js"></script>
     <script src="js/fileio.js"></script>

+ 1 - 1
src/web/Cine Studio/init.agi

@@ -17,7 +17,7 @@ var moduleLaunchInfo = {
 	LaunchFWDir: "Cine Studio/index.html",
 	SupportEmb: false,
 	InitFWSize: [1280, 850],
-	SupportedExt: [".cine"]
+	SupportedExt: [".cine", ".pxs", ".asproj"]
 }
 
 //Register the module

+ 48 - 26
src/web/Cine Studio/js/boot.js

@@ -10,6 +10,7 @@ document.addEventListener("DOMContentLoaded", function () {
 
     CS.media.init();
     CS.player.init();
+    CS.previewctl.init();
     CS.timeline.init();
     CS.inspector.init();
 
@@ -36,26 +37,6 @@ document.addEventListener("DOMContentLoaded", function () {
     });
     document.getElementById("btn-settings").addEventListener("click", CS.fileio.settingsDialog);
 
-    //macOS traffic lights: red closes the float window
-    document.querySelector(".tl-close").addEventListener("click", function () {
-        function doClose() {
-            if (typeof ao_module_close !== "undefined") { ao_module_close(); }
-        }
-        if (CS.state.dirty) {
-            CS.confirm("Unsaved changes", "Close Cine Studio and discard unsaved changes?", doClose);
-        } else {
-            doClose();
-        }
-    });
-    document.querySelector(".tl-min").addEventListener("click", function () {
-        CS.toast("Use the desktop window controls to minimize");
-    });
-    document.querySelector(".tl-max").addEventListener("click", function () {
-        var stage = document.getElementById("preview-stage");
-        if (stage.requestFullscreen && !document.fullscreenElement) { stage.requestFullscreen(); }
-        else if (document.fullscreenElement) { document.exitFullscreen(); }
-    });
-
     /* ---------- nav rail ---------- */
 
     var navItems = document.querySelectorAll("#navrail .nav-item[data-nav]");
@@ -68,21 +49,33 @@ document.addEventListener("DOMContentLoaded", function () {
         navItems[i].addEventListener("click", function () {
             var nav = this.getAttribute("data-nav");
             var kindLabel = document.getElementById("bin-kind-label");
+            activateNav(nav);
             if (nav === "media") {
-                activateNav(nav);
                 CS.state.binKind = "all";
                 kindLabel.textContent = "All Clips";
+                CS.panels.show("media");
                 CS.media.renderBin();
             } else if (nav === "audio") {
-                activateNav(nav);
                 CS.state.binKind = "audio";
                 kindLabel.textContent = "Audio";
+                CS.panels.show("media");
                 CS.media.renderBin();
-            } else {
-                CS.toast(this.querySelector("label").textContent + " is not available in this version");
+            } else if (nav === "effects") {
+                CS.panels.show("fx");
+            } else if (nav === "titles" || nav === "text") {
+                CS.panels.show("titles");
+            } else if (nav === "transitions") {
+                CS.panels.show("transitions");
+            } else if (nav === "elements") {
+                CS.panels.show("elements");
+            } else if (nav === "filters") {
+                CS.panels.show("filters");
+            } else if (nav === "libraries") {
+                CS.panels.show("libraries");
             }
         });
     }
+    CS.panels.show("media");
 
     /* ---------- selection hook: keep inspector tab sensible ---------- */
 
@@ -91,6 +84,8 @@ document.addEventListener("DOMContentLoaded", function () {
         origSelect(clipId);
         CS.inspector.autoTab();
         CS.inspector.render();
+        CS.panels.refresh();
+        CS.previewctl.redraw();
     };
 
     /* ---------- keyboard shortcuts ---------- */
@@ -115,17 +110,38 @@ document.addEventListener("DOMContentLoaded", function () {
         } else if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z") {
             ev.preventDefault();
             CS.undo();
+        } else if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "c") {
+            ev.preventDefault();
+            CS.copySelectedClips();
+        } else if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "v") {
+            ev.preventDefault();
+            CS.pasteClipsAtPlayhead();
+        } else if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "d") {
+            ev.preventDefault();
+            CS.duplicateSelectedClips();
         } else if (ev.key === "Delete" || ev.key === "Backspace") {
             if (CS.state.selectedClipId) {
                 ev.preventDefault();
-                CS.deleteSelectedClip();
+                if (ev.shiftKey) { CS.rippleDeleteSelected(); }
+                else { CS.deleteSelectedClip(); }
             }
+        } else if (ev.key.toLowerCase() === "m" && !ev.ctrlKey && !ev.metaKey) {
+            if (ev.shiftKey) { CS.gotoMarker(1); }
+            else { CS.toggleMarkerAtPlayhead(); }
+        } else if (ev.key.toLowerCase() === "j") {
+            CS.player.shuttle(-1);
+        } else if (ev.key.toLowerCase() === "k") {
+            CS.player.pause();
+        } else if (ev.key.toLowerCase() === "l") {
+            CS.player.shuttle(1);
         } else if (ev.key.toLowerCase() === "v") {
             CS.timeline.setTool("select");
         } else if (ev.key.toLowerCase() === "b") {
             CS.timeline.setTool("blade");
         } else if (ev.key.toLowerCase() === "s" && !ev.ctrlKey && !ev.metaKey) {
             CS.splitAtPlayhead();
+        } else if (ev.key.toLowerCase() === "t" && !ev.ctrlKey && !ev.metaKey) {
+            CS.titles.insertPreset("title");
         } else if (ev.key === "ArrowLeft") {
             ev.preventDefault();
             CS.player.seek(CS.state.playhead - (ev.shiftKey ? 10 : 1) / fps);
@@ -155,5 +171,11 @@ document.addEventListener("DOMContentLoaded", function () {
     CS.updateSaveState();
 
     //Open a project / media passed by the desktop (double-click on .cine)
-    CS.fileio.openLaunchFiles();
+    var openedLaunchFile = CS.fileio.openLaunchFiles();
+
+    //Auto-save loop + crash recovery offer (skip when launched with a file)
+    CS.session.init();
+    if (!openedLaunchFile) {
+        CS.session.checkRecovery();
+    }
 });

+ 313 - 0
src/web/Cine Studio/js/effects.js

@@ -0,0 +1,313 @@
+/*
+    Cine Studio - clip effects
+
+    Each clip carries an ordered effect stack in clip.props.effects
+    ([{type, amount}]). Effects are applied by the compositor so they
+    show identically in the preview and in exports:
+
+      - filter kind:   folded into the canvas ctx.filter string
+      - pixelate:      source downscaled then upscaled without smoothing
+      - mirror:        horizontal flip inside the clip transform
+      - vignette/grain: full-frame overlays drawn after the clip
+      - fade:          time-based alpha ramp (also drives audio volume
+                       ramps for clips on audio tracks)
+*/
+"use strict";
+
+window.CS = window.CS || {};
+
+CS.effects = {
+
+    registry: [
+        { type: "bw",       name: "Black & White", kind: "filter", min: 0, max: 100, def: 100, unit: "%", step: 1,
+          make: function (v) { return "grayscale(" + (v / 100) + ")"; }, preview: "grayscale(1)" },
+        { type: "sepia",    name: "Sepia", kind: "filter", min: 0, max: 100, def: 100, unit: "%", step: 1,
+          make: function (v) { return "sepia(" + (v / 100) + ")"; }, preview: "sepia(1)" },
+        { type: "invert",   name: "Invert", kind: "filter", min: 0, max: 100, def: 100, unit: "%", step: 1,
+          make: function (v) { return "invert(" + (v / 100) + ")"; }, preview: "invert(1)" },
+        { type: "hue",      name: "Hue Shift", kind: "filter", min: -180, max: 180, def: 120, unit: "deg", step: 1,
+          make: function (v) { return "hue-rotate(" + v + "deg)"; }, preview: "hue-rotate(120deg)" },
+        { type: "blur",     name: "Blur", kind: "filter", min: 0, max: 20, def: 6, unit: "px", step: 0.5,
+          make: function (v, scale) { return "blur(" + (v * scale).toFixed(2) + "px)"; }, preview: "blur(2px)" },
+        { type: "pixelate", name: "Pixelate", kind: "pixelate", min: 2, max: 64, def: 12, unit: "px", step: 1 },
+        { type: "mirror",   name: "Mirror", kind: "mirror", noParam: true },
+        { type: "vignette", name: "Vignette", kind: "vignette", min: 0, max: 100, def: 60, unit: "%", step: 1 },
+        { type: "grain",    name: "Film Grain", kind: "grain", min: 0, max: 100, def: 40, unit: "%", step: 1 },
+        { type: "fadein",   name: "Fade In", kind: "fade", min: 0.1, max: 5, def: 1, unit: "s", step: 0.1, audioOk: true },
+        { type: "fadeout",  name: "Fade Out", kind: "fade", min: 0.1, max: 5, def: 1, unit: "s", step: 0.1, audioOk: true }
+    ],
+
+    get: function (type) {
+        for (var i = 0; i < CS.effects.registry.length; i++) {
+            if (CS.effects.registry[i].type === type) { return CS.effects.registry[i]; }
+        }
+        return null;
+    },
+
+    clipHas: function (clip, type) {
+        var list = (clip.props && clip.props.effects) || [];
+        for (var i = 0; i < list.length; i++) {
+            if (list[i].type === type) { return list[i]; }
+        }
+        return null;
+    },
+
+    /* ---------- apply / remove ---------- */
+
+    applyToSelected: function (type) {
+        var clip = CS.selectedClip();
+        if (!clip) { CS.toast("Select a clip on the timeline first"); return; }
+        CS.effects.applyToClip(clip, type);
+    },
+
+    applyToClip: function (clip, type) {
+        var def = CS.effects.get(type);
+        if (!def) { return; }
+        var track = CS.getTrack(clip.trackId);
+        if (track && track.kind === "audio" && !def.audioOk) {
+            CS.toast("Only fades apply to audio clips", true);
+            return;
+        }
+        if (!clip.props.effects) { clip.props.effects = []; }
+        var existing = CS.effects.clipHas(clip, type);
+        if (existing) {
+            existing.amount = def.noParam ? undefined : def.def;
+            CS.commit("Update Effect");
+            CS.toast(def.name + " updated");
+        } else {
+            clip.props.effects.push({ type: type, amount: def.noParam ? undefined : def.def });
+            CS.commit("Add Effect");
+            CS.toast(def.name + " applied");
+        }
+        CS.panels.refresh();
+    },
+
+    removeFromClip: function (clip, type) {
+        clip.props.effects = (clip.props.effects || []).filter(function (e) { return e.type !== type; });
+        CS.commit("Remove Effect");
+        CS.panels.refresh();
+    },
+
+    /* ---------- render-time evaluation ---------- */
+
+    //Product of the fade-in / fade-out ramps at time t (1 when no fades)
+    fadeAlpha: function (clip, t) {
+        var a = 1;
+        var list = (clip.props && clip.props.effects) || [];
+        for (var i = 0; i < list.length; i++) {
+            var e = list[i];
+            if (e.type === "fadein") {
+                a *= CS.clamp((t - clip.start) / Math.max(0.05, e.amount), 0, 1);
+            } else if (e.type === "fadeout") {
+                a *= CS.clamp((CS.clipEnd(clip) - t) / Math.max(0.05, e.amount), 0, 1);
+            }
+        }
+        return a;
+    },
+
+    //Summarize the effect stack for the compositor
+    analyze: function (clip, t, W) {
+        var res = { filter: "", alpha: 1, mirror: false, pixelate: 0, vignette: 0, grain: 0 };
+        var list = (clip.props && clip.props.effects) || [];
+        if (!list.length) { return res; }
+        var scale = (W || 1920) / 1920;
+        for (var i = 0; i < list.length; i++) {
+            var e = list[i];
+            var def = CS.effects.get(e.type);
+            if (!def) { continue; }
+            if (def.kind === "filter") {
+                res.filter += " " + def.make(e.amount, scale);
+            } else if (def.kind === "pixelate") {
+                res.pixelate = e.amount;
+            } else if (def.kind === "mirror") {
+                res.mirror = true;
+            } else if (def.kind === "vignette") {
+                res.vignette = e.amount / 100;
+            } else if (def.kind === "grain") {
+                res.grain = e.amount / 100;
+            }
+        }
+        res.alpha = CS.effects.fadeAlpha(clip, t);
+        return res;
+    },
+
+    //Downscale the source into a reusable scratch canvas for pixelation
+    _scratch: null,
+    pixelateSource: function (src, sw, sh, block) {
+        if (!CS.effects._scratch) { CS.effects._scratch = document.createElement("canvas"); }
+        var c = CS.effects._scratch;
+        var tw = Math.max(1, Math.round(sw / block));
+        var th = Math.max(1, Math.round(sh / block));
+        if (c.width !== tw || c.height !== th) { c.width = tw; c.height = th; }
+        var ctx = c.getContext("2d");
+        ctx.imageSmoothingEnabled = true;
+        ctx.clearRect(0, 0, tw, th);
+        ctx.drawImage(src, 0, 0, tw, th);
+        return c;
+    },
+
+    _vigCache: null,
+    drawVignette: function (ctx, W, H, amount) {
+        var key = W + "x" + H;
+        if (!CS.effects._vigCache || CS.effects._vigCache.key !== key) {
+            var g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.72);
+            g.addColorStop(0, "rgba(0,0,0,0)");
+            g.addColorStop(1, "rgba(0,0,0,1)");
+            CS.effects._vigCache = { key: key, gradient: g };
+        }
+        ctx.save();
+        ctx.globalAlpha = amount;
+        ctx.fillStyle = CS.effects._vigCache.gradient;
+        ctx.fillRect(0, 0, W, H);
+        ctx.restore();
+    },
+
+    _grainTiles: null,
+    _grainTick: 0,
+    drawGrain: function (ctx, W, H, amount) {
+        if (!CS.effects._grainTiles) {
+            CS.effects._grainTiles = [];
+            for (var n = 0; n < 3; n++) {
+                var tile = document.createElement("canvas");
+                tile.width = 256;
+                tile.height = 256;
+                var tctx = tile.getContext("2d");
+                var img = tctx.createImageData(256, 256);
+                for (var i = 0; i < img.data.length; i += 4) {
+                    var v = Math.floor(Math.random() * 255);
+                    img.data[i] = v;
+                    img.data[i + 1] = v;
+                    img.data[i + 2] = v;
+                    img.data[i + 3] = 255;
+                }
+                tctx.putImageData(img, 0, 0);
+                CS.effects._grainTiles.push(tile);
+            }
+        }
+        CS.effects._grainTick++;
+        var t = CS.effects._grainTiles[CS.effects._grainTick % 3];
+        ctx.save();
+        ctx.globalAlpha = amount * 0.28;
+        ctx.globalCompositeOperation = "overlay";
+        ctx.fillStyle = ctx.createPattern(t, "repeat");
+        ctx.fillRect(0, 0, W, H);
+        ctx.restore();
+    },
+
+    /* ---------- gallery panel ---------- */
+
+    _galleryBuilt: false,
+    renderGallery: function () {
+        if (CS.effects._galleryBuilt) { CS.effects.refreshApplied(); return; }
+        CS.effects._galleryBuilt = true;
+        var grid = document.getElementById("fx-grid");
+        grid.innerHTML = "";
+        CS.effects.registry.forEach(function (def) {
+            var card = document.createElement("div");
+            card.className = "fx-card";
+            card.dataset.fxType = def.type;
+
+            var thumb = document.createElement("div");
+            thumb.className = "fx-thumb";
+            thumb.appendChild(CS.effects.previewCanvas(def));
+
+            var name = document.createElement("div");
+            name.className = "fx-name";
+            name.textContent = def.name;
+
+            card.appendChild(thumb);
+            card.appendChild(name);
+            card.addEventListener("click", function () {
+                CS.effects.applyToSelected(def.type);
+            });
+            grid.appendChild(card);
+        });
+        CS.effects.refreshApplied();
+    },
+
+    refreshApplied: function () {
+        var clip = CS.selectedClip();
+        var cards = document.querySelectorAll("#fx-grid .fx-card");
+        for (var i = 0; i < cards.length; i++) {
+            var has = clip && !!CS.effects.clipHas(clip, cards[i].dataset.fxType);
+            cards[i].classList.toggle("applied", has);
+        }
+    },
+
+    //Small demo scene with the effect applied, used as the gallery preview
+    previewCanvas: function (def) {
+        var c = document.createElement("canvas");
+        c.width = 150;
+        c.height = 94;
+        var ctx = c.getContext("2d");
+
+        if (def.kind === "pixelate") {
+            var base = CS.effects.baseScene(150, 94);
+            var small = document.createElement("canvas");
+            small.width = 15;
+            small.height = 10;
+            small.getContext("2d").drawImage(base, 0, 0, 15, 10);
+            ctx.imageSmoothingEnabled = false;
+            ctx.drawImage(small, 0, 0, 150, 94);
+            return c;
+        }
+
+        ctx.save();
+        if (def.kind === "mirror") {
+            ctx.translate(150, 0);
+            ctx.scale(-1, 1);
+        }
+        if (def.kind === "filter" && def.preview && ctx.filter !== undefined) {
+            ctx.filter = def.preview;
+        }
+        ctx.drawImage(CS.effects.baseScene(150, 94), 0, 0);
+        ctx.restore();
+
+        if (def.kind === "vignette") { CS.effects.drawVignette(ctx, 150, 94, 0.85); CS.effects._vigCache = null; }
+        if (def.kind === "grain") { CS.effects.drawGrain(ctx, 150, 94, 1); }
+        if (def.kind === "fade") {
+            var g = ctx.createLinearGradient(0, 0, 150, 0);
+            if (def.type === "fadein") {
+                g.addColorStop(0, "rgba(0,0,0,1)");
+                g.addColorStop(0.9, "rgba(0,0,0,0)");
+            } else {
+                g.addColorStop(0.1, "rgba(0,0,0,0)");
+                g.addColorStop(1, "rgba(0,0,0,1)");
+            }
+            ctx.fillStyle = g;
+            ctx.fillRect(0, 0, 150, 94);
+        }
+        return c;
+    },
+
+    _baseScene: null,
+    baseScene: function (w, h) {
+        if (CS.effects._baseScene) { return CS.effects._baseScene; }
+        var c = document.createElement("canvas");
+        c.width = w;
+        c.height = h;
+        var ctx = c.getContext("2d");
+        var g = ctx.createLinearGradient(0, 0, 0, h);
+        g.addColorStop(0, "#7fb2d9");
+        g.addColorStop(0.6, "#c97b3a");
+        g.addColorStop(1, "#5c3a1e");
+        ctx.fillStyle = g;
+        ctx.fillRect(0, 0, w, h);
+        //sun
+        ctx.fillStyle = "#ffe9b0";
+        ctx.beginPath();
+        ctx.arc(w * 0.68, h * 0.34, h * 0.14, 0, Math.PI * 2);
+        ctx.fill();
+        //hills
+        ctx.fillStyle = "rgba(20,30,40,0.75)";
+        ctx.beginPath();
+        ctx.moveTo(0, h);
+        for (var x = 0; x <= w; x += 6) {
+            ctx.lineTo(x, h * 0.62 + Math.sin(x * 0.06) * h * 0.12);
+        }
+        ctx.lineTo(w, h);
+        ctx.fill();
+        CS.effects._baseScene = c;
+        return c;
+    }
+};

+ 35 - 0
src/web/Cine Studio/js/exporter.js

@@ -98,12 +98,40 @@ CS.exporter = {
     quickMenu: function (anchorEl) {
         CS.showMenuUnder(anchorEl, [
             { label: "Export Video...", icon: "export-up", action: CS.exporter.dialog },
+            { label: "Export Current Frame (PNG)", icon: "camera", action: CS.exporter.exportFrame },
             { sep: true },
             { label: "Save Project", icon: "save", action: CS.fileio.saveProject },
             { label: "Save Project As...", icon: "save", action: CS.fileio.saveProjectAs }
         ]);
     },
 
+    //Save the frame under the playhead as a PNG still
+    exportFrame: function () {
+        CS.player.syncElements();
+        CS.player.renderFrame(CS.player.ctx, CS.state.playhead);
+        var tc = CS.timecode(CS.state.playhead).replace(/:/g, ".");
+        var defaultName = (CS.project.name || "Frame") + " " + tc + ".png";
+        CS.player.canvas.toBlob(function (blob) {
+            if (!blob) { CS.toast("Could not capture the frame", true); return; }
+            if (CS.inArozOS() && typeof ao_module_openFileSelector !== "undefined") {
+                window.csFrameCallback = function csFrameCallback(filedata) {
+                    if (!filedata || !filedata.length) { return; }
+                    var f = filedata[0];
+                    var file = new File([blob], f.filename, { type: "image/png" });
+                    ao_module_uploadFile(file, CS.dirOf(f.filepath), function () {
+                        CS.toast("Exported " + f.filename);
+                    });
+                };
+                ao_module_openFileSelector(window.csFrameCallback, CS.APP_ROOT + "/Exports", "new", false, {
+                    defaultName: defaultName
+                });
+            } else {
+                CS.fileio.downloadBlob(blob, defaultName);
+            }
+            CS.player.render(); //repaint overlays the frame render skipped
+        }, "image/png");
+    },
+
     /* ---------- recording ---------- */
 
     pickMimeType: function () {
@@ -163,6 +191,9 @@ CS.exporter = {
         CS.exporter.recorder.onstop = CS.exporter.onRecorderStop;
 
         CS.exporter.active = true;
+        //Loop must not swallow the end-of-timeline stop that ends the export
+        CS.exporter._loopWas = CS.state.loop;
+        CS.state.loop = false;
         CS.exporter.showProgress();
 
         //Roll from the very beginning and let the player drive the frames
@@ -239,6 +270,10 @@ CS.exporter = {
 
     onRecorderStop: function () {
         CS.exporter.detachAudioTap();
+        if (CS.exporter._loopWas !== undefined) {
+            CS.state.loop = CS.exporter._loopWas;
+            CS.exporter._loopWas = undefined;
+        }
         var wasCancelled = CS.exporter.cancelled;
         CS.exporter.cancelled = false;
         var chunks = CS.exporter.chunks;

+ 17 - 4
src/web/Cine Studio/js/fileio.js

@@ -22,10 +22,11 @@ CS.fileio = {
             name: CS.project.name,
             settings: CS.project.settings,
             media: CS.project.media.map(function (m) {
-                return { id: m.id, name: m.name, vpath: m.vpath || "", type: m.type };
+                return { id: m.id, name: m.name, vpath: m.vpath || "", type: m.type, srcKind: m.srcKind || "" };
             }),
             tracks: CS.project.tracks,
-            clips: CS.project.clips
+            clips: CS.project.clips,
+            markers: CS.project.markers || []
         }, null, 1);
     },
 
@@ -55,6 +56,8 @@ CS.fileio = {
                 name: m.name,
                 vpath: m.vpath || "",
                 blobUrl: "",
+                compositeUrl: "",
+                srcKind: m.srcKind || "",
                 type: m.type,
                 duration: m.type === "image" ? CS.IMAGE_DEFAULT_DURATION : 0,
                 width: 0,
@@ -75,9 +78,10 @@ CS.fileio = {
             }
         });
 
-        //Restore clips, keeping only ones whose media still exists
+        //Restore clips: generated clips (titles / color boards) have no
+        //media; anything else must reference a surviving media entry
         CS.project.clips = (data.clips || []).filter(function (c) {
-            return CS.getMedia(c.mediaId);
+            return c.kind === "title" || c.kind === "color" || CS.getMedia(c.mediaId);
         }).map(function (c) {
             var props = CS.defaultClipProps();
             Object.keys(c.props || {}).forEach(function (k) { props[k] = c.props[k]; });
@@ -85,9 +89,14 @@ CS.fileio = {
             return c;
         });
 
+        CS.project.markers = Array.isArray(data.markers) ? data.markers : [];
+
         CS.history = { stack: [], index: -1 };
         CS.pushHistory("Open Project");
         CS.markClean();
+        if (CS.session && filepath) {
+            CS.session.recordRecent(CS.project.name, filepath);
+        }
         CS.player.applyProjectSize();
         CS.media.renderBin();
         CS.timeline.render();
@@ -206,6 +215,7 @@ CS.fileio = {
                 CS.project.fileName = filename;
                 CS.project.name = CS.baseName(filename);
                 CS.markClean();
+                if (CS.session) { CS.session.recordRecent(CS.project.name, filepath); }
                 CS.toast("Saved " + filename);
             }, undefined, function () {
                 CS.toast("Save failed - check permissions", true);
@@ -330,6 +340,9 @@ CS.fileio = {
         CS.showMenuUnder(anchorEl, [
             { label: "New Project...", icon: "file", action: CS.fileio.newProjectDialog },
             { label: "Open Project...", icon: "folder", action: CS.fileio.openDialog },
+            { label: "Open Recent", icon: "history", action: function () {
+                CS.session.openRecentMenu(anchorEl);
+            } },
             { sep: true },
             { label: "Save", icon: "save", action: CS.fileio.saveProject },
             { label: "Save As...", icon: "save", action: CS.fileio.saveProjectAs },

+ 7 - 0
src/web/Cine Studio/js/icons.js

@@ -53,6 +53,13 @@ CS.iconPaths = {
     "minus":         '<path d="M5 12h14"/>',
     "plus":          '<path d="M12 5v14"/><path d="M5 12h14"/>',
     "plus-square":   '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M12 9v6"/><path d="M9 12h6"/>',
+    "magnet":        '<path d="M5 4h4v8a3 3 0 0 0 6 0V4h4v8a7 7 0 0 1-14 0V4z"/><path d="M5 8h4"/><path d="M15 8h4"/>',
+    "loop":          '<path d="M17 3l3 3-3 3"/><path d="M20 6H8a4 4 0 0 0-4 4v1"/><path d="M7 21l-3-3 3-3"/><path d="M4 18h12a4 4 0 0 0 4-4v-1"/>',
+    "copy":          '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
+    "marker":        '<path d="M6 3h9l3 4-3 4H6"/><path d="M6 3v18"/>',
+    "camera":        '<rect x="3" y="7" width="13" height="12" rx="2"/><path d="M16 12l5-3v8l-5-3"/>',
+    "detach":        '<path d="M4 10v4h3l5 4V6l-5 4H4z"/><path d="M17 5l4 4"/><path d="M21 5l-4 4"/>',
+    "history":       '<path d="M3 4v5h5"/><path d="M3.5 9a8.5 8.5 0 1 1-1 6"/><path d="M12 8v4l3 2"/>',
 
     "film":          '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 4v16"/><path d="M16 4v16"/><path d="M3 9h5"/><path d="M3 15h5"/><path d="M16 9h5"/><path d="M16 15h5"/>',
     "speaker":       '<path d="M4 10v4h3l5 4V6L7 10H4z"/><path d="M15.5 9.5a4 4 0 0 1 0 5"/><path d="M18 7a7.5 7.5 0 0 1 0 10"/>',

+ 160 - 7
src/web/Cine Studio/js/inspector.js

@@ -55,11 +55,12 @@ CS.inspector = {
         var clip = CS.selectedClip();
 
         if (CS.inspector.activeTab === "effects") {
-            CS.inspector.placeholder(body, "Effects are not available in this version. Use the Color section on the Video tab for exposure, contrast and saturation.");
+            CS.inspector.renderEffectsTab(body, clip);
             return;
         }
         if (CS.inspector.activeTab === "transition") {
-            CS.inspector.placeholder(body, "Transitions are not available in this version. Clips cut from one to the next.");
+            CS.transitions.renderTab(body, clip);
+            CS.applyIcons(body);
             return;
         }
 
@@ -95,6 +96,11 @@ CS.inspector = {
     renderVideoTab: function (body, clip) {
         var p = clip.props;
 
+        //---- Text (title clips only) ----
+        if (clip.kind === "title") {
+            CS.titles.buildTextSection(body, clip);
+        }
+
         //---- Transform ----
         var transform = CS.inspector.section(body, "Transform", function () {
             p.x = 0; p.y = 0; p.scale = 100; p.rotation = 0; p.opacity = 100;
@@ -120,6 +126,55 @@ CS.inspector = {
             CS.inspector.numChip(null, p.opacity, "%", function (v) { p.opacity = CS.clamp(v, 0, 100); }, 1)
         ]);
 
+        CS.inspector.row(transform, "Blend", [
+            CS.inspector.select([
+                { v: "normal", l: "Normal" },
+                { v: "multiply", l: "Multiply" },
+                { v: "screen", l: "Screen" },
+                { v: "overlay", l: "Overlay" },
+                { v: "lighter", l: "Add" },
+                { v: "soft-light", l: "Soft Light" },
+                { v: "difference", l: "Difference" }
+            ], p.blend || "normal", function (v) {
+                p.blend = v;
+                CS.commit("Blend Mode");
+            })
+        ]);
+
+        CS.inspector.row(transform, "Flip", [
+            CS.inspector.toggleChip("Horizontal", !!p.flipH, function (on) {
+                p.flipH = on;
+                CS.commit("Flip Horizontal");
+            }),
+            CS.inspector.toggleChip("Vertical", !!p.flipV, function (on) {
+                p.flipV = on;
+                CS.commit("Flip Vertical");
+            })
+        ]);
+
+        //---- Speed (media-backed clips only; generated clips trim freely) ----
+        var media = CS.getMedia(clip.mediaId);
+        if (media && (media.type === "video" || media.type === "audio")) {
+            var speed = CS.inspector.section(body, "Speed", function () {
+                p.speed = 1;
+                CS.commit("Reset Speed");
+            });
+            CS.inspector.row(speed, "Rate", [
+                CS.inspector.slider(25, 400, 5, Math.round(CS.clipSpeed(clip) * 100), function (v) {
+                    p.speed = v / 100;
+                    CS.timeline.clampTrimOverlap(clip);
+                }),
+                CS.inspector.numChip(null, Math.round(CS.clipSpeed(clip) * 100), "%", function (v) {
+                    p.speed = CS.clamp(v, 10, 800) / 100;
+                    CS.timeline.clampTrimOverlap(clip);
+                }, 5)
+            ]);
+            var spNote = document.createElement("div");
+            spNote.className = "modal-note";
+            spNote.textContent = "Changes clip length on the timeline; audio pitch follows the rate.";
+            speed.appendChild(spNote);
+        }
+
         //---- Crop ----
         var crop = CS.inspector.section(body, "Crop", function () {
             p.crop = "fit";
@@ -149,11 +204,7 @@ CS.inspector = {
                 { v: "cool", l: "Cool" },
                 { v: "vivid", l: "Vivid" }
             ], p.preset || "default", function (v) {
-                p.preset = v;
-                if (v === "mono") { p.saturation = 0; }
-                if (v === "vivid") { p.saturation = 1.35; p.contrast = 10; }
-                if (v === "default") { p.exposure = 0; p.contrast = 0; p.saturation = 1; }
-                CS.commit("Color Preset");
+                CS.inspector.applyPreset(clip, v);
             })
         ]);
         CS.inspector.row(color, "Exposure", [
@@ -182,6 +233,87 @@ CS.inspector = {
         CS.applyIcons(body);
     },
 
+    //Shared by the Color preset dropdown and the Filters gallery panel
+    applyPreset: function (clip, v) {
+        var p = clip.props;
+        p.preset = v;
+        if (v === "mono") { p.saturation = 0; }
+        if (v === "vivid") { p.saturation = 1.35; p.contrast = 10; }
+        if (v === "default") { p.exposure = 0; p.contrast = 0; p.saturation = 1; }
+        CS.commit("Color Preset");
+        CS.panels.refresh();
+    },
+
+    /* ---------- effects tab ---------- */
+
+    renderEffectsTab: function (body, clip) {
+        if (!clip) {
+            CS.inspector.placeholder(body, "Select a clip on the timeline, then add effects here or from the Effects panel on the left.");
+            return;
+        }
+        var track = CS.getTrack(clip.trackId);
+        var isAudio = track && track.kind === "audio";
+
+        //Add-effect dropdown (only effects not applied yet, audio gets fades only)
+        var available = CS.effects.registry.filter(function (def) {
+            if (isAudio && !def.audioOk) { return false; }
+            return !CS.effects.clipHas(clip, def.type);
+        });
+        var addBtn = document.createElement("button");
+        addBtn.className = "insp-select";
+        addBtn.style.width = "100%";
+        addBtn.style.marginTop = "10px";
+        addBtn.innerHTML = "<span></span>" + '<span data-icon="plus" class="mini"></span>';
+        addBtn.firstChild.textContent = "Add Effect";
+        addBtn.addEventListener("click", function () {
+            if (!available.length) { CS.toast("All available effects are applied"); return; }
+            CS.showMenuUnder(addBtn, available.map(function (def) {
+                return {
+                    label: def.name,
+                    action: function () { CS.effects.applyToClip(clip, def.type); }
+                };
+            }));
+        });
+        body.appendChild(addBtn);
+
+        var list = clip.props.effects || [];
+        if (!list.length) {
+            CS.inspector.placeholder(body, isAudio
+                ? "No effects on this clip. Fade In and Fade Out shape the clip volume."
+                : "No effects on this clip. Add one above or from the Effects panel.");
+            CS.applyIcons(body);
+            return;
+        }
+
+        list.forEach(function (e) {
+            var def = CS.effects.get(e.type);
+            if (!def) { return; }
+            var sec = CS.inspector.section(body, def.name, function () {
+                CS.effects.removeFromClip(clip, e.type);
+            });
+            //Repurpose the section reset button as a remove button
+            var head = sec.parentNode.querySelector(".sec-reset");
+            head.innerHTML = CS.iconSVG("trash");
+            head.title = "Remove effect";
+
+            if (def.noParam) {
+                var note = document.createElement("div");
+                note.className = "modal-note";
+                note.textContent = "This effect has no settings.";
+                sec.appendChild(note);
+            } else {
+                CS.inspector.row(sec, "Amount", [
+                    CS.inspector.slider(def.min, def.max, def.step || 1, e.amount, function (v) { e.amount = v; }),
+                    CS.inspector.numChip(null, e.amount, def.unit, function (v) {
+                        e.amount = CS.clamp(v, def.min, def.max);
+                    }, def.step || 1, (def.step && def.step < 1) ? 1 : 0)
+                ]);
+            }
+        });
+
+        CS.applyIcons(body);
+    },
+
     /* ---------- audio tab ---------- */
 
     renderAudioTab: function (body, clip, media, track) {
@@ -344,6 +476,27 @@ CS.inspector = {
         return dial;
     },
 
+    //Small on/off pill used for boolean properties (flip H / V)
+    toggleChip: function (labelText, on, onChange) {
+        var btn = document.createElement("button");
+        btn.className = "insp-select";
+        btn.style.minWidth = "0";
+        btn.style.flex = "1";
+        btn.style.justifyContent = "center";
+        function paint() {
+            btn.style.color = on ? "var(--accent)" : "var(--text)";
+            btn.style.boxShadow = on ? "inset 0 0 0 1px var(--accent)" : "none";
+        }
+        btn.textContent = labelText;
+        paint();
+        btn.addEventListener("click", function () {
+            on = !on;
+            paint();
+            onChange(on);
+        });
+        return btn;
+    },
+
     select: function (options, value, onChange) {
         var btn = document.createElement("button");
         btn.className = "insp-select";

+ 222 - 10
src/web/Cine Studio/js/main.js

@@ -34,10 +34,21 @@ CS.defaultClipProps = function () {
         exposure: 0,
         contrast: 0,
         saturation: 1,
-        volume: 100
+        volume: 100,
+        speed: 1,
+        blend: "normal",
+        flipH: false,
+        flipV: false,
+        effects: [],
+        transition: null
     };
 };
 
+CS.clipSpeed = function (clip) {
+    var v = clip.props && clip.props.speed;
+    return (v && v > 0) ? v : 1;
+};
+
 CS.newProject = function (opts) {
     opts = opts || {};
     CS.project = {
@@ -68,10 +79,15 @@ CS.newProject = function (opts) {
         tool: "select",
         previewZoom: "fit",
         safeArea: false,
+        loop: false,
+        snap: true,
         binKind: "all",
         binSearch: "",
+        binView: "grid",
+        selectedClipIds: [],
         dirty: false
     };
+    CS.project.markers = [];
     CS.history = { stack: [], index: -1 };
     CS.pushHistory("New Project");
     if (CS.player) { CS.player.reset(); }
@@ -126,8 +142,9 @@ CS.clipsOnTrack = function (trackId) {
         .sort(function (a, b) { return a.start - b.start; });
 };
 
+//Duration on the timeline: source range divided by playback speed
 CS.clipDuration = function (clip) {
-    return clip.out - clip.in;
+    return (clip.out - clip.in) / CS.clipSpeed(clip);
 };
 
 CS.clipEnd = function (clip) {
@@ -183,6 +200,9 @@ CS.applySnapshot = function (snap) {
     if (CS.state.selectedClipId && !CS.getClip(CS.state.selectedClipId)) {
         CS.state.selectedClipId = null;
     }
+    CS.state.selectedClipIds = (CS.state.selectedClipIds || []).filter(function (id) {
+        return !!CS.getClip(id);
+    });
     CS.markDirty();
     CS.timeline.render();
     CS.inspector.render();
@@ -211,6 +231,8 @@ CS.markDirty = function () {
 CS.markClean = function () {
     CS.state.dirty = false;
     CS.updateSaveState();
+    //Saved on disk: the crash-recovery snapshot is no longer needed
+    if (CS.session) { CS.session.clearSnapshot(); }
 };
 
 CS.updateSaveState = function () {
@@ -226,22 +248,167 @@ CS.updateSaveState = function () {
     }
 };
 
-/* ---------- clip operations ---------- */
+/* ---------- selection (multi-select aware) ---------- */
 
+//Primary selection; replaces any multi-selection with the single clip
 CS.selectClip = function (clipId) {
     CS.state.selectedClipId = clipId;
+    CS.state.selectedClipIds = clipId ? [clipId] : [];
     CS.timeline.refreshSelection();
     CS.inspector.render();
 };
 
+//Shift-click: add / remove a clip from the selection set
+CS.toggleSelectClip = function (clipId) {
+    var ids = CS.state.selectedClipIds || [];
+    var idx = ids.indexOf(clipId);
+    if (idx >= 0) {
+        ids.splice(idx, 1);
+        if (CS.state.selectedClipId === clipId) {
+            CS.state.selectedClipId = ids.length ? ids[ids.length - 1] : null;
+        }
+    } else {
+        ids.push(clipId);
+        CS.state.selectedClipId = clipId;
+    }
+    CS.state.selectedClipIds = ids;
+    CS.timeline.refreshSelection();
+    CS.inspector.render();
+};
+
+CS.selectedClips = function () {
+    return (CS.state.selectedClipIds || [])
+        .map(function (id) { return CS.getClip(id); })
+        .filter(function (c) { return !!c; });
+};
+
 CS.deleteSelectedClip = function () {
-    var clip = CS.selectedClip();
-    if (!clip) { CS.toast("No clip selected"); return; }
+    var clips = CS.selectedClips();
+    if (!clips.length) { CS.toast("No clip selected"); return; }
+    var ids = clips.map(function (c) { return c.id; });
+    CS.project.clips = CS.project.clips.filter(function (c) { return ids.indexOf(c.id) < 0; });
+    CS.state.selectedClipId = null;
+    CS.state.selectedClipIds = [];
+    CS.commit(clips.length > 1 ? "Delete Clips" : "Delete Clip");
+};
+
+//Delete and close the gap: later clips on the same track shift left
+CS.rippleDeleteSelected = function () {
+    var clips = CS.selectedClips();
+    if (clips.length !== 1) {
+        CS.toast(clips.length ? "Ripple delete works on a single clip" : "No clip selected");
+        return;
+    }
+    var clip = clips[0];
+    var dur = CS.clipDuration(clip);
     CS.project.clips = CS.project.clips.filter(function (c) { return c.id !== clip.id; });
+    CS.clipsOnTrack(clip.trackId).forEach(function (c) {
+        if (c.start >= clip.start - 0.0001) { c.start = Math.max(0, c.start - dur); }
+    });
     CS.state.selectedClipId = null;
-    CS.commit("Delete Clip");
+    CS.state.selectedClipIds = [];
+    CS.commit("Ripple Delete");
+};
+
+/* ---------- clipboard (clips) ---------- */
+
+CS.clipClipboard = null;
+
+CS.copySelectedClips = function () {
+    var clips = CS.selectedClips();
+    if (!clips.length) { CS.toast("No clip selected"); return; }
+    var minStart = Math.min.apply(null, clips.map(function (c) { return c.start; }));
+    CS.clipClipboard = clips.map(function (c) {
+        var copy = JSON.parse(JSON.stringify(c));
+        copy.rel = c.start - minStart;
+        return copy;
+    });
+    CS.toast("Copied " + clips.length + " clip" + (clips.length > 1 ? "s" : ""));
 };
 
+CS.pasteClipsAtPlayhead = function () {
+    if (!CS.clipClipboard || !CS.clipClipboard.length) { CS.toast("Clip clipboard is empty"); return; }
+    var newIds = [];
+    CS.clipClipboard.forEach(function (src) {
+        var clip = JSON.parse(JSON.stringify(src));
+        delete clip.rel;
+        clip.id = CS.uid();
+        clip.start = CS.state.playhead + src.rel;
+        if (!CS.getTrack(clip.trackId)) {
+            //Original track is gone: fall back to the first compatible one
+            var kind = (clip.kind === "title" || clip.kind === "color") ? "video"
+                : (CS.getMedia(clip.mediaId) && CS.getMedia(clip.mediaId).type === "audio") ? "audio" : "video";
+            var tracks = CS.project.tracks.filter(function (t) { return t.kind === kind; });
+            if (!tracks.length) { return; }
+            clip.trackId = tracks[0].id;
+        }
+        clip.start = CS.timeline.resolveOverlap(clip, clip.trackId, clip.start);
+        CS.project.clips.push(clip);
+        newIds.push(clip.id);
+    });
+    if (!newIds.length) { return; }
+    CS.state.selectedClipIds = newIds;
+    CS.state.selectedClipId = newIds[newIds.length - 1];
+    CS.commit("Paste");
+};
+
+//Duplicate each selected clip right after itself on its own track
+CS.duplicateSelectedClips = function () {
+    var clips = CS.selectedClips();
+    if (!clips.length) { CS.toast("No clip selected"); return; }
+    var newIds = [];
+    clips.forEach(function (c) {
+        var clip = JSON.parse(JSON.stringify(c));
+        clip.id = CS.uid();
+        clip.start = CS.timeline.resolveOverlap(clip, clip.trackId, CS.clipEnd(c));
+        CS.project.clips.push(clip);
+        newIds.push(clip.id);
+    });
+    CS.state.selectedClipIds = newIds;
+    CS.state.selectedClipId = newIds[newIds.length - 1];
+    CS.commit("Duplicate");
+};
+
+/* ---------- timeline markers ---------- */
+
+//Add a marker at the playhead, or remove one already there
+CS.toggleMarkerAtPlayhead = function () {
+    if (!CS.project.markers) { CS.project.markers = []; }
+    var t = CS.state.playhead;
+    var eps = 4 / CS.state.zoom; //within a few pixels counts as "here"
+    for (var i = 0; i < CS.project.markers.length; i++) {
+        if (Math.abs(CS.project.markers[i].time - t) < eps) {
+            CS.project.markers.splice(i, 1);
+            CS.markDirty();
+            CS.timeline.drawRuler();
+            CS.toast("Marker removed");
+            return;
+        }
+    }
+    CS.project.markers.push({ id: CS.uid(), time: t });
+    CS.project.markers.sort(function (a, b) { return a.time - b.time; });
+    CS.markDirty();
+    CS.timeline.drawRuler();
+    CS.toast("Marker added");
+};
+
+//Jump to the next / previous marker relative to the playhead
+CS.gotoMarker = function (dir) {
+    var ms = CS.project.markers || [];
+    var t = CS.state.playhead;
+    if (dir > 0) {
+        for (var i = 0; i < ms.length; i++) {
+            if (ms[i].time > t + 0.02) { CS.player.seek(ms[i].time); return; }
+        }
+    } else {
+        for (var j = ms.length - 1; j >= 0; j--) {
+            if (ms[j].time < t - 0.02) { CS.player.seek(ms[j].time); return; }
+        }
+    }
+};
+
+/* ---------- clip operations ---------- */
+
 //Split the clip under the playhead (prefers the selected clip)
 CS.splitAtPlayhead = function () {
     var t = CS.state.playhead;
@@ -259,14 +426,15 @@ CS.splitAtPlayhead = function () {
 };
 
 CS.splitClip = function (clip, t) {
-    var offset = t - clip.start;
+    var srcOffset = (t - clip.start) * CS.clipSpeed(clip);
     var right = JSON.parse(JSON.stringify(clip));
     right.id = CS.uid();
     right.start = t;
-    right.in = clip.in + offset;
-    clip.out = clip.in + offset;
+    right.in = clip.in + srcOffset;
+    clip.out = clip.in + srcOffset;
     CS.project.clips.push(right);
     CS.state.selectedClipId = right.id;
+    CS.state.selectedClipIds = [right.id];
 };
 
 //Place a media item on the timeline. Returns the new clip.
@@ -288,7 +456,8 @@ CS.addClipToTimeline = function (media, trackId, startTime) {
     return clip;
 };
 
-CS.addTrack = function (kind) {
+//Create a track without committing history (callers commit themselves)
+CS.createTrack = function (kind) {
     var prefix = kind === "video" ? "V" : "A";
     var maxN = 0;
     CS.project.tracks.forEach(function (t) {
@@ -305,9 +474,52 @@ CS.addTrack = function (kind) {
         visible: true,
         muted: false
     });
+    return id;
+};
+
+CS.addTrack = function (kind) {
+    CS.createTrack(kind);
     CS.commit("Add Track");
 };
 
+//Split a video clip's embedded audio onto its own audio-track clip
+CS.detachAudio = function (clip) {
+    var media = CS.getMedia(clip.mediaId);
+    if (!media || media.type !== "video") { return; }
+    if (clip.props.audioDetached) { CS.toast("Audio is already detached"); return; }
+
+    //Find an audio track with room at this position, or add one
+    var trackId = null;
+    var audioTracks = CS.project.tracks.filter(function (t) { return t.kind === "audio"; });
+    for (var i = 0; i < audioTracks.length; i++) {
+        var busy = CS.clipsOnTrack(audioTracks[i].id).some(function (c) {
+            return clip.start < CS.clipEnd(c) && CS.clipEnd(clip) > c.start;
+        });
+        if (!busy) { trackId = audioTracks[i].id; break; }
+    }
+    if (!trackId) { trackId = CS.createTrack("audio"); }
+
+    var audioClip = JSON.parse(JSON.stringify(clip));
+    audioClip.id = CS.uid();
+    audioClip.trackId = trackId;
+    audioClip.props.transition = null;
+    audioClip.props.effects = (clip.props.effects || []).filter(function (e) {
+        return e.type === "fadein" || e.type === "fadeout";
+    });
+    CS.project.clips.push(audioClip);
+
+    //Silence the original video clip's own audio
+    clip.props.volume = 0;
+    clip.props.audioDetached = true;
+
+    //Waveform for the detached clip if the container's audio can be decoded
+    if (!media.peaks) { CS.media.computePeaks(media); }
+
+    CS.selectClip(audioClip.id);
+    CS.commit("Detach Audio");
+    CS.toast("Audio detached to " + trackId);
+};
+
 /* ---------- backend bootstrap ---------- */
 
 //Make sure the per-user app folders exist (no-op outside ArozOS)

+ 271 - 6
src/web/Cine Studio/js/media.js

@@ -14,20 +14,35 @@ CS.media = {
 
     /* ---------- source resolution ---------- */
 
-    mediaURL: function (media) {
+    //URL of the raw file (the .pxs / .asproj document itself for project imports)
+    rawURL: function (media) {
         if (media.vpath) { return "../media?file=" + encodeURIComponent(media.vpath); }
         return media.blobUrl || "";
     },
 
+    //URL of the playable pixels / samples. Project imports (.pxs / .asproj)
+    //are composited on import into media.compositeUrl.
+    mediaURL: function (media) {
+        if (media.compositeUrl) { return media.compositeUrl; }
+        return CS.media.rawURL(media);
+    },
+
     typeFromExt: function (ext) {
+        if (ext === "pxs") { return "image"; }     //Pixel Studio project
+        if (ext === "asproj") { return "audio"; }  //Audio Studio project
         if (CS.VIDEO_EXTS.indexOf(ext) >= 0) { return "video"; }
         if (CS.AUDIO_EXTS.indexOf(ext) >= 0) { return "audio"; }
         if (CS.IMAGE_EXTS.indexOf(ext) >= 0) { return "image"; }
         return null;
     },
 
+    srcKindFromExt: function (ext) {
+        if (ext === "pxs" || ext === "asproj") { return ext; }
+        return "";
+    },
+
     acceptedExts: function () {
-        return CS.VIDEO_EXTS.concat(CS.AUDIO_EXTS).concat(CS.IMAGE_EXTS);
+        return CS.VIDEO_EXTS.concat(CS.AUDIO_EXTS).concat(CS.IMAGE_EXTS).concat(["pxs", "asproj"]);
     },
 
     /* ---------- import entry points ---------- */
@@ -70,7 +85,8 @@ CS.media = {
                     CS.media.register({
                         name: file.name,
                         blobUrl: URL.createObjectURL(file),
-                        type: CS.media.typeFromExt(ext)
+                        type: CS.media.typeFromExt(ext),
+                        srcKind: CS.media.srcKindFromExt(ext)
                     });
                 });
             }
@@ -119,7 +135,12 @@ CS.media = {
             CS.toast("Unsupported file type: ." + ext, true);
             return null;
         }
-        return CS.media.register({ name: filename, vpath: vpath, type: type });
+        return CS.media.register({
+            name: filename,
+            vpath: vpath,
+            type: type,
+            srcKind: CS.media.srcKindFromExt(ext)
+        });
     },
 
     //Create the media entry, kick off probing, refresh the bin
@@ -129,6 +150,8 @@ CS.media = {
             name: spec.name,
             vpath: spec.vpath || "",
             blobUrl: spec.blobUrl || "",
+            compositeUrl: "",
+            srcKind: spec.srcKind || "",
             type: spec.type,
             duration: spec.type === "image" ? CS.IMAGE_DEFAULT_DURATION : 0,
             width: 0,
@@ -148,11 +171,250 @@ CS.media = {
     /* ---------- probing ---------- */
 
     probe: function (media) {
-        if (media.type === "video") { CS.media.probeVideo(media); }
+        if (media.srcKind === "pxs") { CS.media.probePxs(media); }
+        else if (media.srcKind === "asproj") { CS.media.probeAsproj(media); }
+        else if (media.type === "video") { CS.media.probeVideo(media); }
         else if (media.type === "audio") { CS.media.probeAudio(media); }
         else { CS.media.probeImage(media); }
     },
 
+    /* ---------- Pixel Studio (.pxs) import ---------- */
+
+    //Flatten a Pixel Studio project into a PNG and treat it as image media
+    probePxs: function (media) {
+        fetch(CS.media.rawURL(media))
+            .then(function (r) {
+                if (!r.ok) { throw new Error("HTTP " + r.status); }
+                return r.json();
+            })
+            .then(function (doc) {
+                if (!doc || doc.app !== "PixelStudio" || !Array.isArray(doc.layers)) {
+                    throw new Error("not a Pixel Studio project");
+                }
+                return CS.media.compositePxs(doc);
+            })
+            .then(function (canvas) {
+                return new Promise(function (resolve, reject) {
+                    canvas.toBlob(function (blob) {
+                        if (!blob) { reject(new Error("compositing failed")); return; }
+                        resolve(blob);
+                    }, "image/png");
+                });
+            })
+            .then(function (blob) {
+                media.compositeUrl = URL.createObjectURL(blob);
+                CS.media.probeImage(media); //normal image pipeline from here
+            })
+            .catch(function (err) {
+                CS.toast("Cannot import " + media.name + ": " + err.message, true);
+                CS.media.markOffline(media);
+            });
+    },
+
+    //Draw every visible layer bottom-up with its opacity and blend mode;
+    //raster layers are base64 PNGs, text layers re-render like Pixel Studio
+    compositePxs: function (doc) {
+        var w = Math.max(1, parseInt(doc.width, 10) || 1);
+        var h = Math.max(1, parseInt(doc.height, 10) || 1);
+        var canvas = document.createElement("canvas");
+        canvas.width = w;
+        canvas.height = h;
+        var ctx = canvas.getContext("2d");
+
+        var loaders = doc.layers.map(function (layer) {
+            return new Promise(function (resolve) {
+                if (layer.type === "text" && layer.text) {
+                    resolve({ layer: layer, img: null });
+                } else if (layer.data) {
+                    var img = new Image();
+                    img.onload = function () { resolve({ layer: layer, img: img }); };
+                    img.onerror = function () { resolve({ layer: layer, img: null }); };
+                    img.src = layer.data;
+                } else {
+                    resolve({ layer: layer, img: null });
+                }
+            });
+        });
+
+        return Promise.all(loaders).then(function (entries) {
+            entries.forEach(function (e) {
+                if (e.layer.visible === false) { return; }
+                ctx.save();
+                ctx.globalAlpha = (e.layer.opacity === undefined) ? 1 : e.layer.opacity;
+                ctx.globalCompositeOperation = e.layer.blend || "source-over";
+                if (e.img) {
+                    ctx.drawImage(e.img, 0, 0);
+                } else if (e.layer.type === "text" && e.layer.text) {
+                    CS.media.drawPxsText(ctx, e.layer.text);
+                }
+                ctx.restore();
+            });
+            return canvas;
+        });
+    },
+
+    drawPxsText: function (ctx, t) {
+        ctx.font = (t.italic ? "italic " : "") + (t.bold ? "bold " : "") +
+            (t.size || 24) + 'px "' + (t.font || "Arial") + '"';
+        ctx.textBaseline = "top";
+        ctx.fillStyle = t.color || "#000000";
+        var lineHeight = Math.round((t.size || 24) * 1.25);
+        (t.content || "").split("\n").forEach(function (line, i) {
+            ctx.fillText(line, t.x || 0, (t.y || 0) + i * lineHeight);
+        });
+    },
+
+    /* ---------- Audio Studio (.asproj) import ---------- */
+
+    //Resolve an Audio Studio project to playable audio: prefer an embedded
+    //rendered mixdown, otherwise mix the referenced clips offline
+    probeAsproj: function (media) {
+        fetch(CS.media.rawURL(media))
+            .then(function (r) {
+                if (!r.ok) { throw new Error("HTTP " + r.status); }
+                return r.json();
+            })
+            .then(function (doc) {
+                if (!doc || typeof doc !== "object") { throw new Error("not an Audio Studio project"); }
+
+                //1) embedded / referenced rendered output
+                var keys = ["mixdown", "master", "rendered", "output", "render", "audio"];
+                for (var i = 0; i < keys.length; i++) {
+                    var v = doc[keys[i]];
+                    if (typeof v === "string" && v.indexOf("data:audio") === 0) {
+                        return fetch(v).then(function (r) { return r.blob(); });
+                    }
+                    if (typeof v === "string" && v.indexOf(":/") > 0) {
+                        return fetch("../media?file=" + encodeURIComponent(v))
+                            .then(function (r) {
+                                if (!r.ok) { throw new Error("mixdown file missing"); }
+                                return r.blob();
+                            });
+                    }
+                }
+
+                //2) mix the project's clips ourselves
+                var specs = CS.media.collectAsprojClips(doc);
+                if (!specs.length) { throw new Error("no audio found in project"); }
+                return CS.media.mixAsprojClips(specs);
+            })
+            .then(function (blob) {
+                media.compositeUrl = URL.createObjectURL(blob);
+                CS.media.probeAudio(media); //normal audio pipeline from here
+            })
+            .catch(function (err) {
+                CS.toast("Cannot import " + media.name + ": " + err.message, true);
+                CS.media.markOffline(media);
+            });
+    },
+
+    //Accept clips at doc.clips or doc.tracks[n].clips with tolerant field names
+    collectAsprojClips: function (doc) {
+        var specs = [];
+        function push(c, trackGain) {
+            if (!c || typeof c !== "object") { return; }
+            var src = c.src || c.data || c.vpath || c.file || c.url || "";
+            if (typeof src !== "string" || !src) { return; }
+            specs.push({
+                src: src,
+                start: parseFloat(c.start !== undefined ? c.start : c.offset) || 0,
+                in: parseFloat(c.in !== undefined ? c.in : c.trim) || 0,
+                out: (c.out !== undefined) ? parseFloat(c.out) : null,
+                volume: (parseFloat(c.volume !== undefined ? c.volume : c.gain) || 1) * trackGain
+            });
+        }
+        if (Array.isArray(doc.clips)) {
+            doc.clips.forEach(function (c) { push(c, 1); });
+        }
+        if (Array.isArray(doc.tracks)) {
+            doc.tracks.forEach(function (tr) {
+                if (tr && tr.muted) { return; }
+                var g = (tr && parseFloat(tr.volume !== undefined ? tr.volume : tr.gain)) || 1;
+                if (tr && Array.isArray(tr.clips)) {
+                    tr.clips.forEach(function (c) { push(c, g); });
+                }
+            });
+        }
+        return specs;
+    },
+
+    mixAsprojClips: function (specs) {
+        var AC = window.AudioContext || window.webkitAudioContext;
+        var decodeCtx = new AC();
+
+        function fetchBuf(src) {
+            var url = src.indexOf("data:") === 0 ? src
+                : (src.indexOf(":/") > 0 ? "../media?file=" + encodeURIComponent(src) : src);
+            return fetch(url)
+                .then(function (r) {
+                    if (!r.ok) { throw new Error("clip missing: " + src.substring(0, 40)); }
+                    return r.arrayBuffer();
+                })
+                .then(function (ab) { return decodeCtx.decodeAudioData(ab); });
+        }
+
+        return Promise.all(specs.map(function (s) { return fetchBuf(s.src); }))
+            .then(function (buffers) {
+                decodeCtx.close();
+                var sr = 44100;
+                var end = 0;
+                specs.forEach(function (s, i) {
+                    var dur = (s.out !== null ? s.out : buffers[i].duration) - s.in;
+                    end = Math.max(end, s.start + Math.max(0, dur));
+                });
+                if (end <= 0) { throw new Error("project is empty"); }
+                var off = new OfflineAudioContext(2, Math.ceil(end * sr), sr);
+                specs.forEach(function (s, i) {
+                    var srcNode = off.createBufferSource();
+                    srcNode.buffer = buffers[i];
+                    var gain = off.createGain();
+                    gain.gain.value = CS.clamp(s.volume, 0, 4);
+                    srcNode.connect(gain);
+                    gain.connect(off.destination);
+                    var dur = (s.out !== null ? s.out : buffers[i].duration) - s.in;
+                    srcNode.start(Math.max(0, s.start), Math.max(0, s.in), Math.max(0.01, dur));
+                });
+                return off.startRendering();
+            })
+            .then(function (rendered) {
+                return new Blob([CS.media.encodeWav(rendered)], { type: "audio/wav" });
+            });
+    },
+
+    //AudioBuffer -> 16-bit PCM WAV ArrayBuffer
+    encodeWav: function (buffer) {
+        var ch = Math.min(2, buffer.numberOfChannels);
+        var sr = buffer.sampleRate;
+        var n = buffer.length;
+        var bytes = 44 + n * ch * 2;
+        var ab = new ArrayBuffer(bytes);
+        var dv = new DataView(ab);
+        function ws(o, s) { for (var i = 0; i < s.length; i++) { dv.setUint8(o + i, s.charCodeAt(i)); } }
+        ws(0, "RIFF");
+        dv.setUint32(4, bytes - 8, true);
+        ws(8, "WAVEfmt ");
+        dv.setUint32(16, 16, true);
+        dv.setUint16(20, 1, true);
+        dv.setUint16(22, ch, true);
+        dv.setUint32(24, sr, true);
+        dv.setUint32(28, sr * ch * 2, true);
+        dv.setUint16(32, ch * 2, true);
+        dv.setUint16(34, 16, true);
+        ws(36, "data");
+        dv.setUint32(40, n * ch * 2, true);
+        var offset = 44;
+        var chans = [];
+        for (var c = 0; c < ch; c++) { chans.push(buffer.getChannelData(c)); }
+        for (var i = 0; i < n; i++) {
+            for (var c2 = 0; c2 < ch; c2++) {
+                var v = Math.max(-1, Math.min(1, chans[c2][i]));
+                dv.setInt16(offset, v * 32767, true);
+                offset += 2;
+            }
+        }
+        return ab;
+    },
+
     probeImage: function (media) {
         var img = new Image();
         img.onload = function () {
@@ -341,6 +603,7 @@ CS.media = {
     renderBin: function () {
         var grid = document.getElementById("bin-grid");
         grid.innerHTML = "";
+        grid.classList.toggle("list-mode", CS.state.binView === "list");
         var items = CS.media.filteredMedia();
 
         if (items.length === 0) {
@@ -522,7 +785,9 @@ CS.media = {
             kindBtn.click();
         });
         document.getElementById("btn-bin-view").addEventListener("click", function () {
-            CS.toast("Grid view is the only view in this version");
+            CS.state.binView = CS.state.binView === "list" ? "grid" : "list";
+            CS.setIcon(this.querySelector("[data-icon]"), CS.state.binView === "list" ? "nav-elements" : "list-view");
+            CS.media.renderBin();
         });
 
         //Accept drops from the ArozOS File Manager

+ 141 - 0
src/web/Cine Studio/js/panels.js

@@ -0,0 +1,141 @@
+/*
+    Cine Studio - side panel switching (nav rail) + filters and
+    libraries panels. The media bin column hosts one panel at a time,
+    selected via the data-panel attribute on #mediabin.
+*/
+"use strict";
+
+window.CS = window.CS || {};
+
+CS.panels = {
+
+    current: "media",
+
+    show: function (name) {
+        CS.panels.current = name;
+        document.getElementById("mediabin").setAttribute("data-panel", name);
+        if (name === "fx") { CS.effects.renderGallery(); }
+        else if (name === "titles") { CS.titles.renderPanel(); }
+        else if (name === "transitions") { CS.transitions.renderPanel(); }
+        else if (name === "elements") { CS.titles.renderElementsPanel(); }
+        else if (name === "filters") { CS.panels.renderFilters(); }
+        else if (name === "libraries") { CS.panels.renderLibraries(); }
+    },
+
+    //Re-evaluate "applied" markers after the selection or clip data changes
+    refresh: function () {
+        if (CS.panels.current === "fx") { CS.effects.refreshApplied(); }
+        else if (CS.panels.current === "transitions") { CS.transitions.refreshApplied(); }
+        else if (CS.panels.current === "filters") { CS.panels.refreshFiltersApplied(); }
+    },
+
+    /* ---------- filters (color preset looks) ---------- */
+
+    filterLooks: [
+        { v: "default", l: "Default", css: "" },
+        { v: "mono",    l: "Monochrome", css: "grayscale(1)" },
+        { v: "warm",    l: "Warm", css: "sepia(0.45) saturate(1.2)" },
+        { v: "cool",    l: "Cool", css: "hue-rotate(-18deg) saturate(1.05)" },
+        { v: "vivid",   l: "Vivid", css: "saturate(1.5) contrast(1.12)" }
+    ],
+
+    _filtersBuilt: false,
+    renderFilters: function () {
+        if (CS.panels._filtersBuilt) { CS.panels.refreshFiltersApplied(); return; }
+        CS.panels._filtersBuilt = true;
+        var grid = document.getElementById("filters-grid");
+        grid.innerHTML = "";
+        CS.panels.filterLooks.forEach(function (look) {
+            var card = document.createElement("div");
+            card.className = "fx-card";
+            card.dataset.preset = look.v;
+            var thumb = document.createElement("div");
+            thumb.className = "fx-thumb";
+            var c = document.createElement("canvas");
+            c.width = 150;
+            c.height = 94;
+            var ctx = c.getContext("2d");
+            if (look.css && ctx.filter !== undefined) { ctx.filter = look.css; }
+            ctx.drawImage(CS.effects.baseScene(150, 94), 0, 0);
+            thumb.appendChild(c);
+            var name = document.createElement("div");
+            name.className = "fx-name";
+            name.textContent = look.l;
+            card.appendChild(thumb);
+            card.appendChild(name);
+            card.addEventListener("click", function () {
+                var clip = CS.selectedClip();
+                if (!clip) { CS.toast("Select a clip on the timeline first"); return; }
+                var track = CS.getTrack(clip.trackId);
+                if (!track || track.kind !== "video") {
+                    CS.toast("Filters apply to clips on video tracks", true);
+                    return;
+                }
+                CS.inspector.applyPreset(clip, look.v);
+            });
+            grid.appendChild(card);
+        });
+        CS.panels.refreshFiltersApplied();
+    },
+
+    refreshFiltersApplied: function () {
+        var clip = CS.selectedClip();
+        var current = clip ? (clip.props.preset || "default") : "";
+        var cards = document.querySelectorAll("#filters-grid .fx-card");
+        for (var i = 0; i < cards.length; i++) {
+            cards[i].classList.toggle("applied", cards[i].dataset.preset === current);
+        }
+    },
+
+    /* ---------- libraries (saved projects) ---------- */
+
+    renderLibraries: function () {
+        var list = document.getElementById("libraries-list");
+        list.innerHTML = "";
+        if (!CS.inArozOS()) {
+            var hint = document.createElement("div");
+            hint.className = "lib-empty";
+            hint.textContent = "Saved projects are listed here when Cine Studio runs inside ArozOS.";
+            list.appendChild(hint);
+            return;
+        }
+        var loading = document.createElement("div");
+        loading.className = "lib-empty";
+        loading.textContent = "Loading projects...";
+        list.appendChild(loading);
+
+        ao_module_agirun("Cine Studio/backend/listprojects.js", {}, function (resp) {
+            var items;
+            try { items = typeof resp === "string" ? JSON.parse(resp) : resp; }
+            catch (e) { items = null; }
+            list.innerHTML = "";
+            if (!items || items.error || !items.length) {
+                var empty = document.createElement("div");
+                empty.className = "lib-empty";
+                empty.textContent = "No saved projects yet. Use Save As to store a project in Cine Studio/Projects.";
+                list.appendChild(empty);
+                return;
+            }
+            items.forEach(function (it) {
+                var btn = document.createElement("button");
+                btn.className = "lib-item";
+                btn.innerHTML = '<span data-icon="file"></span><span class="lib-name"></span>';
+                btn.querySelector(".lib-name").textContent = it.filename;
+                btn.title = it.vpath;
+                btn.addEventListener("click", function () {
+                    CS.fileio.confirmDiscard(function () {
+                        CS.fileio.openFromPath(it.vpath, it.filename);
+                    });
+                });
+                list.appendChild(btn);
+            });
+            CS.applyIcons(list);
+        }, function () {
+            list.innerHTML = "";
+            var err = document.createElement("div");
+            err.className = "lib-empty";
+            err.textContent = "Could not list projects.";
+            list.appendChild(err);
+        });
+    }
+};

+ 129 - 33
src/web/Cine Studio/js/player.js

@@ -21,6 +21,7 @@ CS.player = {
     exportDest: null,
     lastTick: 0,
     rafId: 0,
+    rate: 1,          //shuttle rate: 1 = normal, 2/4/8 = fast, negative = reverse
 
     init: function () {
         CS.player.canvas = document.getElementById("preview-canvas");
@@ -44,6 +45,10 @@ CS.player = {
             this.classList.toggle("active", CS.state.safeArea);
             CS.player.render();
         });
+        document.getElementById("btn-loop").addEventListener("click", function () {
+            CS.state.loop = !CS.state.loop;
+            this.classList.toggle("active", CS.state.loop);
+        });
 
         var zoomBtn = document.getElementById("btn-preview-zoom");
         zoomBtn.addEventListener("click", function () {
@@ -175,13 +180,31 @@ CS.player = {
     /* ---------- transport ---------- */
 
     toggle: function () {
-        if (CS.state.playing) { CS.player.pause(); } else { CS.player.play(); }
+        if (CS.state.playing) { CS.player.pause(); } else { CS.player.setRate(1); CS.player.play(); }
+    },
+
+    //JKL shuttle: L speeds up forward, J speeds up backward, K pauses
+    setRate: function (r) {
+        CS.player.rate = r;
+    },
+
+    shuttle: function (dir) {
+        var r = CS.player.rate;
+        if (!CS.state.playing || (dir > 0 && r < 0) || (dir < 0 && r > 0)) {
+            r = dir; //start at 1x in the requested direction
+        } else {
+            r = CS.clamp(r * 2, -8, 8); //same direction: double up to 8x
+        }
+        CS.player.setRate(r);
+        if (!CS.state.playing) { CS.player.play(); }
+        CS.toast("Playback " + (r > 0 ? "" : "-") + Math.abs(r) + "x");
     },
 
     play: function () {
         var dur = CS.timelineDuration();
         if (dur <= 0) { CS.toast("Timeline is empty"); return; }
-        if (CS.state.playhead >= dur - 0.01) { CS.state.playhead = 0; }
+        if (CS.player.rate > 0 && CS.state.playhead >= dur - 0.01) { CS.state.playhead = 0; }
+        if (CS.player.rate < 0 && CS.state.playhead <= 0.01) { CS.state.playhead = dur; }
         CS.player.initAudioBus();
         CS.state.playing = true;
         CS.player.lastTick = performance.now();
@@ -195,6 +218,7 @@ CS.player = {
 
     pause: function () {
         CS.state.playing = false;
+        CS.player.rate = 1;
         cancelAnimationFrame(CS.player.rafId);
         Object.keys(CS.player.pool).forEach(function (id) {
             if (!CS.player.pool[id].paused) { CS.player.pool[id].pause(); }
@@ -232,11 +256,12 @@ CS.player = {
         if (!CS.state.playing) { return; }
         var dt = (now - CS.player.lastTick) / 1000;
         CS.player.lastTick = now;
-        CS.state.playhead += dt;
+        CS.state.playhead += dt * CS.player.rate;
 
         var dur = CS.timelineDuration();
-        if (CS.state.playhead >= dur) {
-            CS.state.playhead = dur;
+        //Reverse shuttle reached the head of the timeline
+        if (CS.player.rate < 0 && CS.state.playhead <= 0) {
+            CS.state.playhead = 0;
             CS.player.syncElements();
             CS.player.render();
             CS.player.updateTransportUI();
@@ -244,6 +269,19 @@ CS.player = {
             CS.player.pause();
             return;
         }
+        if (CS.player.rate > 0 && CS.state.playhead >= dur) {
+            if (CS.state.loop) {
+                CS.state.playhead = 0; //loop back around and keep rolling
+            } else {
+                CS.state.playhead = dur;
+                CS.player.syncElements();
+                CS.player.render();
+                CS.player.updateTransportUI();
+                CS.timeline.updatePlayhead();
+                CS.player.pause();
+                return;
+            }
+        }
 
         CS.player.syncElements();
         CS.player.render();
@@ -265,32 +303,53 @@ CS.player = {
 
     syncElements: function () {
         var t = CS.state.playhead;
+        var frozen = CS.transitions.frozenTargets(t);
+        var rate = CS.player.rate;
+        var reverse = CS.state.playing && rate < 0;
+        //Track solo: when any audio track is soloed, other audio tracks mute
+        var anySolo = CS.project.tracks.some(function (tr) { return tr.kind === "audio" && tr.solo; });
+
         CS.project.clips.forEach(function (clip) {
+            if (clip.kind === "title" || clip.kind === "color") { return; }
             var media = CS.getMedia(clip.mediaId);
             if (!media || media.type === "image" || media.offline) { return; }
             var track = CS.getTrack(clip.trackId);
             var el = CS.player.ensureElement(clip, media);
             var active = CS.player.activeAt(clip, t) && track && track.visible;
-            var target = clip.in + (t - clip.start);
-
-            el.volume = CS.clamp((clip.props.volume === undefined ? 100 : clip.props.volume) / 100, 0, 1);
-            el.muted = !!(track && track.muted);
-
-            if (CS.state.playing && active) {
+            var speed = CS.clipSpeed(clip);
+            var target = clip.in + (t - clip.start) * speed;
+
+            //Volume: clip setting shaped by any fade in/out effects
+            var vol = (clip.props.volume === undefined ? 100 : clip.props.volume) / 100;
+            vol *= CS.effects.fadeAlpha(clip, t);
+            el.volume = CS.clamp(vol, 0, 1);
+            var soloMuted = anySolo && track && track.kind === "audio" && !track.solo;
+            el.muted = !!(track && track.muted) || soloMuted;
+
+            if (CS.state.playing && active && !reverse) {
+                try { el.playbackRate = CS.clamp(speed * Math.max(rate, 0.0625), 0.0625, 16); } catch (e) {}
                 if (el.paused) {
                     try { el.currentTime = target; } catch (e) {}
                     var p = el.play();
                     if (p && p.catch) { p.catch(function () {}); }
-                } else if (Math.abs(el.currentTime - target) > 0.14) {
+                } else if (Math.abs(el.currentTime - target) > 0.14 * Math.max(1, Math.abs(rate))) {
+                    try { el.currentTime = target; } catch (e) {}
+                }
+            } else if (CS.state.playing && active && reverse) {
+                //Media elements cannot play backwards: step frames by seeking
+                if (!el.paused) { el.pause(); }
+                if (Math.abs(el.currentTime - target) > 0.05) {
                     try { el.currentTime = target; } catch (e) {}
                 }
             } else if (CS.state.playing && !active) {
                 if (!el.paused) { el.pause(); }
             } else {
-                //Paused scrub: park the element on the exact frame
+                //Paused scrub: park the element on the exact frame; a clip
+                //feeding a transition freezes on its own last frame instead
                 if (!el.paused) { el.pause(); }
-                if (active && Math.abs(el.currentTime - target) > 0.03) {
-                    try { el.currentTime = target; } catch (e) {}
+                var parkAt = active ? target : frozen[clip.id];
+                if (parkAt !== undefined && Math.abs(el.currentTime - parkAt) > 0.03) {
+                    try { el.currentTime = parkAt; } catch (e) {}
                 }
             }
         });
@@ -325,6 +384,7 @@ CS.player = {
         if (!ctx) { return; }
         CS.player.renderFrame(ctx, CS.state.playhead);
         if (CS.state.safeArea) { CS.player.drawSafeArea(ctx); }
+        if (CS.previewctl && CS.previewctl.overlay) { CS.previewctl.redraw(); }
     },
 
     //Composite the frame at time t onto the given 2d context
@@ -340,7 +400,12 @@ CS.player = {
             if (!track.visible) { return; }
             CS.clipsOnTrack(track.id).forEach(function (clip) {
                 if (!CS.player.activeAt(clip, t)) { return; }
-                CS.player.drawClip(ctx, clip, W, H);
+                var win = CS.transitions.windowAt(clip, t);
+                if (win) {
+                    CS.transitions.draw(ctx, clip, t, W, H, win);
+                } else {
+                    CS.player.drawClip(ctx, clip, W, H, t, {});
+                }
             });
         });
 
@@ -348,27 +413,43 @@ CS.player = {
         ctx.globalAlpha = 1;
     },
 
-    drawClip: function (ctx, clip, W, H) {
-        var media = CS.getMedia(clip.mediaId);
-        if (!media || media.offline) { return; }
-
+    drawClip: function (ctx, clip, W, H, t, opts) {
+        opts = opts || {};
         var src, sw, sh;
-        if (media.type === "video") {
-            src = CS.player.pool[clip.id];
-            if (!src || src.readyState < 2) { return; }
-            sw = src.videoWidth;
-            sh = src.videoHeight;
-        } else if (media.type === "image") {
-            src = CS.player.ensureImage(media);
-            if (!src.complete || !src.naturalWidth) { return; }
-            sw = src.naturalWidth;
-            sh = src.naturalHeight;
+
+        if (clip.kind === "title" || clip.kind === "color") {
+            src = CS.titles.renderSource(clip, W, H);
+            sw = src.width;
+            sh = src.height;
         } else {
-            return; //audio has no visual
+            var media = CS.getMedia(clip.mediaId);
+            if (!media || media.offline) { return; }
+            if (media.type === "video") {
+                src = CS.player.pool[clip.id];
+                if (!src || src.readyState < 2) { return; }
+                sw = src.videoWidth;
+                sh = src.videoHeight;
+            } else if (media.type === "image") {
+                src = CS.player.ensureImage(media);
+                if (!src.complete || !src.naturalWidth) { return; }
+                sw = src.naturalWidth;
+                sh = src.naturalHeight;
+            } else {
+                return; //audio has no visual
+            }
         }
         if (!sw || !sh) { return; }
 
         var p = clip.props;
+        var fx = CS.effects.analyze(clip, t === undefined ? CS.state.playhead : t, W);
+        if (fx.alpha <= 0) { return; }
+
+        if (fx.pixelate > 1) {
+            src = CS.effects.pixelateSource(src, sw, sh, fx.pixelate);
+            sw = src.width;
+            sh = src.height;
+        }
+
         var dw, dh;
         if (p.crop === "stretch") {
             dw = W;
@@ -383,14 +464,29 @@ CS.player = {
         dh *= userScale;
 
         ctx.save();
+        if (p.blend && p.blend !== "normal") {
+            ctx.globalCompositeOperation = p.blend;
+        }
         ctx.translate(W / 2 + (p.x || 0), H / 2 + (p.y || 0));
         if (p.rotation) { ctx.rotate(p.rotation * Math.PI / 180); }
-        ctx.globalAlpha = CS.clamp((p.opacity === undefined ? 100 : p.opacity) / 100, 0, 1);
-        ctx.filter = CS.player.buildFilter(p);
+        var flipX = (fx.mirror ? -1 : 1) * (p.flipH ? -1 : 1);
+        var flipY = p.flipV ? -1 : 1;
+        if (flipX !== 1 || flipY !== 1) { ctx.scale(flipX, flipY); }
+        var alpha = CS.clamp((p.opacity === undefined ? 100 : p.opacity) / 100, 0, 1) * fx.alpha;
+        if (opts.alphaMul !== undefined) { alpha *= CS.clamp(opts.alphaMul, 0, 1); }
+        ctx.globalAlpha = alpha;
+        var baseFilter = CS.player.buildFilter(p);
+        var filterStr = ((baseFilter === "none" ? "" : baseFilter) + fx.filter).trim();
+        ctx.filter = filterStr || "none";
+        if (fx.pixelate > 1) { ctx.imageSmoothingEnabled = false; }
         try {
             ctx.drawImage(src, -dw / 2, -dh / 2, dw, dh);
         } catch (e) { /* frame not decodable yet */ }
         ctx.restore();
+
+        //Full-frame overlays after the clip itself
+        if (fx.vignette > 0) { CS.effects.drawVignette(ctx, W, H, fx.vignette * fx.alpha); }
+        if (fx.grain > 0) { CS.effects.drawGrain(ctx, W, H, fx.grain); }
     },
 
     drawSafeArea: function (ctx) {

+ 284 - 0
src/web/Cine Studio/js/previewctl.js

@@ -0,0 +1,284 @@
+/*
+    Cine Studio - direct manipulation on the preview
+
+    An overlay canvas on top of the preview lets the user click a clip
+    to select it, drag it to reposition (props.x / props.y) and pull a
+    corner handle to scale it (props.scale). Works for video, image,
+    title and color clips on video tracks; rotation aware.
+*/
+"use strict";
+
+window.CS = window.CS || {};
+
+CS.previewctl = {
+    overlay: null,
+    ctx: null,
+    drag: null,
+    HANDLE_R: 7,        //handle hit radius in display px
+
+    init: function () {
+        CS.previewctl.overlay = document.getElementById("preview-overlay");
+        CS.previewctl.ctx = CS.previewctl.overlay.getContext("2d");
+        var ov = CS.previewctl.overlay;
+        ov.addEventListener("pointerdown", CS.previewctl.onDown);
+        ov.addEventListener("pointermove", CS.previewctl.onHover);
+        window.addEventListener("resize", CS.previewctl.redraw);
+    },
+
+    /* ---------- geometry ---------- */
+
+    //Display px per project px (the canvas is CSS-scaled to fit the stage)
+    displayScale: function () {
+        var canvas = CS.player.canvas;
+        var rect = canvas.getBoundingClientRect();
+        return rect.width / CS.project.settings.width;
+    },
+
+    toProject: function (ev) {
+        var rect = CS.player.canvas.getBoundingClientRect();
+        var s = rect.width / CS.project.settings.width;
+        return {
+            x: (ev.clientX - rect.left) / s,
+            y: (ev.clientY - rect.top) / s
+        };
+    },
+
+    //Source dimensions for a clip's frame (project coords, before transform)
+    sourceDims: function (clip) {
+        var W = CS.project.settings.width;
+        var H = CS.project.settings.height;
+        if (clip.kind === "title" || clip.kind === "color") { return { sw: W, sh: H }; }
+        var media = CS.getMedia(clip.mediaId);
+        if (!media || !media.width || !media.height) { return null; }
+        return { sw: media.width, sh: media.height };
+    },
+
+    //Bounding box {cx, cy, w, h, rot} of the clip in project coordinates,
+    //mirroring the drawClip crop / scale math
+    clipBox: function (clip) {
+        var dims = CS.previewctl.sourceDims(clip);
+        if (!dims) { return null; }
+        var W = CS.project.settings.width;
+        var H = CS.project.settings.height;
+        var p = clip.props;
+        var dw, dh;
+        if (p.crop === "stretch") {
+            dw = W;
+            dh = H;
+        } else {
+            var s = (p.crop === "fill")
+                ? Math.max(W / dims.sw, H / dims.sh)
+                : Math.min(W / dims.sw, H / dims.sh);
+            dw = dims.sw * s;
+            dh = dims.sh * s;
+        }
+        var userScale = (p.scale === undefined ? 100 : p.scale) / 100;
+        return {
+            cx: W / 2 + (p.x || 0),
+            cy: H / 2 + (p.y || 0),
+            w: dw * userScale,
+            h: dh * userScale,
+            rot: (p.rotation || 0) * Math.PI / 180
+        };
+    },
+
+    corners: function (box) {
+        var cos = Math.cos(box.rot), sin = Math.sin(box.rot);
+        var hw = box.w / 2, hh = box.h / 2;
+        return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(function (c) {
+            return {
+                x: box.cx + c[0] * cos - c[1] * sin,
+                y: box.cy + c[0] * sin + c[1] * cos
+            };
+        });
+    },
+
+    insideBox: function (box, x, y) {
+        var dx = x - box.cx, dy = y - box.cy;
+        var cos = Math.cos(-box.rot), sin = Math.sin(-box.rot);
+        var lx = dx * cos - dy * sin;
+        var ly = dx * sin + dy * cos;
+        return Math.abs(lx) <= box.w / 2 && Math.abs(ly) <= box.h / 2;
+    },
+
+    //Corner handle index under the pointer (project coords), or -1
+    handleAt: function (box, x, y) {
+        var r = CS.previewctl.HANDLE_R / CS.previewctl.displayScale();
+        var pts = CS.previewctl.corners(box);
+        for (var i = 0; i < pts.length; i++) {
+            if (Math.hypot(pts[i].x - x, pts[i].y - y) <= r * 1.6) { return i; }
+        }
+        return -1;
+    },
+
+    //Clip on a video track that is visible at the playhead and whose box
+    //contains the point; topmost track wins
+    topClipAt: function (x, y) {
+        var t = CS.state.playhead;
+        var tracks = CS.videoTracksInRenderOrder().slice().reverse();
+        for (var i = 0; i < tracks.length; i++) {
+            if (!tracks[i].visible) { continue; }
+            var clips = CS.clipsOnTrack(tracks[i].id);
+            for (var j = 0; j < clips.length; j++) {
+                if (!CS.player.activeAt(clips[j], t)) { continue; }
+                var box = CS.previewctl.clipBox(clips[j]);
+                if (box && CS.previewctl.insideBox(box, x, y)) { return clips[j]; }
+            }
+        }
+        return null;
+    },
+
+    //The selected clip, when it is manipulable on the preview right now
+    activeSelection: function () {
+        var clip = CS.selectedClip();
+        if (!clip) { return null; }
+        var track = CS.getTrack(clip.trackId);
+        if (!track || track.kind !== "video" || !track.visible) { return null; }
+        if (!CS.player.activeAt(clip, CS.state.playhead)) { return null; }
+        return clip;
+    },
+
+    /* ---------- pointer interaction ---------- */
+
+    onDown: function (ev) {
+        if (ev.button !== 0) { return; }
+        var pos = CS.previewctl.toProject(ev);
+        var clip = CS.previewctl.activeSelection();
+        var mode = null;
+
+        if (clip) {
+            var box = CS.previewctl.clipBox(clip);
+            var h = box ? CS.previewctl.handleAt(box, pos.x, pos.y) : -1;
+            if (h >= 0) { mode = "resize"; }
+            else if (box && CS.previewctl.insideBox(box, pos.x, pos.y)) { mode = "move"; }
+        }
+        if (!mode) {
+            var hit = CS.previewctl.topClipAt(pos.x, pos.y);
+            if (hit) {
+                CS.selectClip(hit.id);
+                clip = hit;
+                mode = "move";
+            } else {
+                CS.selectClip(null);
+                return;
+            }
+        }
+
+        CS.player.pause();
+        var box0 = CS.previewctl.clipBox(clip);
+        CS.previewctl.drag = {
+            mode: mode,
+            clip: clip,
+            startPos: pos,
+            origX: clip.props.x || 0,
+            origY: clip.props.y || 0,
+            origScale: clip.props.scale === undefined ? 100 : clip.props.scale,
+            origDist: Math.max(4, Math.hypot(pos.x - box0.cx, pos.y - box0.cy)),
+            moved: false
+        };
+        var ov = CS.previewctl.overlay;
+        ov.setPointerCapture(ev.pointerId);
+        ov.onpointermove = CS.previewctl.onDrag;
+        ov.onpointerup = CS.previewctl.onUp;
+        ev.preventDefault();
+    },
+
+    onDrag: function (ev) {
+        var d = CS.previewctl.drag;
+        if (!d) { return; }
+        var pos = CS.previewctl.toProject(ev);
+        var dx = pos.x - d.startPos.x;
+        var dy = pos.y - d.startPos.y;
+        if (!d.moved && Math.hypot(dx, dy) < 2 / CS.previewctl.displayScale()) { return; }
+        d.moved = true;
+
+        if (d.mode === "move") {
+            d.clip.props.x = Math.round(d.origX + dx);
+            d.clip.props.y = Math.round(d.origY + dy);
+        } else {
+            //uniform scale around the clip center, rotation independent
+            var box = CS.previewctl.clipBox(d.clip);
+            var dist = Math.hypot(pos.x - box.cx, pos.y - box.cy);
+            d.clip.props.scale = CS.clamp(Math.round(d.origScale * dist / d.origDist), 1, 1000);
+        }
+        CS.player.render();
+    },
+
+    onUp: function () {
+        var d = CS.previewctl.drag;
+        var ov = CS.previewctl.overlay;
+        ov.onpointermove = null;
+        ov.onpointerup = null;
+        CS.previewctl.drag = null;
+        if (d && d.moved) {
+            CS.commit(d.mode === "move" ? "Move in Preview" : "Scale in Preview");
+        }
+    },
+
+    //Cursor feedback while not dragging
+    onHover: function (ev) {
+        if (CS.previewctl.drag) { return; }
+        var clip = CS.previewctl.activeSelection();
+        var cursor = "default";
+        if (clip) {
+            var pos = CS.previewctl.toProject(ev);
+            var box = CS.previewctl.clipBox(clip);
+            if (box) {
+                if (CS.previewctl.handleAt(box, pos.x, pos.y) >= 0) { cursor = "nwse-resize"; }
+                else if (CS.previewctl.insideBox(box, pos.x, pos.y)) { cursor = "move"; }
+            }
+        }
+        CS.previewctl.overlay.style.cursor = cursor;
+    },
+
+    /* ---------- overlay drawing ---------- */
+
+    redraw: function () {
+        var ov = CS.previewctl.overlay;
+        var ctx = CS.previewctl.ctx;
+        if (!ov || !ctx || !CS.player.canvas) { return; }
+        var rect = CS.player.canvas.getBoundingClientRect();
+        var dpr = window.devicePixelRatio || 1;
+        var w = Math.max(1, Math.round(rect.width));
+        var h = Math.max(1, Math.round(rect.height));
+        if (ov.width !== w * dpr || ov.height !== h * dpr) {
+            ov.width = w * dpr;
+            ov.height = h * dpr;
+        }
+        ov.style.width = w + "px";
+        ov.style.height = h + "px";
+        //Anchor the overlay exactly over the (centered) canvas
+        var wrapRect = document.getElementById("canvas-wrap").getBoundingClientRect();
+        ov.style.left = (rect.left - wrapRect.left) + "px";
+        ov.style.top = (rect.top - wrapRect.top) + "px";
+        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+        ctx.clearRect(0, 0, w, h);
+
+        var clip = CS.previewctl.activeSelection();
+        if (!clip) { return; }
+        var box = CS.previewctl.clipBox(clip);
+        if (!box) { return; }
+        var s = w / CS.project.settings.width;
+        var pts = CS.previewctl.corners(box).map(function (p) {
+            return { x: p.x * s, y: p.y * s };
+        });
+
+        ctx.strokeStyle = "rgba(46, 124, 246, 0.95)";
+        ctx.lineWidth = 1.5;
+        ctx.beginPath();
+        ctx.moveTo(pts[0].x, pts[0].y);
+        for (var i = 1; i < 4; i++) { ctx.lineTo(pts[i].x, pts[i].y); }
+        ctx.closePath();
+        ctx.stroke();
+
+        pts.forEach(function (p) {
+            ctx.beginPath();
+            ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
+            ctx.fillStyle = "#ffffff";
+            ctx.fill();
+            ctx.strokeStyle = "rgba(0,0,0,0.6)";
+            ctx.lineWidth = 1;
+            ctx.stroke();
+        });
+    }
+};

+ 120 - 0
src/web/Cine Studio/js/session.js

@@ -0,0 +1,120 @@
+/*
+    Cine Studio - session safety: auto-save / crash recovery and the
+    recent projects list. Snapshots live in localStorage (projects are
+    small: media is stored as virtual-path references).
+*/
+"use strict";
+
+window.CS = window.CS || {};
+
+CS.session = {
+    AUTOSAVE_KEY: "cinestudio_autosave",
+    RECENT_KEY: "cinestudio_recent",
+    INTERVAL_MS: 20000,
+
+    init: function () {
+        setInterval(function () {
+            if (CS.state.dirty) { CS.session.saveSnapshot(); }
+        }, CS.session.INTERVAL_MS);
+        window.addEventListener("beforeunload", function () {
+            if (CS.state.dirty) { CS.session.saveSnapshot(); }
+        });
+    },
+
+    /* ---------- auto-save snapshot ---------- */
+
+    saveSnapshot: function () {
+        try {
+            localStorage.setItem(CS.session.AUTOSAVE_KEY, JSON.stringify({
+                when: Date.now(),
+                name: CS.project.name,
+                filePath: CS.project.filePath,
+                fileName: CS.project.fileName,
+                data: JSON.parse(CS.fileio.serializeProject())
+            }));
+        } catch (e) { /* storage full or blocked: recovery just unavailable */ }
+    },
+
+    clearSnapshot: function () {
+        try { localStorage.removeItem(CS.session.AUTOSAVE_KEY); } catch (e) {}
+    },
+
+    //Offer to restore an unsaved session left behind by a crash / close
+    checkRecovery: function () {
+        var raw = null;
+        try { raw = localStorage.getItem(CS.session.AUTOSAVE_KEY); } catch (e) {}
+        if (!raw) { return; }
+        var snap;
+        try { snap = JSON.parse(raw); } catch (e) { CS.session.clearSnapshot(); return; }
+        if (!snap || !snap.data) { CS.session.clearSnapshot(); return; }
+
+        var age = Math.max(1, Math.round((Date.now() - snap.when) / 60000));
+        CS.modal({
+            title: "Restore unsaved project?",
+            build: function (body) {
+                var p = document.createElement("div");
+                p.className = "modal-note";
+                p.style.fontSize = "12.5px";
+                p.style.color = "var(--text-dim)";
+                p.textContent = "An auto-saved copy of \"" + (snap.name || "Untitled") +
+                    "\" from about " + age + " minute" + (age === 1 ? "" : "s") +
+                    " ago was found. Restore it?";
+                body.appendChild(p);
+            },
+            buttons: [
+                {
+                    label: "Discard", action: function () {
+                        CS.session.clearSnapshot();
+                    }
+                },
+                {
+                    label: "Restore", primary: true,
+                    action: function () {
+                        CS.fileio.loadProject(snap.data, snap.filePath || "", snap.fileName || "");
+                        CS.project.name = snap.name || CS.project.name;
+                        CS.markDirty(); //restored content is not on disk yet
+                    }
+                }
+            ]
+        });
+    },
+
+    /* ---------- recent projects ---------- */
+
+    recents: function () {
+        try {
+            return JSON.parse(localStorage.getItem(CS.session.RECENT_KEY)) || [];
+        } catch (e) { return []; }
+    },
+
+    recordRecent: function (name, vpath) {
+        if (!vpath) { return; }
+        var list = CS.session.recents().filter(function (r) { return r.vpath !== vpath; });
+        list.unshift({ name: name, vpath: vpath, when: Date.now() });
+        list = list.slice(0, 8);
+        try { localStorage.setItem(CS.session.RECENT_KEY, JSON.stringify(list)); } catch (e) {}
+    },
+
+    openRecentMenu: function (anchorEl) {
+        var list = CS.session.recents();
+        if (!list.length) { CS.toast("No recent projects yet"); return; }
+        var items = list.map(function (r) {
+            return {
+                label: r.name || r.vpath.split("/").pop(),
+                icon: "file",
+                action: function () {
+                    CS.fileio.confirmDiscard(function () {
+                        CS.fileio.openFromPath(r.vpath, r.vpath.split("/").pop());
+                    });
+                }
+            };
+        });
+        items.push({ sep: true });
+        items.push({
+            label: "Clear recent list", icon: "trash", action: function () {
+                try { localStorage.removeItem(CS.session.RECENT_KEY); } catch (e) {}
+            }
+        });
+        CS.showMenuUnder(anchorEl, items);
+    }
+};

+ 211 - 35
src/web/Cine Studio/js/timeline.js

@@ -54,14 +54,30 @@ CS.timeline = {
         document.getElementById("tool-select").addEventListener("click", function () { CS.timeline.setTool("select"); });
         document.getElementById("tool-blade").addEventListener("click", function () { CS.timeline.setTool("blade"); });
         document.getElementById("tool-crop").addEventListener("click", function () {
-            CS.toast("Use the Crop section in the inspector");
+            //Jump to the Crop controls in the inspector
+            document.getElementById("inspector").classList.remove("hidden");
+            CS.inspector.activeTab = "video";
+            CS.inspector._collapsed["Crop"] = false;
+            CS.inspector.updateTabs();
+            CS.inspector.render();
+            if (!CS.selectedClip()) { CS.toast("Select a clip to crop"); }
         });
         document.getElementById("tool-text").addEventListener("click", function () {
-            CS.toast("Titles are not available in this version");
+            CS.titles.insertPreset("title");
         });
         document.getElementById("tool-audio").addEventListener("click", function () {
-            CS.toast("Use the Audio tab in the inspector");
+            document.getElementById("inspector").classList.remove("hidden");
+            CS.inspector.activeTab = "audio";
+            CS.inspector.updateTabs();
+            CS.inspector.render();
+            if (!CS.selectedClip()) { CS.toast("Select a clip to adjust its audio"); }
         });
+        document.getElementById("btn-snap").addEventListener("click", function () {
+            CS.state.snap = !CS.state.snap;
+            this.classList.toggle("active", CS.state.snap);
+            CS.toast("Snapping " + (CS.state.snap ? "on" : "off"));
+        });
+        document.getElementById("btn-marker").addEventListener("click", CS.toggleMarkerAtPlayhead);
         document.getElementById("btn-undo").addEventListener("click", CS.undo);
         document.getElementById("btn-redo").addEventListener("click", CS.redo);
         document.getElementById("btn-delete-clip").addEventListener("click", CS.deleteSelectedClip);
@@ -72,6 +88,31 @@ CS.timeline = {
                 { label: "Add audio track", icon: "speaker", action: function () { CS.addTrack("audio"); } }
             ]);
         });
+
+        //Dropping media on the empty area beyond the lanes creates a track
+        var content = document.getElementById("tl-content");
+        content.addEventListener("dragover", function (ev) {
+            ev.preventDefault();
+        });
+        content.addEventListener("drop", function (ev) {
+            if (ev.target.closest && ev.target.closest(".tl-track")) { return; } //lane handled it
+            ev.preventDefault();
+            var mediaId = ev.dataTransfer && ev.dataTransfer.getData("cinestudio/media");
+            if (!mediaId) { return; }
+            var media = CS.getMedia(mediaId);
+            if (!media) { return; }
+            if (media.offline) { CS.toast("Cannot use offline media", true); return; }
+            var kind = media.type === "audio" ? "audio" : "video";
+            var trackId = CS.createTrack(kind);
+            var s = document.getElementById("tl-scroll");
+            var rect = s.getBoundingClientRect();
+            var t = Math.max(0, (ev.clientX - rect.left + s.scrollLeft) / CS.state.zoom);
+            var clip = CS.addClipToTimeline(media, trackId, t);
+            if (clip) {
+                CS.state.selectedClipId = clip.id;
+                CS.commit("Add Clip on New Track");
+            }
+        });
     },
 
     setTool: function (tool) {
@@ -123,7 +164,7 @@ CS.timeline = {
         holder.innerHTML = "";
         CS.tracksInDisplayOrder().forEach(function (track) {
             var h = document.createElement("div");
-            h.className = "track-header";
+            h.className = "track-header" + (track.solo ? " solo" : "");
             h.style.height = CS.timeline.trackHeight(track) + "px";
 
             var icon = document.createElement("span");
@@ -160,15 +201,25 @@ CS.timeline = {
     trackMenu: function (track, x, y) {
         var empty = CS.clipsOnTrack(track.id).length === 0;
         var sameKind = CS.project.tracks.filter(function (t) { return t.kind === track.kind; }).length;
-        CS.showMenu([
-            {
-                label: "Delete track", icon: "trash", disabled: !empty || sameKind <= 1,
+        var items = [];
+        if (track.kind === "audio") {
+            items.push({
+                label: "Solo", icon: "speaker", checked: !!track.solo,
                 action: function () {
-                    CS.project.tracks = CS.project.tracks.filter(function (t) { return t.id !== track.id; });
-                    CS.commit("Delete Track");
+                    track.solo = !track.solo;
+                    CS.commit(track.solo ? "Solo Track" : "Unsolo Track");
                 }
+            });
+            items.push({ sep: true });
+        }
+        items.push({
+            label: "Delete track", icon: "trash", disabled: !empty || sameKind <= 1,
+            action: function () {
+                CS.project.tracks = CS.project.tracks.filter(function (t) { return t.id !== track.id; });
+                CS.commit("Delete Track");
             }
-        ], x, y);
+        });
+        CS.showMenu(items, x, y);
     },
 
     renderTracks: function () {
@@ -210,7 +261,23 @@ CS.timeline = {
         el.style.width = w + "px";
         if (clip.id === CS.state.selectedClipId) { el.classList.add("selected"); }
 
-        if (!media || media.offline) {
+        if (clip.kind === "title") {
+            el.classList.add("title-clip");
+            var tlbl = document.createElement("span");
+            tlbl.className = "clip-label";
+            tlbl.textContent = (clip.props.text && clip.props.text.content) || "Title";
+            el.appendChild(tlbl);
+        } else if (clip.kind === "color") {
+            el.classList.add("color-clip");
+            var cspec = clip.props.color || {};
+            el.style.background = cspec.c1
+                ? "linear-gradient(180deg, " + cspec.c0 + ", " + cspec.c1 + ")"
+                : (cspec.c0 || "#000");
+            var clbl = document.createElement("span");
+            clbl.className = "clip-label";
+            clbl.textContent = "Color";
+            el.appendChild(clbl);
+        } else if (!media || media.offline) {
             el.classList.add("offline");
             var lbl = document.createElement("span");
             lbl.className = "clip-label";
@@ -239,6 +306,19 @@ CS.timeline = {
             el.appendChild(strip);
         }
 
+        //Markers: effect stack + transition-in
+        if (clip.props.effects && clip.props.effects.length) {
+            var fxBadge = document.createElement("span");
+            fxBadge.className = "clip-fx";
+            fxBadge.textContent = "fx";
+            el.appendChild(fxBadge);
+        }
+        if (clip.props.transition && clip.props.transition.type !== "none") {
+            var trMark = document.createElement("span");
+            trMark.className = "clip-tr";
+            el.appendChild(trMark);
+        }
+
         //Trim handles
         ["left", "right"].forEach(function (side) {
             var handle = document.createElement("div");
@@ -261,20 +341,44 @@ CS.timeline = {
                 }
                 return;
             }
-            CS.selectClip(clip.id);
+            if (ev.shiftKey) {
+                //Shift-click: extend / shrink the multi-selection, no drag
+                CS.toggleSelectClip(clip.id);
+                return;
+            }
+            if ((CS.state.selectedClipIds || []).indexOf(clip.id) < 0) {
+                CS.selectClip(clip.id);
+            } else {
+                //Clicked inside an existing multi-selection: keep the group
+                CS.state.selectedClipId = clip.id;
+                CS.timeline.refreshSelection();
+                CS.inspector.render();
+            }
             CS.timeline.beginDrag(ev, clip, el, "move");
         });
 
         el.addEventListener("contextmenu", function (ev) {
             ev.preventDefault();
-            CS.selectClip(clip.id);
+            if ((CS.state.selectedClipIds || []).indexOf(clip.id) < 0) {
+                CS.selectClip(clip.id);
+            }
+            var canDetach = media && media.type === "video" && track.kind === "video" &&
+                !clip.props.audioDetached;
             CS.showMenu([
+                { label: "Copy", icon: "copy", action: CS.copySelectedClips },
+                { label: "Paste at playhead", icon: "copy", disabled: !CS.clipClipboard, action: CS.pasteClipsAtPlayhead },
+                { label: "Duplicate", icon: "plus-square", action: CS.duplicateSelectedClips },
+                { sep: true },
                 { label: "Split at playhead", icon: "scissors", action: CS.splitAtPlayhead },
+                { label: "Detach audio", icon: "detach", disabled: !canDetach, action: function () {
+                    CS.detachAudio(clip);
+                } },
                 { label: "Reset properties", icon: "rotate-ccw", action: function () {
                     clip.props = CS.defaultClipProps();
                     CS.commit("Reset Clip");
                 } },
                 { sep: true },
+                { label: "Ripple delete", icon: "trash", action: CS.rippleDeleteSelected },
                 { label: "Delete", icon: "trash", action: CS.deleteSelectedClip }
             ], ev.clientX, ev.clientY);
         });
@@ -323,9 +427,10 @@ CS.timeline = {
     },
 
     refreshSelection: function () {
+        var ids = CS.state.selectedClipIds || [];
         var nodes = document.querySelectorAll(".tl-clip");
         for (var i = 0; i < nodes.length; i++) {
-            nodes[i].classList.toggle("selected", nodes[i].dataset.clipId === CS.state.selectedClipId);
+            nodes[i].classList.toggle("selected", ids.indexOf(nodes[i].dataset.clipId) >= 0);
         }
     },
 
@@ -334,11 +439,22 @@ CS.timeline = {
     beginDrag: function (ev, clip, el, mode) {
         if (CS.state.tool === "blade") { return; }
         CS.player.pause();
-        el.setPointerCapture(ev.pointerId);
+        //Move mode drags the whole selection when the clip belongs to it
+        var group = [{ clip: clip, origStart: clip.start }];
+        if (mode === "move") {
+            var ids = CS.state.selectedClipIds || [];
+            if (ids.indexOf(clip.id) >= 0 && ids.length > 1) {
+                group = ids.map(function (id) {
+                    var c = CS.getClip(id);
+                    return c ? { clip: c, origStart: c.start } : null;
+                }).filter(function (g) { return !!g; });
+            }
+        }
         CS.timeline._drag = {
             mode: mode,
             clip: clip,
             el: el,
+            group: group,
             pointerId: ev.pointerId,
             startX: ev.clientX,
             startY: ev.clientY,
@@ -348,8 +464,10 @@ CS.timeline = {
             origTrackId: clip.trackId,
             moved: false
         };
-        el.onpointermove = CS.timeline.onDragMove;
-        el.onpointerup = CS.timeline.onDragEnd;
+        //Track on the window: element-level capture is silently lost when
+        //the clip is reparented into another lane mid-drag
+        window.addEventListener("pointermove", CS.timeline.onDragMove);
+        window.addEventListener("pointerup", CS.timeline.onDragEnd);
     },
 
     onDragMove: function (ev) {
@@ -362,7 +480,8 @@ CS.timeline = {
         var dt = dx / CS.state.zoom;
         var clip = d.clip;
         var media = CS.getMedia(clip.mediaId);
-        var isImage = media && media.type === "image";
+        //Images, titles and color boards have no intrinsic duration
+        var isImage = !media || media.type === "image";
 
         if (d.mode === "move") {
             var target = Math.max(0, d.origStart + dt);
@@ -370,35 +489,67 @@ CS.timeline = {
             //If no snap on the left edge, try snapping the right edge
             clip.start = target;
 
-            //Vertical: move across compatible tracks
+            //Group move: apply the (snapped) primary delta to every member
+            var appliedDt = clip.start - d.origStart;
+            d.group.forEach(function (g) {
+                if (g.clip.id === clip.id) { return; }
+                g.clip.start = Math.max(0, g.origStart + appliedDt);
+                var gel = document.querySelector('.tl-clip[data-clip-id="' + g.clip.id + '"]');
+                if (gel) { gel.style.left = (g.clip.start * CS.state.zoom) + "px"; }
+            });
+
+            //Vertical: move across compatible tracks (single-clip drags only)
+            var isAudioClip = media && media.type === "audio";
             var lane = CS.timeline.laneUnderPointer(ev.clientY);
-            if (lane) {
+            d.newTrackKind = null;
+            if (d.group.length > 1) {
+                //group drags stay on their own tracks
+            } else if (lane) {
                 var track = CS.getTrack(lane.dataset.trackId);
-                var kindOk = track && ((track.kind === "audio") === (media && media.type === "audio"));
+                var kindOk = track && ((track.kind === "audio") === isAudioClip);
                 if (kindOk && track.id !== clip.trackId) {
                     clip.trackId = track.id;
                     lane.appendChild(d.el);
                 }
+            } else {
+                //Dragged past the outermost lanes: offer a brand-new track
+                //(video above the top lane, audio below the bottom lane)
+                var lanesRect = document.getElementById("tl-tracks").getBoundingClientRect();
+                if (isAudioClip && ev.clientY > lanesRect.bottom) {
+                    d.newTrackKind = "audio";
+                } else if (!isAudioClip && ev.clientY < lanesRect.top) {
+                    d.newTrackKind = "video";
+                } else if (!isAudioClip && ev.clientY > lanesRect.bottom) {
+                    //below everything also works for video: stack a new track on top
+                    d.newTrackKind = "video";
+                }
             }
             d.el.style.left = (clip.start * CS.state.zoom) + "px";
         } else {
             var minDur = CS.timeline.MIN_CLIP_DUR;
+            //Timeline deltas convert to source-time deltas via the clip speed
+            var v = isImage ? 1 : CS.clipSpeed(clip);
             if (d.mode === "trim-l") {
                 var newStart = d.origStart + dt;
-                var maxStart = d.origStart + (d.origOut - d.origIn) - minDur;
-                newStart = CS.clamp(newStart, isImage ? 0 : d.origStart - d.origIn, maxStart);
+                var maxStart = d.origStart + (d.origOut - d.origIn) / v - minDur;
+                newStart = CS.clamp(newStart, isImage ? 0 : d.origStart - d.origIn / v, maxStart);
                 newStart = Math.max(0, CS.timeline.applySnap(newStart, clip, "trim"));
                 var delta = newStart - d.origStart;
                 clip.start = newStart;
-                clip.in = isImage ? 0 : d.origIn + delta;
-                if (isImage) { clip.out = d.origOut - delta; }
+                if (isImage) {
+                    //Free-duration clips renormalize to in = 0
+                    clip.in = 0;
+                    clip.out = (d.origOut - d.origIn) - delta;
+                } else {
+                    clip.in = d.origIn + delta * v;
+                }
             } else {
-                var newOut = d.origOut + dt;
+                var newOut = d.origOut + dt * v;
                 var maxOut = isImage ? 1e9 : (media && media.duration ? media.duration : d.origOut);
-                newOut = CS.clamp(newOut, d.origIn + minDur, maxOut);
-                var endTime = clip.start + (newOut - clip.in);
+                newOut = CS.clamp(newOut, d.origIn + minDur * v, maxOut);
+                var endTime = clip.start + (newOut - clip.in) / v;
                 endTime = CS.timeline.applySnap(endTime, clip, "end");
-                newOut = CS.clamp(endTime - clip.start + clip.in, d.origIn + minDur, maxOut);
+                newOut = CS.clamp((endTime - clip.start) * v + clip.in, d.origIn + minDur * v, maxOut);
                 clip.out = newOut;
             }
             d.el.style.left = (clip.start * CS.state.zoom) + "px";
@@ -419,8 +570,8 @@ CS.timeline = {
     onDragEnd: function (ev) {
         var d = CS.timeline._drag;
         if (!d) { return; }
-        d.el.onpointermove = null;
-        d.el.onpointerup = null;
+        window.removeEventListener("pointermove", CS.timeline.onDragMove);
+        window.removeEventListener("pointerup", CS.timeline.onDragEnd);
         CS.timeline._drag = null;
         CS.timeline.hideSnapGuide();
 
@@ -431,7 +582,14 @@ CS.timeline = {
 
         var clip = d.clip;
         if (d.mode === "move") {
-            clip.start = CS.timeline.resolveOverlap(clip, clip.trackId, clip.start);
+            if (d.newTrackKind && d.group.length === 1) {
+                clip.trackId = CS.createTrack(d.newTrackKind);
+            }
+            //Settle every group member without overlaps, left to right
+            d.group.slice().sort(function (a, b) { return a.clip.start - b.clip.start; })
+                .forEach(function (g) {
+                    g.clip.start = CS.timeline.resolveOverlap(g.clip, g.clip.trackId, g.clip.start);
+                });
         } else {
             //Trimming may have created an overlap with the next clip: clamp
             CS.timeline.clampTrimOverlap(clip);
@@ -457,11 +615,13 @@ CS.timeline = {
             pts.push(c.start);
             pts.push(CS.clipEnd(c));
         });
+        (CS.project.markers || []).forEach(function (m) { pts.push(m.time); });
         return pts;
     },
 
     //Snap the candidate time (for the given clip edge) to nearby targets
     applySnap: function (time, clip, edge) {
+        if (!CS.state.snap) { CS.timeline.hideSnapGuide(); return time; }
         var threshold = 8 / CS.state.zoom;
         var targets = CS.timeline.snapTargets(clip);
         var dur = CS.clipDuration(clip);
@@ -535,18 +695,21 @@ CS.timeline = {
     },
 
     clampTrimOverlap: function (clip) {
+        var v = CS.clipSpeed(clip);
         var others = CS.clipsOnTrack(clip.trackId).filter(function (c) { return c.id !== clip.id; });
         others.forEach(function (o) {
             //clip's tail overlaps o's head
             if (clip.start < o.start && CS.clipEnd(clip) > o.start) {
-                clip.out = clip.in + (o.start - clip.start);
+                clip.out = clip.in + (o.start - clip.start) * v;
             }
             //clip's head overlaps o's tail
             if (clip.start >= o.start && clip.start < CS.clipEnd(o)) {
                 var shift = CS.clipEnd(o) - clip.start;
                 clip.start += shift;
-                clip.in += shift;
-                if (clip.out - clip.in < CS.timeline.MIN_CLIP_DUR) { clip.out = clip.in + CS.timeline.MIN_CLIP_DUR; }
+                clip.in += shift * v;
+                if (clip.out - clip.in < CS.timeline.MIN_CLIP_DUR * v) {
+                    clip.out = clip.in + CS.timeline.MIN_CLIP_DUR * v;
+                }
             }
         });
     },
@@ -647,6 +810,19 @@ CS.timeline = {
                 ctx.fillRect(x, h - 6, 1, 6);
             }
         }
+        //timeline markers: small flags pinned to the bottom of the ruler
+        (CS.project.markers || []).forEach(function (m) {
+            var mx = m.time * zoom - scrollLeft;
+            if (mx < -8 || mx > w + 8) { return; }
+            ctx.fillStyle = "#f6c945";
+            ctx.beginPath();
+            ctx.moveTo(mx, h - 1);
+            ctx.lineTo(mx - 5, h - 9);
+            ctx.lineTo(mx + 5, h - 9);
+            ctx.closePath();
+            ctx.fill();
+        });
+
         //bottom hairline
         ctx.fillStyle = "#1c1c22";
         ctx.fillRect(0, h - 1, w, 1);

+ 347 - 0
src/web/Cine Studio/js/titles.js

@@ -0,0 +1,347 @@
+/*
+    Cine Studio - generated clips: titles and color boards
+
+    Title clips (clip.kind === "title") and color clips (clip.kind ===
+    "color") have no backing media. They render into a cached offscreen
+    canvas at project resolution which the compositor then treats as a
+    normal frame source, so transforms, color controls, effects and
+    transitions all apply to them for free.
+*/
+"use strict";
+
+window.CS = window.CS || {};
+
+CS.titles = {
+
+    presets: [
+        { id: "title",      name: "Title",
+          text: { content: "Title", size: 110, color: "#ffffff", bold: true, align: "center", vpos: 0.5, style: "plain" } },
+        { id: "subtitle",   name: "Subtitle",
+          text: { content: "Subtitle", size: 54, color: "#ffffff", bold: false, align: "center", vpos: 0.72, style: "plain" } },
+        { id: "lowerthird", name: "Lower Third",
+          text: { content: "Lower Third", size: 62, color: "#ffffff", bold: true, align: "left", vpos: 0.84, style: "bar" } },
+        { id: "caption",    name: "Caption",
+          text: { content: "Caption", size: 44, color: "#ffffff", bold: false, align: "center", vpos: 0.9, style: "box" } }
+    ],
+
+    elements: [
+        { id: "black",  name: "Black",  c0: "#000000" },
+        { id: "white",  name: "White",  c0: "#ffffff" },
+        { id: "red",    name: "Red",    c0: "#c62828" },
+        { id: "blue",   name: "Blue",   c0: "#1e63c9" },
+        { id: "green",  name: "Green",  c0: "#1e8a4c" },
+        { id: "violet", name: "Violet", c0: "#6d5ae0" },
+        { id: "dusk",   name: "Dusk",   c0: "#2b3a67", c1: "#b56576" },
+        { id: "ember",  name: "Ember",  c0: "#1a1a1d", c1: "#c9401e" }
+    ],
+
+    /* ---------- insertion ---------- */
+
+    //Find a video track free around the playhead (topmost first) or add one
+    pickVideoTrack: function (start, dur) {
+        var video = CS.videoTracksInRenderOrder().slice().reverse(); //topmost first
+        for (var i = 0; i < video.length; i++) {
+            var busy = CS.clipsOnTrack(video[i].id).some(function (c) {
+                return start < CS.clipEnd(c) && start + dur > c.start;
+            });
+            if (!busy) { return video[i].id; }
+        }
+        //All video tracks busy at the playhead: add a new one on top
+        var maxN = 0;
+        CS.project.tracks.forEach(function (t) {
+            if (t.kind === "video") { maxN = Math.max(maxN, parseInt(t.id.substring(1), 10)); }
+        });
+        var id = "V" + (maxN + 1);
+        CS.project.tracks.push({ id: id, kind: "video", name: "Video " + (maxN + 1), visible: true, muted: false });
+        return id;
+    },
+
+    insertPreset: function (presetId) {
+        var preset = null;
+        CS.titles.presets.forEach(function (p) { if (p.id === presetId) { preset = p; } });
+        if (!preset) { return; }
+        var dur = 4;
+        var start = CS.state.playhead;
+        var props = CS.defaultClipProps();
+        props.text = JSON.parse(JSON.stringify(preset.text));
+        var clip = {
+            id: CS.uid(),
+            mediaId: null,
+            kind: "title",
+            trackId: CS.titles.pickVideoTrack(start, dur),
+            start: start,
+            in: 0,
+            out: dur,
+            props: props
+        };
+        CS.project.clips.push(clip);
+        CS.selectClip(clip.id);
+        CS.commit("Add Title");
+        CS.toast("Title added - edit the text in the inspector");
+    },
+
+    insertElement: function (elId) {
+        var el = null;
+        CS.titles.elements.forEach(function (e) { if (e.id === elId) { el = e; } });
+        if (!el) { return; }
+        var dur = 5;
+        var start = CS.state.playhead;
+        var props = CS.defaultClipProps();
+        props.color = { c0: el.c0, c1: el.c1 || "" };
+        var clip = {
+            id: CS.uid(),
+            mediaId: null,
+            kind: "color",
+            trackId: CS.titles.pickVideoTrack(start, dur),
+            start: start,
+            in: 0,
+            out: dur,
+            props: props
+        };
+        CS.project.clips.push(clip);
+        CS.selectClip(clip.id);
+        CS.commit("Add Element");
+    },
+
+    /* ---------- frame source rendering ---------- */
+
+    //Render (and cache) the clip's generated frame at project resolution.
+    //The cache lives outside the clip object so history snapshots and
+    //project serialization never see DOM canvases.
+    _cache: {},
+    renderSource: function (clip, W, H) {
+        var key = W + "x" + H + JSON.stringify(clip.props.text || clip.props.color || {});
+        var cached = CS.titles._cache[clip.id];
+        if (cached && cached.key === key) { return cached.canvas; }
+        var c = document.createElement("canvas");
+        c.width = W;
+        c.height = H;
+        var ctx = c.getContext("2d");
+        if (clip.kind === "color") {
+            CS.titles.drawColor(ctx, clip.props.color, W, H);
+        } else {
+            CS.titles.drawText(ctx, clip.props.text, W, H);
+        }
+        CS.titles._cache[clip.id] = { key: key, canvas: c };
+        return c;
+    },
+
+    drawColor: function (ctx, color, W, H) {
+        if (color && color.c1) {
+            var g = ctx.createLinearGradient(0, 0, 0, H);
+            g.addColorStop(0, color.c0);
+            g.addColorStop(1, color.c1);
+            ctx.fillStyle = g;
+        } else {
+            ctx.fillStyle = (color && color.c0) || "#000000";
+        }
+        ctx.fillRect(0, 0, W, H);
+    },
+
+    drawText: function (ctx, text, W, H) {
+        text = text || {};
+        var size = Math.max(8, (text.size || 80) * (W / 1920));
+        var font = (text.bold ? "700 " : "400 ") + size + "px " +
+            '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
+        ctx.font = font;
+        ctx.textBaseline = "middle";
+        var content = (text.content || "").split("\n");
+        var lineH = size * 1.25;
+        var blockH = lineH * content.length;
+        var y0 = H * (text.vpos === undefined ? 0.5 : text.vpos) - blockH / 2 + lineH / 2;
+        var pad = size * 0.45;
+
+        content.forEach(function (line, i) {
+            var y = y0 + i * lineH;
+            var tw = ctx.measureText(line).width;
+            var x;
+            if (text.align === "left") { x = W * 0.08; ctx.textAlign = "left"; }
+            else if (text.align === "right") { x = W * 0.92; ctx.textAlign = "right"; }
+            else { x = W / 2; ctx.textAlign = "center"; }
+
+            if (text.style === "bar") {
+                var bx = (text.align === "right") ? x - tw - pad : (text.align === "center" ? x - tw / 2 - pad : x - pad);
+                ctx.fillStyle = "rgba(20, 20, 26, 0.72)";
+                CS.titles.roundRect(ctx, bx, y - lineH / 2, tw + pad * 2, lineH, size * 0.16);
+                ctx.fill();
+                //accent stripe on the leading edge
+                ctx.fillStyle = "#2e7cf6";
+                ctx.fillRect(bx, y - lineH / 2, Math.max(3, size * 0.09), lineH);
+            } else if (text.style === "box") {
+                var bx2 = (text.align === "right") ? x - tw - pad : (text.align === "center" ? x - tw / 2 - pad : x - pad);
+                ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
+                CS.titles.roundRect(ctx, bx2, y - lineH / 2, tw + pad * 2, lineH, size * 0.2);
+                ctx.fill();
+            } else {
+                ctx.shadowColor = "rgba(0,0,0,0.65)";
+                ctx.shadowBlur = size * 0.12;
+                ctx.shadowOffsetY = size * 0.03;
+            }
+            ctx.fillStyle = text.color || "#ffffff";
+            ctx.fillText(line, x, y);
+            ctx.shadowColor = "transparent";
+            ctx.shadowBlur = 0;
+            ctx.shadowOffsetY = 0;
+        });
+    },
+
+    roundRect: function (ctx, x, y, w, h, r) {
+        ctx.beginPath();
+        ctx.moveTo(x + r, y);
+        ctx.arcTo(x + w, y, x + w, y + h, r);
+        ctx.arcTo(x + w, y + h, x, y + h, r);
+        ctx.arcTo(x, y + h, x, y, r);
+        ctx.arcTo(x, y, x + w, y, r);
+        ctx.closePath();
+    },
+
+    //Invalidate the cached frame after a text edit
+    invalidate: function (clip) {
+        delete CS.titles._cache[clip.id];
+        CS.player.render();
+    },
+
+    /* ---------- inspector text section ---------- */
+
+    buildTextSection: function (body, clip) {
+        var text = clip.props.text;
+        var sec = CS.inspector.section(body, "Text", function () {
+            var preset = CS.titles.presets[0];
+            clip.props.text = JSON.parse(JSON.stringify(preset.text));
+            CS.titles.invalidate(clip);
+            CS.commit("Reset Text");
+        });
+
+        var contentIn = document.createElement("input");
+        contentIn.type = "text";
+        contentIn.value = text.content;
+        contentIn.style.cssText = "flex:1;min-width:0;height:26px;padding:0 9px;background:var(--bg-elev);" +
+            "border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12.5px;outline:none;";
+        contentIn.addEventListener("input", function () {
+            text.content = contentIn.value;
+            CS.titles.invalidate(clip);
+            CS.timeline.render();
+        });
+        contentIn.addEventListener("change", function () { CS.commit("Edit Title Text"); });
+        CS.inspector.row(sec, "Content", [contentIn]);
+
+        CS.inspector.row(sec, "Size", [
+            CS.inspector.slider(12, 240, 1, text.size, function (v) { text.size = v; CS.titles.invalidate(clip); }),
+            CS.inspector.numChip(null, text.size, "px", function (v) { text.size = CS.clamp(v, 8, 400); CS.titles.invalidate(clip); }, 1)
+        ]);
+
+        var colorIn = document.createElement("input");
+        colorIn.type = "color";
+        colorIn.value = text.color;
+        colorIn.style.cssText = "width:34px;height:24px;padding:0;border:1px solid var(--border);" +
+            "border-radius:6px;background:var(--bg-elev);cursor:pointer;";
+        colorIn.addEventListener("input", function () {
+            text.color = colorIn.value;
+            CS.titles.invalidate(clip);
+        });
+        colorIn.addEventListener("change", function () { CS.commit("Title Color"); });
+        CS.inspector.row(sec, "Color", [colorIn,
+            CS.inspector.select([
+                { v: "false", l: "Regular" },
+                { v: "true", l: "Bold" }
+            ], String(!!text.bold), function (v) {
+                text.bold = (v === "true");
+                CS.titles.invalidate(clip);
+                CS.commit("Title Weight");
+            })
+        ]);
+
+        CS.inspector.row(sec, "Align", [
+            CS.inspector.select([
+                { v: "left", l: "Left" },
+                { v: "center", l: "Center" },
+                { v: "right", l: "Right" }
+            ], text.align || "center", function (v) {
+                text.align = v;
+                CS.titles.invalidate(clip);
+                CS.commit("Title Align");
+            })
+        ]);
+
+        CS.inspector.row(sec, "Vertical", [
+            CS.inspector.slider(0, 100, 1, Math.round((text.vpos === undefined ? 0.5 : text.vpos) * 100), function (v) {
+                text.vpos = v / 100;
+                CS.titles.invalidate(clip);
+            }),
+            CS.inspector.numChip(null, Math.round((text.vpos === undefined ? 0.5 : text.vpos) * 100), "%", function (v) {
+                text.vpos = CS.clamp(v, 0, 100) / 100;
+                CS.titles.invalidate(clip);
+            }, 1)
+        ]);
+
+        CS.inspector.row(sec, "Style", [
+            CS.inspector.select([
+                { v: "plain", l: "Plain" },
+                { v: "bar", l: "Lower-third bar" },
+                { v: "box", l: "Caption box" }
+            ], text.style || "plain", function (v) {
+                text.style = v;
+                CS.titles.invalidate(clip);
+                CS.commit("Title Style");
+            })
+        ]);
+    },
+
+    /* ---------- gallery panels ---------- */
+
+    _panelBuilt: false,
+    renderPanel: function () {
+        if (CS.titles._panelBuilt) { return; }
+        CS.titles._panelBuilt = true;
+        var grid = document.getElementById("titles-grid");
+        grid.innerHTML = "";
+        CS.titles.presets.forEach(function (p) {
+            var card = document.createElement("div");
+            card.className = "fx-card";
+            var thumb = document.createElement("div");
+            thumb.className = "fx-thumb";
+            var c = document.createElement("canvas");
+            c.width = 300;
+            c.height = 188;
+            var ctx = c.getContext("2d");
+            ctx.fillStyle = "#101016";
+            ctx.fillRect(0, 0, 300, 188);
+            var demo = JSON.parse(JSON.stringify(p.text));
+            demo.size = demo.size * 1.6;
+            CS.titles.drawText(ctx, demo, 300, 188);
+            thumb.appendChild(c);
+            var name = document.createElement("div");
+            name.className = "fx-name";
+            name.textContent = p.name;
+            card.appendChild(thumb);
+            card.appendChild(name);
+            card.addEventListener("click", function () { CS.titles.insertPreset(p.id); });
+            grid.appendChild(card);
+        });
+    },
+
+    _elementsBuilt: false,
+    renderElementsPanel: function () {
+        if (CS.titles._elementsBuilt) { return; }
+        CS.titles._elementsBuilt = true;
+        var grid = document.getElementById("elements-grid");
+        grid.innerHTML = "";
+        CS.titles.elements.forEach(function (e) {
+            var card = document.createElement("div");
+            card.className = "fx-card";
+            var thumb = document.createElement("div");
+            thumb.className = "fx-thumb";
+            thumb.style.background = e.c1
+                ? "linear-gradient(180deg, " + e.c0 + ", " + e.c1 + ")"
+                : e.c0;
+            if (e.id === "white") { thumb.style.borderColor = "#3a3a44"; }
+            var name = document.createElement("div");
+            name.className = "fx-name";
+            name.textContent = e.name;
+            card.appendChild(thumb);
+            card.appendChild(name);
+            card.addEventListener("click", function () { CS.titles.insertElement(e.id); });
+            grid.appendChild(card);
+        });
+    }
+};

+ 246 - 0
src/web/Cine Studio/js/transitions.js

@@ -0,0 +1,246 @@
+/*
+    Cine Studio - clip transitions
+
+    A transition is stored on the INCOMING clip (clip.props.transition =
+    {type, duration}) and plays over the first `duration` seconds of the
+    clip. When another clip on the same track ends exactly where this
+    clip starts, its last frame is frozen and blended; otherwise the
+    transition runs from black.
+*/
+"use strict";
+
+window.CS = window.CS || {};
+
+CS.transitions = {
+
+    registry: [
+        { type: "none",     name: "None" },
+        { type: "dissolve", name: "Cross Dissolve" },
+        { type: "fade",     name: "Fade" },
+        { type: "wipe",     name: "Wipe Right" }
+    ],
+
+    get: function (type) {
+        for (var i = 0; i < CS.transitions.registry.length; i++) {
+            if (CS.transitions.registry[i].type === type) { return CS.transitions.registry[i]; }
+        }
+        return null;
+    },
+
+    /* ---------- apply ---------- */
+
+    applyToSelected: function (type) {
+        var clip = CS.selectedClip();
+        if (!clip) { CS.toast("Select a clip on the timeline first"); return; }
+        var track = CS.getTrack(clip.trackId);
+        if (!track || track.kind !== "video") {
+            CS.toast("Transitions apply to clips on video tracks", true);
+            return;
+        }
+        if (type === "none") {
+            clip.props.transition = null;
+        } else {
+            var prevDur = (clip.props.transition && clip.props.transition.duration) || 1;
+            clip.props.transition = { type: type, duration: prevDur };
+        }
+        CS.commit(type === "none" ? "Remove Transition" : "Add Transition");
+        CS.panels.refresh();
+        if (type !== "none") { CS.toast(CS.transitions.get(type).name + " applied to clip start"); }
+    },
+
+    /* ---------- render-time helpers ---------- */
+
+    //The clip on the same track that ends where this clip begins
+    prevOf: function (clip) {
+        var clips = CS.clipsOnTrack(clip.trackId);
+        for (var i = 0; i < clips.length; i++) {
+            if (clips[i].id !== clip.id && Math.abs(CS.clipEnd(clips[i]) - clip.start) < 0.05) {
+                return clips[i];
+            }
+        }
+        return null;
+    },
+
+    //Whether clip is inside its transition window at time t
+    windowAt: function (clip, t) {
+        var tr = clip.props && clip.props.transition;
+        if (!tr || tr.type === "none") { return null; }
+        var dur = Math.min(tr.duration, CS.clipDuration(clip));
+        if (t >= clip.start && t < clip.start + dur) {
+            return { tr: tr, k: CS.clamp((t - clip.start) / Math.max(0.05, dur), 0, 1) };
+        }
+        return null;
+    },
+
+    //Predecessor clips that must stay parked on their last frame at time t,
+    //so scrubbing into a transition shows the frozen outgoing frame
+    frozenTargets: function (t) {
+        var out = {};
+        CS.project.clips.forEach(function (clip) {
+            if (!CS.transitions.windowAt(clip, t)) { return; }
+            var prev = CS.transitions.prevOf(clip);
+            if (prev) { out[prev.id] = Math.max(prev.in, prev.out - 0.05); }
+        });
+        return out;
+    },
+
+    //Draw the incoming clip (and frozen predecessor) blended by type
+    draw: function (ctx, clip, t, W, H, win) {
+        var prev = CS.transitions.prevOf(clip);
+        var k = win.k;
+        var prevT = prev ? Math.max(prev.start, CS.clipEnd(prev) - 0.05) : 0;
+
+        if (win.tr.type === "dissolve") {
+            if (prev) { CS.player.drawClip(ctx, prev, W, H, prevT, {}); }
+            CS.player.drawClip(ctx, clip, W, H, t, { alphaMul: k });
+        } else if (win.tr.type === "fade") {
+            //first half: outgoing fades to black; second half: incoming fades in
+            if (k < 0.5) {
+                if (prev) { CS.player.drawClip(ctx, prev, W, H, prevT, { alphaMul: 1 - k * 2 }); }
+            } else {
+                CS.player.drawClip(ctx, clip, W, H, t, { alphaMul: k * 2 - 1 });
+            }
+        } else if (win.tr.type === "wipe") {
+            if (prev) { CS.player.drawClip(ctx, prev, W, H, prevT, {}); }
+            ctx.save();
+            ctx.beginPath();
+            ctx.rect(0, 0, W * k, H);
+            ctx.clip();
+            CS.player.drawClip(ctx, clip, W, H, t, {});
+            ctx.restore();
+        } else {
+            CS.player.drawClip(ctx, clip, W, H, t, {});
+        }
+    },
+
+    /* ---------- inspector tab ---------- */
+
+    renderTab: function (body, clip) {
+        if (!clip) {
+            CS.inspector.placeholder(body, "Select a clip on the timeline to add a transition into it.");
+            return;
+        }
+        var track = CS.getTrack(clip.trackId);
+        if (!track || track.kind !== "video") {
+            CS.inspector.placeholder(body, "Transitions apply to clips on video tracks. Use Fade In / Fade Out effects for audio.");
+            return;
+        }
+
+        var tr = clip.props.transition;
+        var sec = CS.inspector.section(body, "Transition", function () {
+            clip.props.transition = null;
+            CS.commit("Remove Transition");
+        });
+
+        CS.inspector.row(sec, "Type", [
+            CS.inspector.select(
+                CS.transitions.registry.map(function (r) { return { v: r.type, l: r.name }; }),
+                tr ? tr.type : "none",
+                function (v) { CS.transitions.applyToSelected(v); }
+            )
+        ]);
+
+        if (tr && tr.type !== "none") {
+            CS.inspector.row(sec, "Duration", [
+                CS.inspector.slider(0.2, 3, 0.1, tr.duration, function (v) { tr.duration = v; }),
+                CS.inspector.numChip(null, tr.duration, "s", function (v) {
+                    tr.duration = CS.clamp(v, 0.1, 10);
+                }, 0.1, 1)
+            ]);
+        }
+
+        var note = document.createElement("div");
+        note.className = "modal-note";
+        note.textContent = CS.transitions.prevOf(clip)
+            ? "Blends from the previous clip on this track into this clip."
+            : "No clip ends where this one starts, so the transition will run from black.";
+        sec.appendChild(note);
+    },
+
+    /* ---------- gallery panel ---------- */
+
+    _panelBuilt: false,
+    renderPanel: function () {
+        if (CS.transitions._panelBuilt) { CS.transitions.refreshApplied(); return; }
+        CS.transitions._panelBuilt = true;
+        var grid = document.getElementById("transitions-grid");
+        grid.innerHTML = "";
+        CS.transitions.registry.forEach(function (def) {
+            if (def.type === "none") { return; }
+            var card = document.createElement("div");
+            card.className = "fx-card";
+            card.dataset.trType = def.type;
+            var thumb = document.createElement("div");
+            thumb.className = "fx-thumb";
+            thumb.appendChild(CS.transitions.previewCanvas(def.type));
+            var name = document.createElement("div");
+            name.className = "fx-name";
+            name.textContent = def.name;
+            card.appendChild(thumb);
+            card.appendChild(name);
+            card.addEventListener("click", function () {
+                CS.transitions.applyToSelected(def.type);
+            });
+            grid.appendChild(card);
+        });
+        CS.transitions.refreshApplied();
+    },
+
+    refreshApplied: function () {
+        var clip = CS.selectedClip();
+        var current = clip && clip.props.transition ? clip.props.transition.type : "";
+        var cards = document.querySelectorAll("#transitions-grid .fx-card");
+        for (var i = 0; i < cards.length; i++) {
+            cards[i].classList.toggle("applied", cards[i].dataset.trType === current);
+        }
+    },
+
+    previewCanvas: function (type) {
+        var c = document.createElement("canvas");
+        c.width = 150;
+        c.height = 94;
+        var ctx = c.getContext("2d");
+        //outgoing side: warm scene, incoming side: cool scene
+        var warm = ctx.createLinearGradient(0, 0, 0, 94);
+        warm.addColorStop(0, "#c97b3a");
+        warm.addColorStop(1, "#5c3a1e");
+        var cool = ctx.createLinearGradient(0, 0, 0, 94);
+        cool.addColorStop(0, "#2b6f9e");
+        cool.addColorStop(1, "#123246");
+
+        if (type === "wipe") {
+            ctx.fillStyle = warm;
+            ctx.fillRect(0, 0, 150, 94);
+            ctx.fillStyle = cool;
+            ctx.fillRect(0, 0, 82, 94);
+            ctx.fillStyle = "rgba(255,255,255,0.8)";
+            ctx.fillRect(80, 0, 3, 94);
+        } else if (type === "fade") {
+            ctx.fillStyle = warm;
+            ctx.fillRect(0, 0, 70, 94);
+            ctx.fillStyle = "#000";
+            ctx.fillRect(60, 0, 30, 94);
+            ctx.fillStyle = cool;
+            ctx.globalAlpha = 0.9;
+            ctx.fillRect(85, 0, 65, 94);
+            ctx.globalAlpha = 1;
+            var g = ctx.createLinearGradient(45, 0, 105, 0);
+            g.addColorStop(0, "rgba(0,0,0,0)");
+            g.addColorStop(0.5, "rgba(0,0,0,0.95)");
+            g.addColorStop(1, "rgba(0,0,0,0)");
+            ctx.fillStyle = g;
+            ctx.fillRect(40, 0, 70, 94);
+        } else {
+            //dissolve: blend the two scenes across the middle
+            ctx.fillStyle = warm;
+            ctx.fillRect(0, 0, 150, 94);
+            for (var x = 0; x < 150; x += 3) {
+                ctx.globalAlpha = CS.clamp((x - 30) / 90, 0, 1);
+                ctx.fillStyle = cool;
+                ctx.fillRect(x, 0, 3, 94);
+            }
+            ctx.globalAlpha = 1;
+        }
+        return c;
+    }
+};