From 62c43d6fbd7246cc83dd1ffed93a865e769ab0cf Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 27 Jun 2026 20:41:59 +0200 Subject: [PATCH 001/141] design: import initial OpenScreen artifact set Editor (v1 + v2), landing, and launcher copied from Open Design project 21a2a0a2. Source files unchanged; review before merge. --- design/editor.html | 1619 +++++++++++++++ design/index.html | 237 +++ design/openscreen-editor-2.html | 3442 +++++++++++++++++++++++++++++++ design/openscreen-editor.html | 1116 ++++++++++ design/openscreen-landing.html | 799 +++++++ 5 files changed, 7213 insertions(+) create mode 100644 design/editor.html create mode 100644 design/index.html create mode 100644 design/openscreen-editor-2.html create mode 100644 design/openscreen-editor.html create mode 100644 design/openscreen-landing.html diff --git a/design/editor.html b/design/editor.html new file mode 100644 index 0000000000..8bd884b3bb --- /dev/null +++ b/design/editor.html @@ -0,0 +1,1619 @@ + + + + + + OpenScreen — Éditeur + + + +
+ + +
+ + + + OpenScreen + + + + demo2 + + + + Enregistré · il y a 2 s + + + + + + + + +
+ + +
+ + + + + + + + +
+
+
+ + 00:00:12.4 + 1920 × 1080 · 60 fps +
Aperçu · 16:9
+
+ +
+
+
+ +
+ + + + + + + +
+ + +
+ + +
+
+

Timeline

+ + 2 clips·24 sauts·7:03.6 total + + + Auto-sauvegardé + 0:00.0 · Clip 1/2 + +
+ +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + V1 + +
+
+ + demo-n8n-as-code-1.mp4 + 0:00.0 → 2:34.7 · source 0:00.0–2:34.7 + + +
+
+ + 2026-03-10 09-10-39.mp4 + 2:34.7 → 7:03.6 · source 0:00.0–4:28.9 + + +
+
+
+
+ + + A1 + +
+
+ + voix · microphone principal + 0:00.0 → 2:34.7 · -16 LUFS +
+
+ + voix · microphone principal + 2:34.7 → 7:03.6 · -14 LUFS +
+
+
+
+
+
+ + + + +
+
+ + + + diff --git a/design/index.html b/design/index.html new file mode 100644 index 0000000000..c81cadb8a3 --- /dev/null +++ b/design/index.html @@ -0,0 +1,237 @@ + + + + + + OpenScreen — Aperçu du projet + + + +
+ + + OpenScreen + + +

Aperçu du projet · v0.0.3

+

Deux surfaces, un même système de design.

+

Lance l'écran que tu veux explorer. La landing documente le produit, l'éditeur est l'app de bureau redesignée autour du brief AI : panneau d'édition, transcription, timeline à sauts hachurés, et propriétés du clip.

+ + + + +
+ + \ No newline at end of file diff --git a/design/openscreen-editor-2.html b/design/openscreen-editor-2.html new file mode 100644 index 0000000000..1a6da5fc24 --- /dev/null +++ b/design/openscreen-editor-2.html @@ -0,0 +1,3442 @@ + + + + + + + OpenScreen — Éditeur + + + +
+ + +
+ + + + OpenScreen + + + + demo2 + + + + Enregistré · il y a 2 s + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + +
+
+
+ + 00:00:12.4 + 1920 × 1080 · 60 fps +
Aperçu · 16:9
+
+ +
+
+
+ +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + +
+ + +
+ + +
+
+

Timeline

+ + 2 clips·24 sauts·7:03.6 total + + + + 0:00.0 · Clip 1/2 + +
+ +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + V1 + +
+
+ + demo-n8n-as-code-1.mp4 + 0:00.0 → 2:34.7 · source 0:00.0–2:34.7 + + +
+
+ + 2026-03-10 09-10-39.mp4 + 2:34.7 → 7:03.6 · source 0:00.0–4:28.9 + + +
+
+
+
+ + + Z + +
+
1.8×
+
1.8×
+
1.8×
+
1.8×
+
1.8×
+
1.8×
+
1.8×
+
1.8×
+
+
+
+ + + T + +
+
Enter text…
+
+
+
+ + + S + +
+
1.5×
+
+
+
+
+
+ +
+
+ + + + + + + + + + diff --git a/design/openscreen-editor.html b/design/openscreen-editor.html new file mode 100644 index 0000000000..b31db9f169 --- /dev/null +++ b/design/openscreen-editor.html @@ -0,0 +1,1116 @@ + + + + + + + OpenScreen — Éditeur + + + +
+
+ + OpenScreen + + + demo-produit.osc + enregistré · 14:02 + + + + + + +
+ + + + + +
+ + +
+
+
+ Projets/2026-Q2/demo-produit.osc +
+
+ + + +
+
+
+
+
+
+
+ + 00:01:38 + 3840 × 2160 · 60 fps +
+
+
+ +
+ + +
+ 00:01:38 / 00:07:45 +
+
+ +
+
+ A annotation · S vitesse · ⌘K couper · Espace lecture +
+
+ + +
+
1.25×
+
+
+
+
Pistes
+
+
+
+
+
Vidéo3
+
+ Capt. principale + coupé + Démo produit + coupé + Slide finale +
+
+
+
Annotations3
+
+ Curseur + Titre + Cercle +
+
+
+
Vitesse2
+
+ 0.5× + 2.0× +
+
+
+
+
+
+ + +
+ + +
+ + + + diff --git a/design/openscreen-landing.html b/design/openscreen-landing.html new file mode 100644 index 0000000000..f2b72d6697 --- /dev/null +++ b/design/openscreen-landing.html @@ -0,0 +1,799 @@ + + + + + + + OpenScreen — Enregistrez, coupez, peaufinez vos vidéos d'écran + + + +
+
+ + + OpenScreen + + + v0.0.3 · macOS +
+
+ +
+
+
+
+

v0.0.3 · alpha publique

+

Enregistrez, coupez, peaufinez vos vidéos d'écran, sans quitter l'éditeur.

+

OpenScreen rassemble capture, timeline et habillage dans une seule fenêtre native — pas de cloud, pas de re-rendu dans le navigateur, juste un fichier local prêt à partager.

+ +
+ macOS 12+ · Apple Silicon & Intel + Win & Linux en bêta privée +
+
+ +
+
+ + OpenScreen · sans-titre · 7:45 + 16:9 +
+
+ Aperçu de l'éditeur OpenScreen : timeline multi-piste et sélecteur de fond. +
+
+
+
+ +
+
+
+
+
4K · 60fps
+
Capture native Retina/HiDPI sans driver additionnel.
+
+
+
3 pistes
+
Vidéo, Annotations, Vitesse — non-destructif jusqu'à l'export.
+
+
+
0 octet
+
Aucune télémétrie par défaut. Les projets restent sur disque.
+
+
+
~14 formats
+
Export MP4, MOV, WebM, GIF, PNG séquence, WAV audio seul.
+
+
+
+
+ +
+
+
+

Capacités

+

Trois gestes pour passer d'une capture brute à une vidéo publiable.

+
+
+
+ +

Capture système

+

Fenêtre, zone ou écran complet, audio système inclus. La session reprend après une mise en veille sans corrompre le fichier.

+
+
+ +

Montage non-destructif

+

Trim à la souris, annotations au clavier (A), segments de vitesse (S) — chaque clip reste référencé, jamais ré-encodé.

+
+
+ +

Fonds et cadrage

+

15 dégradés d'ambiance, palette libre, ratios 16:9, 9:16 et 1:1. Le cadrage se met à jour sans déranger la timeline.

+
+
+
+
+ +
+
+
+

L'éditeur

+

Une timeline qui se lit comme un texte.

+

Trois pistes explicites — Vidéo, Annotations, Vitesse — pour composer un montage en quelques minutes, sans tutorial ni plug-in. Les raccourcis sont imprimés directement sur les clips.

+
    +
  • + A + Ajoute une annotation au curseur de lecture +
  • +
  • + S + Insère un segment de vitesse (0.5× à 4×) +
  • +
  • + ⌘ K + Coupe le clip sous la tête de lecture +
  • +
+
+ +
+
+ + OpenScreen · démo + + Charger un projet + Sauvegarder + +
+
+ + +
+
+
+
+ +
VidéoCapt. principaleReprise webcamSlide finale
+
AnnotationsPointer curseurTexte courtCercle
+
Vitesse0.5×2.0×
+
+
+
+
+
+ +
+
+
+
J'ai remplacé OBS pour les démos produit et Premiere pour les teasers. OpenScreen fait les deux dans une fenêtre qui ne plante pas.
+

— Camille Roux, ingénieure produit chez Vercel

+
+
+ +
+
+
+
+

Arrêtez de jongler entre OBS, Premiere et Loom.

+

OpenScreen reste gratuit pendant l'alpha. La licence personnelle s'activera à la v1.0 ; les early adopters garderont leur clé.

+
+ +
+
+
+
+ + + + + + From 358e71b8b9186e44baf7874a5f6681e17866d671 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 27 Jun 2026 21:11:07 +0200 Subject: [PATCH 002/141] refactor(design): consolidate design assets and add specification Consolidate multiple HTML prototypes into a single canonical editor file and add a formal design system specification in DESIGN.md. Update the editor to align with the new design tokens. --- design/DESIGN.md | 156 ++ design/editor.html | 1619 ------------ design/index.html | 237 -- design/openscreen-editor-2.html | 3442 -------------------------- design/openscreen-editor.html | 4090 ++++++++++++++++++++++++------- design/openscreen-landing.html | 799 ------ 6 files changed, 3364 insertions(+), 6979 deletions(-) create mode 100644 design/DESIGN.md delete mode 100644 design/editor.html delete mode 100644 design/index.html delete mode 100644 design/openscreen-editor-2.html delete mode 100644 design/openscreen-landing.html diff --git a/design/DESIGN.md b/design/DESIGN.md new file mode 100644 index 0000000000..8450dcb04e --- /dev/null +++ b/design/DESIGN.md @@ -0,0 +1,156 @@ +# OpenScreen Design System + +> Category: Video & Productivity Tools +> Surface: Desktop web (Electron) +> Source: tokens extracted from `openscreen-editor-2.html` (canonical) + +A high-fidelity, professional dark-and-light desktop application design system +optimized for video recording, post-processing editing, and timeline +manipulation. Mint is the single brand accent; red is reserved strictly for +REC / cut / skip / trim / transcript-highlight states; amber covers warnings. + +--- + +## Color Palette + +### Light theme (`:root`) + +| Role | Token | Hex | Notes | +|---|---|---|---| +| Canvas | `--bg` | `#fafbfc` | Off-white, never pure white; soft radial gradient | +| Panel | `--surface` | `#ffffff` | Raised panels on canvas | +| Card | `--surface-1` | `#f7f8fa` | Tier 1 raised | +| Tier 2 | `--surface-2` | `#f3f5f8` | Raised on panel | +| Hover | `--surface-3` | `#eef0f3` | Active/hover state | +| Popover | `--surface-hi` | `#ffffff` | Highest elevation | +| Border | `--border` | `#e7e9ee` | Soft hairline | +| Border soft | `--border-soft` | `#f1f2f5` | Subtle dividers | +| Border hi | `--border-hi` | `#d1d5db` | Emphasis lines | +| Foreground | `--fg` | `#1f2937` | Slate, not pure black | +| Foreground emphasis | `--fg-emphasis` | `#111827` | Headlines | +| Muted | `--muted` | `#6b7280` | Body / labels | +| Meta | `--meta` | `#9ca3af` | Timestamps, captions | +| Brand (mint) | `--accent` | `#10b981` | Primary action, focus ring, toggle-on | +| Brand glow | `--brand-glow` | `rgba(16,185,129,0.35)` | Focus / hover | +| Danger | `--danger` | `#ef4444` | REC, skip, trim, transcript highlight | +| Warning | `--warn` | `#f59e0b` | Soft amber | +| Success | `--success` | `#10b981` | Alias of brand | + +### Dark theme (`:root[data-theme="dark"]`) + +| Role | Token | Hex | Notes | +|---|---|---|---| +| Canvas | `--bg` | `#0a0d12` | Near-black with faint blue cast | +| Panel | `--surface` | `#14181f` | Tier 1 panel | +| Card | `--surface-1` | `#14181f` | Same as panel base | +| Tier 2 | `--surface-2` | `#1c2029` | Raised | +| Hover | `--surface-3` | `#252a35` | Hover/active | +| Popover | `--surface-hi` | `#2e3440` | Highest | +| Border | `--border` | `#252a35` | Reads on canvas | +| Foreground | `--fg` | `#e6e9ef` | Off-white, never pure `#ffffff` | +| Muted | `--muted` | `#8b95a3` | Tuned for AA on dark | +| Brand (mint) | `--accent` | `#10b981` | Same hue, brighter tone on `--brand-lo: #34d399` | +| Danger | `--danger` | `#f87171` | Softer red for dark bg | + +### Traffic lights (macOS chrome) + +| Token | Hex | +|---|---| +| `--light-red` | `#ff5f57` | +| `--light-yellow` | `#febc2e` | +| `--light-green` | `#28c840` | + +--- + +## Typography + +- **Display:** system-ui stack — `system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif` +- **Body:** system-ui stack (same as display) +- **Mono:** `ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace` + +### Type scale (desktop editor, base 13px) + +| Token | Size | Use | +|---|---|---| +| `--fs-app` | 13px | Base UI | +| `--fs-app-sm` | 12px | Secondary | +| `--fs-app-lg` | 14px | Primary UI | +| `--fs-title` | 16px | Panel titles | +| `--fs-section` | 11px | Section headers (uppercase, tracked) | +| `--fs-display` | 24px | Hero numerals | + +Nothing below 11px. + +--- + +## Layout + +- **Radius:** 8px standard; `--r-xs: 4px`, `--r-sm: 6px`, `--r-md: 8px`, `--r-lg: 12px`, `--r-pill: 9999px` +- **Border weight:** 1px +- **Spacing:** 4px baseline grid (`--sp-1` through `--sp-6`) +- **Editor wireframe:** + - Titlebar: 34px + - Left utility rail: 48px + - Left panel: 320px + - Right properties: 320px + - Resize handle: 6px + - Bottom timeline/toolbar: 224px + +### Posture rules + +- **One accent, used at most twice per screen.** Default budget is eyebrow + primary CTA. +- **Red is reserved** for REC, cut, skip, trim, and transcript highlight only. It is not a brand color. +- **Mint is the single brand color** for active state, focus ring, toggle-on, and brand mark. +- **Off-white canvas, tiered surfaces.** Never use pure `#000` or pure `#fff` for editorial chrome. +- **Soft elevation, slate-based shadows.** Two `--elev-card` and `--elev-pop` are enough. +- **System-ui type, mono numerics.** No web fonts loaded; ships the declared fallback stack. + +--- + +## Motion + +- `--motion-fast: 120ms` — hover, focus ring +- `--motion-base: 180ms` — state transitions +- `--ease: cubic-bezier(0.2, 0, 0, 1)` — standard easing + +--- + +## Voice & Tone + +- **Adjectives:** high-fidelity, professional, calm, restrained. +- **Tone:** a confident dark-themed tool. Marketing prose is sparse; product UI carries the work. +- **Messaging pillar:** video recording, post-processing editing, and timeline manipulation — same three jobs the editor does. + +### Vocabulary + +- **Use:** Record, Trim, Cut, Skip, Timeline, Clip, Track, Transcript. +- **Avoid:** playful emoji feature labels (✨ 🚀 🎯), "AI-powered" in product chrome, generic SaaS copy ("supercharge your workflow"). + +--- + +## Imagery + +- **Style:** schematic, palette-derived. Scene illustration in preview is grayscale made from the same neutrals — not literal artwork. +- **Treatment:** replace placeholder scenes with real project footage when shipping. +- **Avoid:** stock photography, decorative illustration in tool chrome, hand-drawn mascots. + +--- + +## Files in this directory + +| File | Role | +|---|---| +| `openscreen-editor-2.html` | Latest editor (light + dark themes in one file). Canonical source of tokens. | +| `openscreen-editor.html` | First editor pass. Light-only. | +| `editor.html` | Earlier experimental editor with red REC accent. | +| `openscreen-landing.html` | Marketing landing page. | +| `index.html` | Launcher / overview. | +| `DESIGN.md` | This document. | + +--- + +## Open questions / follow-ups + +- `editor.html` uses a different red-dominant accent and predates the mint brand color. Keep as historical reference; do not import its tokens into new work. +- Landing page (`openscreen-landing.html`) is light-only; consider a dark variant once product photography is sourced. +- No logo asset is committed yet. Brand mark should be a simple wordmark or geometric mark in mint on the dark canvas. diff --git a/design/editor.html b/design/editor.html deleted file mode 100644 index 8bd884b3bb..0000000000 --- a/design/editor.html +++ /dev/null @@ -1,1619 +0,0 @@ - - - - - - OpenScreen — Éditeur - - - -
- - -
- - - - OpenScreen - - - - demo2 - - - - Enregistré · il y a 2 s - - - - - - - - -
- - -
- - - - - - - - -
-
-
- - 00:00:12.4 - 1920 × 1080 · 60 fps -
Aperçu · 16:9
-
- -
-
-
- -
- - - - - - - -
- - -
- - -
-
-

Timeline

- - 2 clips·24 sauts·7:03.6 total - - - Auto-sauvegardé - 0:00.0 · Clip 1/2 - -
- -
- -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- - - V1 - -
-
- - demo-n8n-as-code-1.mp4 - 0:00.0 → 2:34.7 · source 0:00.0–2:34.7 - - -
-
- - 2026-03-10 09-10-39.mp4 - 2:34.7 → 7:03.6 · source 0:00.0–4:28.9 - - -
-
-
-
- - - A1 - -
-
- - voix · microphone principal - 0:00.0 → 2:34.7 · -16 LUFS -
-
- - voix · microphone principal - 2:34.7 → 7:03.6 · -14 LUFS -
-
-
-
-
-
- - - - -
-
- - - - diff --git a/design/index.html b/design/index.html deleted file mode 100644 index c81cadb8a3..0000000000 --- a/design/index.html +++ /dev/null @@ -1,237 +0,0 @@ - - - - - - OpenScreen — Aperçu du projet - - - -
- - - OpenScreen - - -

Aperçu du projet · v0.0.3

-

Deux surfaces, un même système de design.

-

Lance l'écran que tu veux explorer. La landing documente le produit, l'éditeur est l'app de bureau redesignée autour du brief AI : panneau d'édition, transcription, timeline à sauts hachurés, et propriétés du clip.

- - - - -
- - \ No newline at end of file diff --git a/design/openscreen-editor-2.html b/design/openscreen-editor-2.html deleted file mode 100644 index 1a6da5fc24..0000000000 --- a/design/openscreen-editor-2.html +++ /dev/null @@ -1,3442 +0,0 @@ - - - - - - - OpenScreen — Éditeur - - - -
- - -
- - - - OpenScreen - - - - demo2 - - - - Enregistré · il y a 2 s - - - - - - - - - - - -
- - -
- - - - - - - - - - - -
- - -
-
-
- - 00:00:12.4 - 1920 × 1080 · 60 fps -
Aperçu · 16:9
-
- -
-
-
- -
- - -
- - -
- - - - - - - - - - - - - - - - - - - -
- - - - -
- - -
- - -
- - -
-
-

Timeline

- - 2 clips·24 sauts·7:03.6 total - - - - 0:00.0 · Clip 1/2 - -
- -
- -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- - - V1 - -
-
- - demo-n8n-as-code-1.mp4 - 0:00.0 → 2:34.7 · source 0:00.0–2:34.7 - - -
-
- - 2026-03-10 09-10-39.mp4 - 2:34.7 → 7:03.6 · source 0:00.0–4:28.9 - - -
-
-
-
- - - Z - -
-
1.8×
-
1.8×
-
1.8×
-
1.8×
-
1.8×
-
1.8×
-
1.8×
-
1.8×
-
-
-
- - - T - -
-
Enter text…
-
-
-
- - - S - -
-
1.5×
-
-
-
-
-
- -
-
- - - - - - - - - - diff --git a/design/openscreen-editor.html b/design/openscreen-editor.html index b31db9f169..1a6da5fc24 100644 --- a/design/openscreen-editor.html +++ b/design/openscreen-editor.html @@ -6,1107 +6,3433 @@ OpenScreen — Éditeur
-
- - OpenScreen + + +
+ + + + OpenScreen + - demo-produit.osc - enregistré · 14:02 + demo2 + + + + Enregistré · il y a 2 s - - - + + + + + + + +
- + +
- + -
- + + + + + + + +
+ + +
+
+
+ + 00:00:12.4 + 1920 × 1080 · 60 fps +
Aperçu · 16:9
+
+
-
+
- + + +
+ + + + - + +
+ + +
+ + +
+
+

Timeline

+ + 2 clips·24 sauts·7:03.6 total + + + + 0:00.0 · Clip 1/2 + +
+ +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + V1 + +
+
+ + demo-n8n-as-code-1.mp4 + 0:00.0 → 2:34.7 · source 0:00.0–2:34.7 + + +
+
+ + 2026-03-10 09-10-39.mp4 + 2:34.7 → 7:03.6 · source 0:00.0–4:28.9 + + +
+
+
+
+ + + Z + +
+
1.8×
+
1.8×
+
1.8×
+
1.8×
+
1.8×
+
1.8×
+
1.8×
+
1.8×
+
+
+
+ + + T + +
+
Enter text…
+
+
+
+ + + S + +
+
1.5×
+
+
+
+
+
+ +
+ + + + + + + - - From c553bb09332fff0170475493a191c81cba507e94 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 27 Jun 2026 22:00:00 +0200 Subject: [PATCH 003/141] docs:update design --- design/openscreen-editor.html | 531 ++++++++++++++++++++++++++++++++-- 1 file changed, 513 insertions(+), 18 deletions(-) diff --git a/design/openscreen-editor.html b/design/openscreen-editor.html index 1a6da5fc24..14875da0bc 100644 --- a/design/openscreen-editor.html +++ b/design/openscreen-editor.html @@ -323,6 +323,10 @@ width: 6px; height: 6px; border-radius: 50%; background: var(--success); } + .titlebar .proj-actions { + display: inline-flex; align-items: center; gap: var(--sp-1); + margin-right: var(--sp-1); + } .titlebar .right { margin-left: auto; display: inline-flex; align-items: center; gap: var(--sp-1); @@ -785,11 +789,11 @@ width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; - background: var(--fg); - color: var(--bg); - transition: background var(--motion-fast) var(--ease), transform var(--motion-fast) var(--ease); + background: transparent; + color: var(--fg); + transition: color var(--motion-fast) var(--ease), transform var(--motion-fast) var(--ease); } - .send-btn:hover { background: var(--fg-2); transform: scale(1.05); } + .send-btn:hover { color: var(--accent); transform: scale(1.05); } /* ─── preview — main canvas, transport bar at bottom ─────────── */ .preview-wrap { @@ -1554,10 +1558,13 @@ position: relative; padding: var(--sp-2) var(--sp-4) var(--sp-3); overflow-x: auto; - overflow-y: hidden; + overflow-y: scroll; + scrollbar-gutter: stable; } - .timeline-body::-webkit-scrollbar { height: 8px; } + .timeline-body::-webkit-scrollbar { height: 8px; width: 8px; } + .timeline-body::-webkit-scrollbar-track { background: transparent; } .timeline-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } + .timeline-body { scrollbar-color: var(--border) transparent; scrollbar-width: thin; } .ruler { position: relative; @@ -2000,14 +2007,14 @@ .crop-controls .done-btn:hover { filter: brightness(1.08); } /* ─── Transcript pane — flowing text + highlighted phrases ───── */ - .transcript-body { padding-top: 4px; } + .transcript-body { padding-top: 0; } .transcript-clip-head { display: flex; align-items: center; gap: 10px; - padding: 0 4px 14px; + padding: 0 4px 4px; border-bottom: 1px solid var(--border-soft); - margin-bottom: 14px; + margin-bottom: 2px; } .transcript-clip-head .badge { width: 22px; height: 22px; @@ -2021,8 +2028,10 @@ flex-shrink: 0; } .transcript-clip-head .title { - font: 600 13px/1.4 var(--font-body); + font: 600 13px/1.2 var(--font-body); color: var(--fg); + margin: 0; + padding: 0; } .transcript-clip-head .sub { font: 400 11px/1.3 var(--font-mono); @@ -2060,6 +2069,7 @@ border-radius: var(--r-sm); font-weight: 500; } + .pane-transcript .transcript-body { max-height: none; } /* ─── panel toggles (titlebar) ─────────────────────────── */ .app[data-collapse~="left"] { --panel-w-left: 0px; } .app[data-collapse~="left"] .rail-left, @@ -2223,7 +2233,243 @@ .regen-control select:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; } .regen-btn { width: 38px; height: 38px; border-radius: var(--r-md); border: 1px solid var(--border); background: var(--surface); color: var(--fg); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } .regen-btn:hover { background: var(--surface-warm); border-color: var(--accent); color: var(--accent); } - .transcript-body { margin: 0; padding: 14px 16px; border-radius: var(--r-md); border: 1px solid var(--border); background: var(--surface-warm); font-family: var(--font-mono); font-size: 12px; line-height: 1.55; color: var(--fg); white-space: pre; overflow: auto; max-height: 38vh; } + pre.transcript-body { margin: 0; padding: 14px 16px; border-radius: var(--r-md); border: 1px solid var(--border); background: var(--surface-warm); font-family: var(--font-mono); font-size: 12px; line-height: 1.55; color: var(--fg); white-space: pre; overflow: auto; max-height: 38vh; } + + /* ─── project modals — open + create ─────────────────────── */ + .proj-search { + position: relative; + display: flex; align-items: center; + margin: 0 0 var(--sp-3); + } + .proj-search svg { + position: absolute; left: 12px; + color: var(--muted); pointer-events: none; + } + .proj-search input { + width: 100%; + height: 38px; + padding: 0 12px 0 36px; + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--surface); + color: var(--fg); + font: 400 13px/1 var(--font-body); + transition: border-color var(--motion-fast) var(--ease), box-shadow var(--motion-fast) var(--ease); + } + .proj-search input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + } + .proj-section-label { + display: flex; align-items: center; justify-content: space-between; + margin: 4px 0 var(--sp-2); + font: 600 11px/1 var(--font-body); + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + } + .proj-section-label .clear { + font: 500 11px/1 var(--font-body); + text-transform: none; + letter-spacing: 0; + color: var(--accent); + background: none; border: 0; cursor: pointer; padding: 4px 6px; + border-radius: var(--r-sm); + } + .proj-section-label .clear:hover { background: var(--accent-wash); } + + .proj-list { + display: flex; flex-direction: column; gap: 2px; + margin: 0 0 var(--sp-3); + max-height: 320px; + overflow-y: auto; + } + .proj-item { + display: grid; + grid-template-columns: 36px 1fr auto auto; + align-items: center; + gap: var(--sp-3); + padding: 10px 12px; + border-radius: var(--r-md); + cursor: pointer; + transition: background var(--motion-fast) var(--ease); + } + .proj-item:hover { background: var(--surface-warm); } + .proj-item.is-active { + background: var(--accent-wash); + box-shadow: inset 0 0 0 1px var(--accent); + } + .proj-thumb { + width: 36px; height: 36px; + border-radius: var(--r-sm); + background: var(--surface-2); + display: grid; place-items: center; + color: var(--muted); + overflow: hidden; + position: relative; + } + .proj-thumb.has-thumb { + background: linear-gradient(135deg, var(--brand-lo) 0%, var(--brand) 100%); + color: var(--accent-on); + } + .proj-thumb-rec::after { + content: ''; position: absolute; top: 6px; right: 6px; + width: 6px; height: 6px; border-radius: 50%; + background: var(--danger); + box-shadow: 0 0 0 2px var(--surface); + } + .proj-meta { min-width: 0; } + .proj-name { + font: 500 13px/1.3 var(--font-body); + color: var(--fg); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .proj-path { + font: 400 11px/1.4 var(--font-mono); + color: var(--muted); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + margin-top: 2px; + } + .proj-time { + font: 400 11px/1 var(--font-mono); + color: var(--meta); + white-space: nowrap; + } + .proj-pin { + width: 24px; height: 24px; + display: grid; place-items: center; + border: 0; background: transparent; + border-radius: var(--r-sm); + color: var(--muted); + cursor: pointer; + opacity: 0; + transition: opacity var(--motion-fast) var(--ease), background var(--motion-fast) var(--ease); + } + .proj-item:hover .proj-pin { opacity: 1; } + .proj-pin:hover { background: var(--surface-3); color: var(--fg); } + .proj-pin.is-pinned { opacity: 1; color: var(--accent); } + + .proj-footer { + display: flex; align-items: center; justify-content: space-between; + padding-top: var(--sp-3); + border-top: 1px solid var(--border-soft); + } + .proj-footer .hint { + font: 400 12px/1.4 var(--font-body); + color: var(--muted); + } + .proj-footer .hint kbd { + font: 500 11px/1 var(--font-mono); + padding: 2px 6px; + border-radius: 4px; + background: var(--surface-2); + border: 1px solid var(--border); + color: var(--fg); + } + .btn-secondary { + height: 32px; + padding: 0 14px; + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--surface); + color: var(--fg); + font: 500 13px/1 var(--font-body); + cursor: pointer; + display: inline-flex; align-items: center; gap: 6px; + transition: background var(--motion-fast) var(--ease), border-color var(--motion-fast) var(--ease); + } + .btn-secondary:hover { background: var(--surface-warm); border-color: var(--border-hi); } + + /* ─── new project — template grid ─────────────────────── */ + .new-form { display: flex; flex-direction: column; gap: var(--sp-4); } + .new-field { display: flex; flex-direction: column; gap: 6px; } + .new-field label { + font: 500 11px/1 var(--font-body); + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + } + .new-field input, .new-field select { + width: 100%; + height: 38px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--surface); + color: var(--fg); + font: 400 13px/1 var(--font-body); + } + .new-field input:focus, .new-field select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + } + .new-field .path-row { + display: grid; grid-template-columns: 1fr auto; gap: var(--sp-2); + } + .tpl-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--sp-2); + } + .tpl-cell { + display: flex; flex-direction: column; align-items: flex-start; + gap: var(--sp-2); + padding: var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--surface); + cursor: pointer; + text-align: left; + transition: border-color var(--motion-fast) var(--ease), background var(--motion-fast) var(--ease); + } + .tpl-cell:hover { border-color: var(--border-hi); background: var(--surface-warm); } + .tpl-cell.is-active { + border-color: var(--accent); + background: var(--accent-wash); + box-shadow: 0 0 0 1px var(--accent); + } + .tpl-icon { + width: 36px; height: 36px; + border-radius: var(--r-sm); + background: var(--surface-2); + display: grid; place-items: center; + color: var(--muted); + } + .tpl-cell.is-active .tpl-icon { background: var(--accent); color: var(--accent-on); } + .tpl-name { + font: 500 13px/1.3 var(--font-body); + color: var(--fg); + } + .tpl-desc { + font: 400 11px/1.4 var(--font-body); + color: var(--muted); + } + .new-actions { + display: flex; justify-content: flex-end; gap: var(--sp-2); + padding-top: var(--sp-3); + border-top: 1px solid var(--border-soft); + } + .btn-primary { + height: 32px; + padding: 0 16px; + border: 1px solid var(--accent); + border-radius: var(--r-md); + background: var(--accent); + color: var(--accent-on); + font: 500 13px/1 var(--font-body); + cursor: pointer; + display: inline-flex; align-items: center; gap: 6px; + transition: background var(--motion-fast) var(--ease); + } + .btn-primary:hover { background: var(--brand-lo); } + .btn-primary:disabled { + background: var(--surface-2); + border-color: var(--border); + color: var(--muted); + cursor: not-allowed; + } @@ -2234,8 +2480,16 @@ + + + + - + OpenScreen @@ -2268,9 +2522,7 @@ - + @@ -2296,8 +2548,8 @@ 0% context - @@ -3071,6 +3323,182 @@

Crop Video

+ + + + + + From 7976fe864524518ef1dabf31bf79fff6aa77847e Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sun, 28 Jun 2026 18:48:45 +0200 Subject: [PATCH 004/141] refactor(design): update titlebar layout and brand mark implementation Relocate project action buttons and update brand mark to use a masked image. --- design/openscreen-editor.html | 182 ++++++++++++++++++++++++++-------- 1 file changed, 140 insertions(+), 42 deletions(-) diff --git a/design/openscreen-editor.html b/design/openscreen-editor.html index 14875da0bc..fb040dfbd0 100644 --- a/design/openscreen-editor.html +++ b/design/openscreen-editor.html @@ -298,8 +298,7 @@ } .titlebar .brand-mark { width: 18px; height: 18px; border-radius: 5px; - background: radial-gradient(circle at 30% 30%, var(--brand), var(--brand-lo) 80%); - box-shadow: 0 0 8px var(--brand-glow); + display: block; } .titlebar .sep { width: 1px; height: 16px; background: var(--border); @@ -2480,16 +2479,8 @@ - - - - - + OpenScreen @@ -2500,12 +2491,17 @@ Enregistré · il y a 2 s - - + + + + + + +
Also tighten pauses longer than 0.6s in the intro.
+ + + +
+
+
+ OpenScreen + 10:43 +
+
Detected 3 pauses over 600ms between 0:08 and 0:31.
Proposed cuts: 0:08.2–0:09.1, 0:18.5–0:19.4, 0:31.0–0:32.2.
Apply all three?
+
+ + + +
+
+
+ +
+
+
+ YOU + + + + +
+
Apply all.
+
+
+ +
+
+
+ OpenScreen + 10:43 +
+
Removed 2.0s · output ready · undo available for 10 min
+
+
+ +
+
+
+ YOU + + + + +
+
Add subtle background music at 18% volume.
+
+
+ +
+
+
+ OpenScreen + 10:44 +
+
Picked Soft Pad A from the project library. Inserting on track 2, fading in at 0:00.0 and fading out at the end. Ducking to 12% under speech where overlap detected.
+
+
+ +
+
+
+ OpenScreen + 10:44 +
+
Music track added · 3 duck points set · review in timeline
+
+
@@ -2679,47 +2781,47 @@

MEDIA

-
-
demo-alfa-no-code-1.mp4
+
+
demo-alfa-no-code-1.mp4
- 0:00:32.0·1·A - 124 MB + 0:00:32.0·1·A + 124 MB
-
  • +
  • -
    -
    demo-alfa-no-code-1.mp4
    +
    +
    demo-alfa-no-code-1.mp4
    - 0:00:32.0·1·A - 98 MB + 0:00:32.0·1·A + 98 MB
  • -
  • +
  • -
    -
    demo-alfa-no-code-1.mp4
    +
    +
    demo-alfa-no-code-1.mp4
    - 0:00:32.0·1·A - 156 MB + 0:00:32.0·1·A + 156 MB
  • -
  • +
  • -
    -
    demo-alfa-no-code-1.mp4
    +
    +
    demo-alfa-no-code-1.mp4
    - 0:00:32.0·1·A - 87 MB + 0:00:32.0·1·A + 87 MB
  • @@ -2768,11 +2870,7 @@

    MEDIA

    0:00.0/7:03.6 - - - - - + + + + +
    -
    -
    - - - V1 - -
    -
    -
    -
    - - - Z - -
    -
    1.8×
    -
    1.8×
    -
    1.8×
    -
    1.8×
    -
    1.8×
    -
    1.8×
    -
    1.8×
    -
    1.8×
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    voice-over-take-3.wav

    +

    0:00.0 — 1:45.2 source 0:00.0–1:45.2

    +
    +
    +
    -
    -
    - - - T - -
    -
    Enter text…
    + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    intro-animation.mp4

    +

    0:00.0 — 0:08.5 source 0:00.0–0:08.5

    +
    +
    +
    +
    -
    - - - S - -
    -
    1.5×
    + + + +
    +
    + + +
    +
    - + +
    @@ -3372,6 +3810,81 @@

    Source Transcript

    + + + + + + + + + + + \ No newline at end of file From be24adb55814aa7558b25f46ec4077f9bbc6a0b1 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 4 Jul 2026 18:08:46 +0200 Subject: [PATCH 138/141] Implement feature X to enhance user experience and optimize performance --- design/openscreen-editor-v2.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/design/openscreen-editor-v2.html b/design/openscreen-editor-v2.html index 7fb2007dd5..af1b09de1e 100644 --- a/design/openscreen-editor-v2.html +++ b/design/openscreen-editor-v2.html @@ -166,7 +166,7 @@ \ No newline at end of file From 2a3612ec5601b2cbb7a9d243442a10bd467d2c08 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 4 Jul 2026 22:53:19 +0200 Subject: [PATCH 139/141] feat(ai-edition): resolve camera/cursor links per asset, not per project A project can hold multiple recordings, each with its own camera or none, but cameraTrack lived on the document (one project = one camera), so only the first asset ever got auto-linked and the PiP never hid for clips without a camera. - Move cameraTrack onto the owning asset; v3 documents auto-upgrade via a schemaVersion 3->4 preprocess step. - Add a fingerprint-based media-links registry (electron/media) so a recording's camera/cursor-telemetry link survives being moved, renamed, or imported into a different project, falling back to the existing sidecar convention for older recordings. - projectStore.addAsset resolves the camera for every asset added, not just the first. - WebcamOverlay/PreviewCanvas resolve the camera from the clip under the playhead, so the webcam slot (and its shadow/background) only renders when that clip's asset actually has one. - LayoutPane disables the layout controls when no clip in the timeline has a camera attached. Co-Authored-By: Claude Sonnet 5 --- electron/ai-edition/document-service.test.ts | 6 +- electron/ai-edition/document-service.ts | 1 + electron/ipc/handlers.ts | 131 ++++++++- electron/media/mediaLinksRegistry.test.ts | 155 +++++++++++ electron/media/mediaLinksRegistry.ts | 261 ++++++++++++++++++ .../ai-edition/NewEditorShell.module.css | 6 + src/components/ai-edition/PreviewCanvas.tsx | 19 +- src/components/ai-edition/RightPanes.tsx | 21 +- .../ai-edition/WebcamOverlay.test.tsx | 159 +++++++++++ src/components/ai-edition/WebcamOverlay.tsx | 38 ++- src/lib/ai-edition/document/migrate.test.ts | 23 +- src/lib/ai-edition/document/migrate.ts | 23 +- .../ai-edition/exporter/documentExporter.ts | 11 +- src/lib/ai-edition/schema/index.test.ts | 68 ++++- src/lib/ai-edition/schema/index.ts | 74 +++-- src/lib/ai-edition/store/projectStore.test.ts | 137 +++++++++ src/lib/ai-edition/store/projectStore.ts | 29 +- src/lib/ai-edition/timeline/camera.test.ts | 113 ++++++++ src/lib/ai-edition/timeline/camera.ts | 24 ++ 19 files changed, 1220 insertions(+), 79 deletions(-) create mode 100644 electron/media/mediaLinksRegistry.test.ts create mode 100644 electron/media/mediaLinksRegistry.ts create mode 100644 src/components/ai-edition/WebcamOverlay.test.tsx create mode 100644 src/lib/ai-edition/timeline/camera.test.ts create mode 100644 src/lib/ai-edition/timeline/camera.ts diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts index 765e7e884d..3fa0dd4e14 100644 --- a/electron/ai-edition/document-service.test.ts +++ b/electron/ai-edition/document-service.test.ts @@ -23,9 +23,9 @@ describe("DocumentService", () => { }); describe("createProject", () => { - it("creates a v3 doc with the given title and writes it to disk", async () => { + it("creates a v4 doc with the given title and writes it to disk", async () => { const doc = await service.createProject("Demo Project"); - expect(doc.schemaVersion).toBe(3); + expect(doc.schemaVersion).toBe(4); expect(doc.project.title).toBe("Demo Project"); expect(doc.project.id).toMatch(/^proj_/); expect(doc.assets).toEqual([]); @@ -33,7 +33,7 @@ describe("DocumentService", () => { const filePath = path.join(tempDir, `${doc.project.id}.axcut`); const raw = await fs.readFile(filePath, "utf8"); expect(JSON.parse(raw)).toMatchObject({ - schemaVersion: 3, + schemaVersion: 4, project: { title: "Demo Project" }, }); }); diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts index 561411d237..67640a39ec 100644 --- a/electron/ai-edition/document-service.ts +++ b/electron/ai-edition/document-service.ts @@ -188,6 +188,7 @@ export class DocumentService { label: input.label?.trim() || path.basename(absolutePath), originalPath: absolutePath, sizeBytes, + cameraTrack: null, }; const next: AxcutDocument = { ...doc, diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index f926d6a316..1ad7a975a0 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -55,6 +55,7 @@ import { LlmConfigStore } from "../ai-edition/llm-config-store"; import { mainLogBuffer } from "../diagnostics/main-log-buffer"; import { mainT } from "../i18n"; import { RECORDINGS_DIR } from "../main"; +import { findMediaLinksByFingerprint, registerMediaLinks } from "../media/mediaLinksRegistry"; import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory"; import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession"; import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; @@ -509,8 +510,7 @@ function normalizeCursorAsset(asset: unknown): NativeCursorAsset | null { }; } -async function readCursorRecordingFile(targetVideoPath: string): Promise { - const telemetryPath = `${targetVideoPath}.cursor.json`; +async function readCursorRecordingFileAt(telemetryPath: string): Promise { try { const content = await fs.readFile(telemetryPath, "utf-8"); const parsed = JSON.parse(content); @@ -555,6 +555,40 @@ async function readCursorRecordingFile(targetVideoPath: string): Promise { + try { + await fs.access(filePath, fsConstants.F_OK); + return true; + } catch { + return false; + } +} + +// P4 — the sidecar convention (`.cursor.json`) only holds while the +// video stays exactly where it was recorded. If it's missing (file moved, +// renamed, or imported from elsewhere), fall back to the fingerprint registry +// (electron/media/mediaLinksRegistry.ts) to re-find the same telemetry file. +async function readCursorRecordingFile(targetVideoPath: string): Promise { + const directPath = `${targetVideoPath}.cursor.json`; + if (await fileExists(directPath)) { + return readCursorRecordingFileAt(directPath); + } + try { + const links = await findMediaLinksByFingerprint(RECORDINGS_DIR, targetVideoPath); + if (links?.cursorTelemetryPath) { + return readCursorRecordingFileAt(links.cursorTelemetryPath); + } + } catch (error) { + console.warn("[media-links] fingerprint lookup failed for cursor telemetry:", error); + } + return { + version: CURSOR_TELEMETRY_VERSION, + provider: "none", + samples: [], + assets: [], + }; +} + async function readCursorTelemetryFile(targetVideoPath: string) { try { const recordingData = await readCursorRecordingFile(targetVideoPath); @@ -857,6 +891,30 @@ async function writePendingCursorTelemetry(videoPath: string) { pendingCursorRecordingData = null; } +// P4 — proactively seeds the media-links registry for a fresh recording so +// its camera/cursor-telemetry links can still be found later even if the +// screen video is moved, renamed, or imported into a different project. +// Best-effort: a registry write hiccup must never fail the recording flow. +async function registerRecordingMediaLinks( + screenVideoPath: string, + options: { webcamVideoPath?: string; cursorCaptureMode?: CursorCaptureMode }, +) { + try { + const cursorTelemetryPath = `${screenVideoPath}.cursor.json`; + const hasCursorTelemetry = await fs + .access(cursorTelemetryPath, fsConstants.F_OK) + .then(() => true) + .catch(() => false); + await registerMediaLinks(RECORDINGS_DIR, screenVideoPath, { + ...(options.webcamVideoPath ? { webcamVideoPath: options.webcamVideoPath } : {}), + ...(hasCursorTelemetry ? { cursorTelemetryPath } : {}), + ...(options.cursorCaptureMode ? { cursorCaptureMode: options.cursorCaptureMode } : {}), + }); + } catch (error) { + console.warn("[media-links] failed to register recording links:", error); + } +} + function shiftPendingCursorTelemetry(offsetMs: number) { if (!pendingCursorRecordingData || !Number.isFinite(offsetMs) || offsetMs <= 0) { return; @@ -1293,6 +1351,63 @@ async function loadRecordedSessionForVideoPath( } } +// P4 — resolves the camera (and cursor-telemetry path, though callers that +// only care about the camera can ignore it) for a screen-recording video, +// trying the cheap path-adjacency sidecar first (handles "just recorded" and +// pre-existing recordings) and falling back to the fingerprint registry +// (handles the file having been moved/renamed/imported from elsewhere). +// `videoPath` must already be normalized + approved by the caller. +async function resolveMediaLinksForVideo(videoPath: string): Promise<{ + webcamVideoPath?: string; + webcamOffsetMs?: number; + cursorTelemetryPath?: string; + resolvedVia: "sidecar" | "fingerprint" | "none"; +}> { + const session = await loadRecordedSessionForVideoPath(videoPath); + const cursorTelemetryPath = `${videoPath}.cursor.json`; + const hasCursorTelemetry = await fs + .access(cursorTelemetryPath, fsConstants.F_OK) + .then(() => true) + .catch(() => false); + + if (session?.webcamVideoPath || hasCursorTelemetry) { + // Opportunistic backfill so the link survives a later move even if this + // recording predates the registry, or if its sidecar doesn't travel with it. + await registerMediaLinks(RECORDINGS_DIR, videoPath, { + ...(session?.webcamVideoPath ? { webcamVideoPath: session.webcamVideoPath } : {}), + ...(hasCursorTelemetry ? { cursorTelemetryPath } : {}), + }).catch((error) => console.warn("[media-links] backfill failed:", error)); + + return { + ...(session?.webcamVideoPath + ? { webcamVideoPath: session.webcamVideoPath, webcamOffsetMs: 0 } + : {}), + ...(hasCursorTelemetry ? { cursorTelemetryPath } : {}), + resolvedVia: "sidecar", + }; + } + + try { + const links = await findMediaLinksByFingerprint(RECORDINGS_DIR, videoPath); + if (links?.webcamVideoPath || links?.cursorTelemetryPath) { + let webcamVideoPath = links.webcamVideoPath; + if (webcamVideoPath && !isPathAllowed(webcamVideoPath)) { + webcamVideoPath = + (await approveReadableVideoPath(webcamVideoPath, [RECORDINGS_DIR])) ?? undefined; + } + return { + ...(webcamVideoPath ? { webcamVideoPath, webcamOffsetMs: links.webcamOffsetMs ?? 0 } : {}), + ...(links.cursorTelemetryPath ? { cursorTelemetryPath: links.cursorTelemetryPath } : {}), + resolvedVia: "fingerprint", + }; + } + } catch (error) { + console.warn("[media-links] fingerprint lookup failed:", error); + } + + return { resolvedVia: "none" }; +} + export function registerIpcHandlers( createEditorWindow: () => void, createSourceSelectorWindow: () => BrowserWindow, @@ -2061,6 +2176,7 @@ export function registerIpcHandlers( `${path.parse(screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`, ); await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); + await registerRecordingMediaLinks(screenVideoPath, { webcamVideoPath, cursorCaptureMode }); return { success: true, @@ -2147,6 +2263,7 @@ export function registerIpcHandlers( `${path.parse(screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`, ); await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); + await registerRecordingMediaLinks(screenVideoPath, { cursorCaptureMode }); return { success: true, @@ -2220,6 +2337,7 @@ export function registerIpcHandlers( `${path.parse(screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`, ); await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); + await registerRecordingMediaLinks(screenVideoPath, { webcamVideoPath, cursorCaptureMode }); return { success: true, @@ -2343,6 +2461,7 @@ export function registerIpcHandlers( `${path.parse(payload.screen.fileName).name}${RECORDING_SESSION_SUFFIX}`, ); await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); + await registerRecordingMediaLinks(screenVideoPath, { webcamVideoPath, cursorCaptureMode }); return { success: true, @@ -2882,14 +3001,14 @@ export function registerIpcHandlers( if (!normalized || !isPathAllowed(normalized)) { return { success: false, error: "Video path has not been approved" }; } - const session = await loadRecordedSessionForVideoPath(normalized); - if (!session?.webcamVideoPath) { + const resolution = await resolveMediaLinksForVideo(normalized); + if (!resolution.webcamVideoPath) { return { success: false, error: "No camera attached to this recording" }; } return { success: true, - webcamVideoPath: session.webcamVideoPath, - offsetMs: 0, + webcamVideoPath: resolution.webcamVideoPath, + offsetMs: resolution.webcamOffsetMs ?? 0, }; } catch (err) { return { diff --git a/electron/media/mediaLinksRegistry.test.ts b/electron/media/mediaLinksRegistry.test.ts new file mode 100644 index 0000000000..9e6521ebb1 --- /dev/null +++ b/electron/media/mediaLinksRegistry.test.ts @@ -0,0 +1,155 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + computeFingerprint, + findMediaLinksByFingerprint, + registerMediaLinks, +} from "./mediaLinksRegistry"; + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), "openscreen-media-links-")); +} + +async function writeFileOfSize(filePath: string, sizeBytes: number, fill = "a"): Promise { + // Not all-identical bytes at the seams so head/tail samples aren't trivially + // equal to each other for small files — irrelevant for correctness, just + // makes assertions easier to reason about. + await fs.writeFile(filePath, fill.repeat(sizeBytes)); +} + +describe("mediaLinksRegistry", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe("computeFingerprint", () => { + it("never reads more than the head+tail sample regardless of file size", async () => { + const bigPath = path.join(tempDir, "big.webm"); + // 2MB file — comfortably larger than the 64KB head/tail sample, but + // small enough to keep the test fast. The point is the read count and + // size, not the absolute file size. + await writeFileOfSize(bigPath, 2 * 1024 * 1024); + + const realOpen = fs.open.bind(fs); + const readLengths: number[] = []; + const openSpy = vi.spyOn(fs, "open").mockImplementation(async (...args) => { + const handle = await realOpen(...(args as Parameters)); + const realRead = handle.read.bind(handle); + // biome-ignore lint/suspicious/noExplicitAny: overriding one instance's method for the read-size assertion below + handle.read = (async (...readArgs: any[]) => { + readLengths.push(readArgs[2]); + return realRead(...readArgs); + }) as typeof handle.read; + return handle; + }); + + await computeFingerprint(bigPath); + openSpy.mockRestore(); + + expect(readLengths).toHaveLength(2); + for (const length of readLengths) { + expect(length).toBeLessThanOrEqual(64 * 1024); + } + }); + + it("degenerates gracefully for files smaller than the sample size", async () => { + const smallPath = path.join(tempDir, "small.webm"); + await writeFileOfSize(smallPath, 10); + const fp = await computeFingerprint(smallPath); + expect(fp.sizeBytes).toBe(10); + expect(fp.headSampleBase64).toBe(fp.tailSampleBase64); + }); + }); + + describe("resolution via sidecar (fast path)", () => { + it("returns webcam + cursor links found next to the video and backfills the registry", async () => { + const screenPath = path.join(tempDir, "recording-1.webm"); + const webcamPath = path.join(tempDir, "recording-1-webcam.webm"); + await writeFileOfSize(screenPath, 5000, "s"); + await writeFileOfSize(webcamPath, 3000, "w"); + + await registerMediaLinks(tempDir, screenPath, { webcamVideoPath: webcamPath }); + + const resolved = await findMediaLinksByFingerprint(tempDir, screenPath); + expect(resolved?.webcamVideoPath).toBe(webcamPath); + }); + }); + + describe("resolution via fingerprint (moved/imported-elsewhere)", () => { + it("re-links a copy of the screen video at a brand new path with no sidecars", async () => { + const originalDir = await makeTempDir(); + try { + const originalScreenPath = path.join(originalDir, "recording-2.webm"); + const webcamPath = path.join(originalDir, "recording-2-webcam.webm"); + await writeFileOfSize(originalScreenPath, 5000, "x"); + await writeFileOfSize(webcamPath, 3000, "y"); + + await registerMediaLinks(tempDir, originalScreenPath, { webcamVideoPath: webcamPath }); + + // Simulate "imported into a different project from a different + // location": same bytes, brand new path, no sidecar files here. + const importedPath = path.join(tempDir, "imported-copy.webm"); + await fs.copyFile(originalScreenPath, importedPath); + + const resolved = await findMediaLinksByFingerprint(tempDir, importedPath); + expect(resolved?.webcamVideoPath).toBe(webcamPath); + } finally { + await fs.rm(originalDir, { recursive: true, force: true }); + } + }); + + it("returns null when there is no matching fingerprint", async () => { + const unknownPath = path.join(tempDir, "unknown.webm"); + await writeFileOfSize(unknownPath, 1000, "z"); + const resolved = await findMediaLinksByFingerprint(tempDir, unknownPath); + expect(resolved).toBeNull(); + }); + }); + + describe("registerMediaLinks", () => { + it("does nothing when no links are provided", async () => { + const videoPath = path.join(tempDir, "no-links.webm"); + await writeFileOfSize(videoPath, 500); + await registerMediaLinks(tempDir, videoPath, {}); + const registryFile = path.join(tempDir, "media-links.registry.json"); + await expect(fs.stat(registryFile)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("handles concurrent registrations without corrupting the registry file", async () => { + const paths = await Promise.all( + Array.from({ length: 8 }, async (_, i) => { + const p = path.join(tempDir, `concurrent-${i}.webm`); + await writeFileOfSize(p, 400 + i, String(i)); + return p; + }), + ); + + await Promise.all( + paths.map((p, i) => + registerMediaLinks(tempDir, p, { + webcamVideoPath: `${p}-webcam.webm`, + webcamOffsetMs: i, + }), + ), + ); + + const registryFile = path.join(tempDir, "media-links.registry.json"); + const raw = await fs.readFile(registryFile, "utf-8"); + const parsed = JSON.parse(raw); + expect(parsed.entries).toHaveLength(paths.length); + + for (const p of paths) { + const resolved = await findMediaLinksByFingerprint(tempDir, p); + expect(resolved?.webcamVideoPath).toBe(`${p}-webcam.webm`); + } + }); + }); +}); diff --git a/electron/media/mediaLinksRegistry.ts b/electron/media/mediaLinksRegistry.ts new file mode 100644 index 0000000000..974266e302 --- /dev/null +++ b/electron/media/mediaLinksRegistry.ts @@ -0,0 +1,261 @@ +// P4 — media-links registry: a project-independent record of which webcam +// recording and/or cursor-telemetry sidecar go with a given screen-recording +// video file, keyed by a lightweight content fingerprint rather than by path. +// +// The existing sidecar conventions (`.session.json` next to a screen +// recording, `.cursor.json` next to a video) only work while the file +// stays exactly where it was recorded. This registry lets OpenScreen re-find +// the same links after the screen video has been imported into a different +// project from a different location — the caller (electron/ipc/handlers.ts) +// tries the cheap path-adjacency sidecars first and falls back to this +// registry, backfilling an entry once it resolves so future lookups survive +// a later move even if the original sidecar doesn't travel with the file. +// +// ponytail: the fingerprint is deliberately NOT a full-file hash — recordings +// can be an hour of 4K footage, and re-reading gigabytes on every asset-add +// would make importing feel broken. Size + a head/tail byte sample is enough +// to survive move/rename/copy, which is the only scenario this needs to +// solve (not detecting a re-encoded duplicate). + +import fs from "node:fs/promises"; +import path from "node:path"; +import type { CursorCaptureMode } from "../../src/lib/recordingSession"; + +// ponytail: `baseDir` is passed in by every caller (RECORDINGS_DIR in +// electron/ipc/handlers.ts) rather than imported here, so this module has no +// dependency on Electron and can be unit-tested with a plain temp directory — +// same rationale as DocumentService's constructor-injected `projectsRoot`. + +const REGISTRY_FILE_NAME = "media-links.registry.json"; +const SAMPLE_BYTES = 64 * 1024; + +export interface MediaFingerprint { + sizeBytes: number; + headSampleBase64: string; + tailSampleBase64: string; +} + +export interface MediaLinkEntry { + lastKnownPath: string; + fingerprint: MediaFingerprint; + webcamVideoPath?: string; + webcamOffsetMs?: number; + cursorTelemetryPath?: string; + cursorCaptureMode?: CursorCaptureMode; + updatedAt: string; +} + +interface MediaLinksRegistryFile { + version: 1; + entries: MediaLinkEntry[]; +} + +const EMPTY_REGISTRY: MediaLinksRegistryFile = { version: 1, entries: [] }; + +function registryPath(baseDir: string): string { + return path.join(baseDir, REGISTRY_FILE_NAME); +} + +async function readSample(handle: fs.FileHandle, size: number, position: number): Promise { + const buf = Buffer.alloc(size); + if (size === 0) return buf; + await handle.read(buf, 0, size, position); + return buf; +} + +/** + * Reads at most `SAMPLE_BYTES` from the head and `SAMPLE_BYTES` from the tail + * of the file — never the file body — so this stays fast regardless of + * recording length. + */ +export async function computeFingerprint(filePath: string): Promise { + const stat = await fs.stat(filePath); + const handle = await fs.open(filePath, "r"); + try { + const headSize = Math.min(SAMPLE_BYTES, stat.size); + const head = await readSample(handle, headSize, 0); + const tailSize = Math.min(SAMPLE_BYTES, stat.size); + const tail = await readSample(handle, tailSize, Math.max(0, stat.size - tailSize)); + return { + sizeBytes: stat.size, + headSampleBase64: head.toString("base64"), + tailSampleBase64: tail.toString("base64"), + }; + } finally { + await handle.close(); + } +} + +export function fingerprintsMatch(a: MediaFingerprint, b: MediaFingerprint): boolean { + return ( + a.sizeBytes === b.sizeBytes && + a.headSampleBase64 === b.headSampleBase64 && + a.tailSampleBase64 === b.tailSampleBase64 + ); +} + +function normalizeEntry(candidate: unknown): MediaLinkEntry | null { + if (!candidate || typeof candidate !== "object") return null; + const raw = candidate as Partial; + const fp = raw.fingerprint; + if ( + typeof raw.lastKnownPath !== "string" || + !fp || + typeof fp.sizeBytes !== "number" || + typeof fp.headSampleBase64 !== "string" || + typeof fp.tailSampleBase64 !== "string" + ) { + return null; + } + return { + lastKnownPath: raw.lastKnownPath, + fingerprint: { + sizeBytes: fp.sizeBytes, + headSampleBase64: fp.headSampleBase64, + tailSampleBase64: fp.tailSampleBase64, + }, + ...(typeof raw.webcamVideoPath === "string" ? { webcamVideoPath: raw.webcamVideoPath } : {}), + ...(typeof raw.webcamOffsetMs === "number" ? { webcamOffsetMs: raw.webcamOffsetMs } : {}), + ...(typeof raw.cursorTelemetryPath === "string" + ? { cursorTelemetryPath: raw.cursorTelemetryPath } + : {}), + ...(raw.cursorCaptureMode === "editable-overlay" || raw.cursorCaptureMode === "system" + ? { cursorCaptureMode: raw.cursorCaptureMode } + : {}), + updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : new Date().toISOString(), + }; +} + +async function readRegistry(baseDir: string): Promise { + try { + const raw = await fs.readFile(registryPath(baseDir), "utf-8"); + const parsed = JSON.parse(raw); + const entries = Array.isArray(parsed?.entries) + ? parsed.entries + .map((e: unknown) => normalizeEntry(e)) + .filter((e: MediaLinkEntry | null): e is MediaLinkEntry => e !== null) + : []; + return { version: 1, entries }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { ...EMPTY_REGISTRY }; + } + console.warn("[media-links] failed to read registry, starting fresh:", error); + return { ...EMPTY_REGISTRY }; + } +} + +async function writeRegistry(baseDir: string, file: MediaLinksRegistryFile): Promise { + await fs.mkdir(baseDir, { recursive: true }); + const target = registryPath(baseDir); + const tmpPath = `${target}.tmp-${process.pid}-${Date.now()}`; + await fs.writeFile(tmpPath, JSON.stringify(file, null, 2), "utf-8"); + await fs.rename(tmpPath, target); +} + +// Single-process, single-user desktop app — an in-process promise chain per +// registry path is enough to keep concurrent writes from clobbering each +// other (keyed by baseDir so tests using different temp dirs don't serialize +// against each other). +const writeQueues = new Map>(); + +function withWriteLock(baseDir: string, fn: () => Promise): Promise { + const queue = writeQueues.get(baseDir) ?? Promise.resolve(); + const result = queue.then(fn, fn); + writeQueues.set( + baseDir, + result.then( + () => undefined, + () => undefined, + ), + ); + return result; +} + +async function updateRegistry( + baseDir: string, + mutator: (file: MediaLinksRegistryFile) => MediaLinksRegistryFile, +): Promise { + await withWriteLock(baseDir, async () => { + const current = await readRegistry(baseDir); + await writeRegistry(baseDir, mutator(current)); + }); +} + +export interface MediaLinksToRegister { + webcamVideoPath?: string; + webcamOffsetMs?: number; + cursorTelemetryPath?: string; + cursorCaptureMode?: CursorCaptureMode; +} + +/** + * Registers (or refreshes) the links for `videoPath`. Safe to call whenever + * a link is known-good — at recording time, or as an opportunistic backfill + * after resolving a link via the legacy sidecar convention. `baseDir` is the + * directory the registry file lives in (RECORDINGS_DIR in production). + */ +export async function registerMediaLinks( + baseDir: string, + videoPath: string, + links: MediaLinksToRegister, +): Promise { + if (!links.webcamVideoPath && !links.cursorTelemetryPath) return; + const fingerprint = await computeFingerprint(videoPath); + await updateRegistry(baseDir, (file) => { + const existingIndex = file.entries.findIndex((e) => + fingerprintsMatch(e.fingerprint, fingerprint), + ); + const entry: MediaLinkEntry = { + lastKnownPath: videoPath, + fingerprint, + ...links, + updatedAt: new Date().toISOString(), + }; + const entries = + existingIndex >= 0 + ? file.entries.map((e, i) => (i === existingIndex ? { ...e, ...entry } : e)) + : [...file.entries, entry]; + return { version: 1, entries }; + }); +} + +export interface MediaLinksLookup { + webcamVideoPath?: string; + webcamOffsetMs?: number; + cursorTelemetryPath?: string; + cursorCaptureMode?: CursorCaptureMode; +} + +/** + * Looks up `videoPath` in the registry by content fingerprint — used as the + * fallback when the file has no (or a stale) sidecar sitting next to it, + * e.g. because it was moved, renamed, or imported from elsewhere. + */ +export async function findMediaLinksByFingerprint( + baseDir: string, + videoPath: string, +): Promise { + const fingerprint = await computeFingerprint(videoPath); + const registry = await readRegistry(baseDir); + const match = registry.entries.find((e) => fingerprintsMatch(e.fingerprint, fingerprint)); + if (!match) return null; + + // Path drifted from what's on record — refresh it so the next lookup can + // take a cheaper path if one becomes available again. + if (match.lastKnownPath !== videoPath) { + void updateRegistry(baseDir, (file) => ({ + version: 1, + entries: file.entries.map((e) => + fingerprintsMatch(e.fingerprint, fingerprint) ? { ...e, lastKnownPath: videoPath } : e, + ), + })); + } + + return { + ...(match.webcamVideoPath ? { webcamVideoPath: match.webcamVideoPath } : {}), + ...(typeof match.webcamOffsetMs === "number" ? { webcamOffsetMs: match.webcamOffsetMs } : {}), + ...(match.cursorTelemetryPath ? { cursorTelemetryPath: match.cursorTelemetryPath } : {}), + ...(match.cursorCaptureMode ? { cursorCaptureMode: match.cursorCaptureMode } : {}), + }; +} diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index 7740fb34c7..deea85cc20 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -969,6 +969,8 @@ } .field select:focus, .field input:focus { border-color: var(--brand); } +.field select:disabled, +.field input:disabled { opacity: 0.5; cursor: not-allowed; } /* ─── bg thumb grid ────────────────────────────────────────────── */ .bgGrid { @@ -1018,6 +1020,7 @@ } .toggle.isOn { background: var(--brand); } .toggle.isOn::after { transform: translateX(16px); } +.toggle:disabled { opacity: 0.5; cursor: not-allowed; } /* ─── slider cell (effect) ─────────────────────────────────────── */ .sliderGrid { @@ -1066,6 +1069,7 @@ cursor: pointer; } .sliderCell.full { grid-column: 1 / -1; } +.sliderCell input[type=range]:disabled { opacity: 0.5; cursor: not-allowed; } /* ─── cursor style grid ────────────────────────────────────────── */ .cursorGrid { @@ -1086,6 +1090,8 @@ color: var(--muted); } .cursorCell:hover { border-color: var(--border-hi); color: var(--fg-2); } +.cursorCell:disabled { opacity: 0.5; cursor: not-allowed; } +.cursorCell:disabled:hover { border-color: var(--border); color: var(--muted); } .cursorCell.isActive { border-color: var(--brand); box-shadow: 0 0 0 2px color-mix(in oklab, var(--brand), transparent 75%); diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index 0e6106cbaf..7b16b164bc 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -35,7 +35,9 @@ import type { AxcutSkipRange, AxcutZoomRegion, } from "@/lib/ai-edition/schema"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; +import { resolveActiveCameraTrack } from "@/lib/ai-edition/timeline/camera"; import type { SpeedRegion } from "@/lib/ai-edition/timeline/speed"; import { computeCompositeLayout, @@ -86,6 +88,7 @@ const WEBCAM_SOURCE_SIZE = { width: 960, height: 720 }; export function PreviewCanvas(props: PreviewCanvasProps) { const { settings, setLive, commit } = useEditorSettings(); + const assets = useProjectStore((s) => s.document?.assets ?? []); const frameRef = useRef(null); const webcamSlotRef = useRef(null); const [canvasSize, setCanvasSize] = useState({ width: 1280, height: 720 }); @@ -130,7 +133,8 @@ export function PreviewCanvas(props: PreviewCanvasProps) { const frameSize = useMemo(() => { const ratio = getAspectRatioValue(settings.aspectRatio); const { width: containerWidth, height: containerHeight } = containerSize; - if (containerWidth <= 0 || containerHeight <= 0) return { width: containerWidth, height: containerHeight }; + if (containerWidth <= 0 || containerHeight <= 0) + return { width: containerWidth, height: containerHeight }; if (containerWidth / containerHeight > ratio) { const height = containerHeight; return { width: Math.round(height * ratio), height: Math.round(height) }; @@ -185,6 +189,17 @@ export function PreviewCanvas(props: PreviewCanvasProps) { () => buildWebcamStyle(layout, settings, canvasSize), [layout, settings, canvasSize], ); + // P4 — the layout math above only knows the user's chosen preset + // (PiP/dual/stack), not whether the clip under the playhead actually has a + // camera. Without this, an empty (but styled — shadow, background) webcam + // slot stays visible for clips with no camera attached. + const activeCameraTrack = useMemo( + () => resolveActiveCameraTrack(assets, props.clips, props.currentTimeSec), + [assets, props.clips, props.currentTimeSec], + ); + const showWebcamSlot = Boolean( + layout?.webcamRect && activeCameraTrack?.visible && activeCameraTrack.sourcePath, + ); const [isPlaying, setIsPlaying] = useState(false); const [videoEl, setVideoEl] = useState(null); const handleVideoElement = useMemo(() => props.onVideoElement, [props.onVideoElement]); @@ -278,7 +293,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) { ) : null}
    ) : null} - {layout?.webcamRect ? ( + {layout?.webcamRect && showWebcamSlot ? (
    s.document); // ponytail: the mask shape picker only makes sense for Picture-in-Picture. // Dual-frame (side-by-side) and vertical-stack (top/bottom) hardcode a // rectangle in the legacy layout math, so we hide those controls when the // preset isn't PiP. const isPip = settings.webcamLayoutPreset === "picture-in-picture"; + // P4 — a project can hold clips with no camera attached at all (plain + // imported videos, or a recording made without a webcam). The layout + // controls have nothing to act on in that case, so they're disabled + // rather than left live for a preset that will never show anything. + const hasAnyCamera = document + ? hasAnyClipWithCamera(document.assets, document.timeline.clips) + : false; + const layoutControlsDisabled = !hasDocument || !hasAnyCamera; return ( Layout