From b74285d58b008a1504b8ca5b474b851a73c98bfe Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 21 Aug 2026 22:50:08 +0200 Subject: [PATCH 01/13] feat(editor): merge the background facet into video effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background and Effects were two inspector facets, and four of Effects' five controls were background controls in disguise: the blur blurs the background, the shadow falls on it, and roundness and padding exist only to let it show through. A user who wanted no background opened "Background", found nothing but wallpapers, and filed #84 — the split hid the answer in the tab that doesn't say "background". The facet is gone. VideoEffectsPane now carries three sections: Background (picker + blur), Frame (shadow, roundness, padding) and Motion (motion blur). Motion blur is alone in its section and correctly so — it blurs the recording as it moves, never the background, so it belongs under neither of the other two. The picker itself floats behind a swatch trigger rather than sitting inline. Its 18-swatch grid was ~300px on its own and pushed the frame sliders below the fold on a laptop window, which would have reproduced #84's failure one level down. Collapsed, the whole pane is 462px instead of 806px and fits with no scroll at 1512x982; Frame moves from 502px to 158px. Same trade the aspect-ratio menu in the timeline toolbar already makes: big choice, small trigger. Two keys added across all 13 locales (effects.frame, effects.motion); the pane's help text joins the two existing sentences rather than adding a 14th string to translate. Refs #84 Co-Authored-By: Claude Opus 5 --- scripts/check-docs.mjs | 1 + .../ai-edition/NewEditorShell.module.css | 44 +++ .../ai-edition/RightPanes.i18n.test.tsx | 24 +- src/components/ai-edition/RightPanes.tsx | 355 +++++++++++------- .../ai-edition/backgroundImageUpload.test.tsx | 4 +- .../ai-edition/v4/FloatingInspector.tsx | 17 +- src/i18n/locales/ar/settings.json | 2 + src/i18n/locales/en/settings.json | 2 + src/i18n/locales/es/settings.json | 2 + src/i18n/locales/fr/settings.json | 2 + src/i18n/locales/it/settings.json | 2 + src/i18n/locales/ja-JP/settings.json | 2 + src/i18n/locales/ko-KR/settings.json | 2 + src/i18n/locales/pt-BR/settings.json | 2 + src/i18n/locales/ru/settings.json | 2 + src/i18n/locales/tr/settings.json | 2 + src/i18n/locales/vi/settings.json | 2 + src/i18n/locales/zh-CN/settings.json | 2 + src/i18n/locales/zh-TW/settings.json | 2 + .../architecture/decisions.md | 1 + .../architecture/editor-shell.md | 14 +- 21 files changed, 327 insertions(+), 159 deletions(-) diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index fc798edb2..65119765e 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -26,6 +26,7 @@ const LEGACY = [ "Bottombar", "Titlebar", "TranscriptEditor", + "BackgroundPane", "ai-edition-roadmap", "ai-edition-collision-analysis", "openscreen-inventory", diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index 833dda366..4afd78cc3 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -1050,6 +1050,50 @@ box-shadow: 0 0 0 2px color-mix(in oklab, var(--brand), transparent 70%); } +/* ─── background picker (collapsed trigger + floating panel) ───── */ +/* The trigger paints the CURRENT background as its own surface, so the pane shows what is + selected without spending the ~300px the inline swatch grid used to cost. */ +.bgTrigger { + display: flex; + align-items: flex-end; + justify-content: flex-end; + height: 52px; + margin: 0 var(--sp-4) 12px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-2); + cursor: pointer; + overflow: hidden; + transition: border-color 150ms ease; +} +.bgTrigger:hover { border-color: var(--brand); } +/* Sits ON an arbitrary photo, so it carries its own ground rather than trusting contrast. */ +.bgTriggerChip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 7px; + border-radius: 999px; + background: color-mix(in oklab, var(--bg), transparent 10%); + color: var(--fg); + font: 500 var(--fs-app-sm)/1 var(--font-body); +} +.bgPopover { + width: 300px; + padding-top: 4px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-1); + box-shadow: var(--elev-pop); + /* Tall content (18 swatches, or the gradient editor) scrolls itself instead of running + off-screen on a short window. */ + max-height: min(70vh, 460px); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + /* ─── toggle (switch) ──────────────────────────────────────────── */ .toggle { width: 36px; diff --git a/src/components/ai-edition/RightPanes.i18n.test.tsx b/src/components/ai-edition/RightPanes.i18n.test.tsx index 36cfd8c80..51441b255 100644 --- a/src/components/ai-edition/RightPanes.i18n.test.tsx +++ b/src/components/ai-edition/RightPanes.i18n.test.tsx @@ -6,12 +6,12 @@ // localized text is what actually reaches the DOM. import "@testing-library/jest-dom"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { ReactElement } from "react"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { I18nProvider } from "@/contexts/I18nContext"; import { LOCALE_STORAGE_KEY } from "@/i18n/config"; -import { BackgroundPane, CursorPane, LayoutPane, VideoEffectsPane } from "./RightPanes"; +import { CursorPane, LayoutPane, VideoEffectsPane } from "./RightPanes"; function renderIn(locale: string, ui: ReactElement) { localStorage.setItem(LOCALE_STORAGE_KEY, locale); @@ -28,10 +28,21 @@ afterEach(() => { }); describe("right-rail panes are localized", () => { - it("renders the background pane in French", () => { - renderIn("fr", ); - expect(screen.getByRole("heading", { name: "Arrière-plan" })).toBeInTheDocument(); + it("renders the background section of the effects pane in French", () => { + // Background stopped being its own pane when the two facets merged; it is a section + // of the effects pane now, so the same strings must survive under the new heading. + renderIn("fr", ); + expect(screen.getByRole("heading", { name: "Effets vidéo" })).toBeInTheDocument(); + expect(screen.getByText("Arrière-plan")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Aide" })).toBeInTheDocument(); + }); + + it("renders the floating background picker in French once it is opened", () => { + // The picker collapsed into a popover so the frame sliders stay above the fold, so + // its strings are only in the DOM after the trigger is clicked — the assertion has + // to open it, or it would pass on an empty document forever. + renderIn("fr", ); + fireEvent.click(screen.getByRole("button", { name: "Arrière-plan" })); expect(screen.getByRole("button", { name: "Téléverser une image" })).toBeInTheDocument(); // wallpaper swatches interpolate their index through the catalog expect(screen.getByRole("button", { name: "Fond 1" })).toBeInTheDocument(); @@ -42,6 +53,9 @@ describe("right-rail panes are localized", () => { expect(screen.getByRole("heading", { name: "Efectos de video" })).toBeInTheDocument(); expect(screen.getByText("Desenfoque de movimiento")).toBeInTheDocument(); expect(screen.getByText("Sombra")).toBeInTheDocument(); + // the merged pane's own section headers + expect(screen.getByText("Marco")).toBeInTheDocument(); + expect(screen.getByText("Movimiento")).toBeInTheDocument(); }); it("renders the layout pane in Japanese", () => { diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 554a77a7e..988ad0e18 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -7,17 +7,18 @@ import { AudioLines, + ChevronDown, FileText, HelpCircle, Layout as LayoutIcon, Loader2, MousePointerClick, - Palette, Sliders, Trash2, } from "lucide-react"; import { type ChangeEvent, + type CSSProperties, type FormEvent, memo, type ClipboardEvent as ReactClipboardEvent, @@ -34,6 +35,7 @@ import { import { toast } from "sonner"; import defaultCursorPreviewUrl from "@/assets/cursors/Cursor=Default.svg"; import GradientEditor, { type GradientEditorState } from "@/components/ui/gradient-editor"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useScopedT } from "@/contexts/I18nContext"; import type { AxcutAsset, @@ -62,7 +64,12 @@ import { resolveWebcamLayoutPreset, supportsWebcamReactiveZoom } from "@/lib/com import { supportsCursorClickEffects } from "@/lib/cursor/cursorCapabilities"; import { CURSOR_THEMES, DEFAULT_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes"; import { buildGradientFromEditor } from "@/lib/gradientBuilder"; -import { resolveImageWallpaperUrl, WALLPAPER_PATHS, WALLPAPER_THUMB_PATHS } from "@/lib/wallpaper"; +import { + classifyWallpaper, + resolveImageWallpaperUrl, + WALLPAPER_PATHS, + WALLPAPER_THUMB_PATHS, +} from "@/lib/wallpaper"; import { isNativeCompositorActive, setNativeParam } from "@/native"; import styles from "./NewEditorShell.module.css"; @@ -123,7 +130,7 @@ function Pane({ title, icon, helpText, children }: PaneProps) { ); } -// ─── Background ──────────────────────────────────────────────────── +// ─── Background (section of the Effects pane) ────────────────────── // keep the gradient palette small and curated — every block renders // in the picker and gets serialized to legacyEditor on save. @@ -201,10 +208,15 @@ export function isSupportedBackgroundImage(type: string, fileName: string): bool // in the v2 editor: gradient strings stay as-is, colors as `#hex`, and image // paths are restricted to `/wallpapers/...` or the user's own data: URLs from // the upload custom flow. -export function BackgroundPane() { +function BackgroundSection() { const ts = useScopedT("settings"); const { settings, set, setLive, commit, hasDocument } = useEditorSettings(); - const [tab, setTab] = useState<"image" | "color" | "gradient">("image"); + const [pickerOpen, setPickerOpen] = useState(false); + // Seeded from what the project is actually using, so the picker opens on the tab the + // user is already in rather than always on Image. + const [tab, setTab] = useState<"image" | "color" | "gradient">( + () => classifyWallpaper(settings.wallpaper).kind, + ); const fileInputRef = useRef(null); const customUrls = useMemoCustomWallpapers(settings.wallpaper); @@ -267,109 +279,177 @@ export function BackgroundPane() { }; return ( - } - helpText={ts("background.help")} - > -
- - - -
- {tab === "image" ? ( - <> + <> +
{ts("background.title")}
+ {/* The picker FLOATS instead of sitting inline. Inline, the 18-swatch grid was + ~300px of the pane on its own and pushed padding/roundness/shadow — the + controls #84 is actually about — below the fold on a laptop window. A user + who opened the one appearance tab saw wallpapers and nothing else, which is + the same failure the facet merge set out to fix, one level down. Same + trade the aspect-ratio menu makes in the timeline toolbar: big choice, + small trigger. */} + + - -
- {customUrls.map((url) => ( + + +
+
+ + +
+ {tab === "image" ? ( + <>
- - ) : tab === "color" ? ( - void set({ wallpaper: color })} - /> - ) : ( - <> -
- {GRAD_PRESETS.map((bg, i) => ( - +
+ {customUrls.map((url) => ( +
+ + ) : tab === "color" ? ( + void set({ wallpaper: color })} /> - ))} + ) : ( + <> +
+ {GRAD_PRESETS.map((bg, i) => ( +
+ {hasDocument ? : null} + + )}
- {hasDocument ? : null} - - )} - +
+ + {/* Stays mounted OUTSIDE the popover: opening the OS file dialog takes focus, + which closes the popover and would unmount the input mid-pick, dropping the + file. It has no layout to cost us here. */} + + {/* Reads in the order it acts: pick a background, then blur it. Lived under + "Effects" while that was a separate facet, which is how a control named + "Blur BG" ended up in the tab that doesn't say background. */} +
+ {ts("effects.blurBg")} + { + void set({ showBlur: v }); + if (isNativeCompositorActive()) { + setNativeParam("backgroundBlur", v); + } + }} + /> +
+ + ); +} + +/** + * The CSS `background` shorthand that paints a wallpaper value as a swatch — the same + * painting the grid thumbs do, hoisted out so the collapsed trigger shows exactly what the + * grid would show as selected. Bundled wallpapers resolve to their small pre-generated + * thumbnail; colours and gradients are their own literal; a custom `data:` URL passes + * through `resolveImageWallpaperUrl` untouched. + */ +function backgroundSwatchStyle(value: string): CSSProperties { + const classified = classifyWallpaper(value); + if (classified.kind !== "image") return { background: classified.value }; + const bundled = WALLPAPER_PATHS.indexOf(classified.path); + const url = resolveImageWallpaperUrl( + bundled >= 0 ? WALLPAPER_THUMB_PATHS[bundled] : classified.path, ); + return { background: `center/cover no-repeat url(${url})` }; } // keep the user's last data: URL after they switch tabs so the Image @@ -1361,55 +1441,44 @@ export type { AxcutWord }; // ─── Video Effects ───────────────────────────────────────────────── +/** + * One pane for everything that shapes the composition. + * + * Background and Effects used to be two facets, and four of Effects' five controls were + * background controls in disguise: the blur blurs the background, the shadow falls ON the + * background, and roundness and padding exist only to let it show through. So a user who + * wanted no background at all opened "Background", found nothing but wallpapers, and filed + * #84. The split had no seam to sit on — it just hid the answer in the tab that doesn't say + * "background". + * + * Merged, the sections read as what they are: pick a background, decide how the recording + * sits on it, then the one control that is about neither. + */ export function VideoEffectsPane() { const ts = useScopedT("settings"); - const { settings, set, setLive, commit, hasDocument } = useEditorSettings(); + const { settings, setLive, commit, hasDocument } = useEditorSettings(); - // Push the current frame-styling settings into the native D3D compositor - // view whenever it becomes active (or the settings change while it's up). - // The onChange handlers above already push per-control diffs; this effect - // also covers the "user tweaked a setting before the native view was - // mounted" case so the view doesn't render with stale defaults. // Le rayon natif = rayon de base de la fixture (~24px @1920) × cette échelle. Diviser la // valeur px de l'UI par ce même rayon de base fait que le coin natif ≈ les px affichés // (au lieu de plafonner à ~24px comme avec /64). const NATIVE_SCREEN_BASE_RADIUS_PX = 24; - // La synchro initiale de ces params vit desormais dans NativeCompositorOverlay + // La synchro initiale de ces params vit dans NativeCompositorOverlay // (`pushAllNativeParams`) : l'inspecteur n'affiche qu'un panneau a la fois, donc // un effet de montage ici ne poussait rien tant que ce panneau precis n'avait pas // ete ouvert. Les handlers par controle ci-dessous poussent toujours leurs diffs. return ( - } helpText={ts("effects.help")}> -
- {ts("effects.blurBg")} - { - void set({ showBlur: v }); - if (isNativeCompositorActive()) { - setNativeParam("backgroundBlur", v); - } - }} - /> -
+ } + // Two complete sentences, one per merged half, rather than a third string to + // translate 13 times — both already exist in every locale and neither is a + // fragment of the other, so joining them survives translation and RTL alike. + helpText={`${ts("background.help")} ${ts("effects.help")}`} + > + +
{ts("effects.frame")}
- { - setLive({ motionBlurAmount: v / 100 }); - if (isNativeCompositorActive()) { - setNativeParam("motionBlur", v / 100); - } - }} - onCommit={() => void commit()} - /> void commit()} />
+ {/* Alone in its section, and correctly so: this blurs the RECORDING as it moves + (zooms, layout changes) — see `effects.motion_blur` driving the tap count in + frame_geometry.rs. It is the one control here that never touches the + background, so it does not belong under "Frame" either. */} +
{ts("effects.motion")}
+
+ { + setLive({ motionBlurAmount: v / 100 }); + if (isNativeCompositorActive()) { + setNativeParam("motionBlur", v / 100); + } + }} + onCommit={() => void commit()} + /> +
); } diff --git a/src/components/ai-edition/backgroundImageUpload.test.tsx b/src/components/ai-edition/backgroundImageUpload.test.tsx index bf2d95694..e118172dc 100644 --- a/src/components/ai-edition/backgroundImageUpload.test.tsx +++ b/src/components/ai-edition/backgroundImageUpload.test.tsx @@ -6,7 +6,7 @@ import { cleanup, fireEvent, render } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/contexts/I18nContext"; -import { BackgroundPane, isSupportedBackgroundImage } from "./RightPanes"; +import { isSupportedBackgroundImage, VideoEffectsPane } from "./RightPanes"; const toastError = vi.hoisted(() => vi.fn()); vi.mock("sonner", () => ({ toast: { error: toastError, success: vi.fn(), info: vi.fn() } })); @@ -55,7 +55,7 @@ describe("a rejected upload tells the user", () => { function pick(file: File) { const { container } = render( - + , ); const input = container.querySelector('input[type="file"]'); diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index 2caca6d72..2d3651a15 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -3,7 +3,6 @@ import { Captions as CaptionsIcon, ChevronRight, FileText, - Image as ImageIcon, Layout as LayoutIcon, Maximize2, MousePointer2, @@ -44,7 +43,6 @@ import { CaptionsPane } from "../CaptionsPane"; import { ColorField } from "../ColorField"; import { AudioPane, - BackgroundPane, CursorPane, LayoutPane, SliderCell, @@ -56,17 +54,11 @@ import styles from "./EditorShellV4.module.css"; type TimelineApi = ReturnType; -export type Facet = - | "background" - | "effects" - | "layout" - | "audio" - | "cursor" - | "captions" - | "transcript"; +export type Facet = "effects" | "layout" | "audio" | "cursor" | "captions" | "transcript"; -const FACETS: Array<{ id: Facet; labelKey: string; icon: typeof ImageIcon }> = [ - { id: "background", labelKey: "background.title", icon: ImageIcon }, +const FACETS: Array<{ id: Facet; labelKey: string; icon: typeof SlidersHorizontal }> = [ + // Background is a SECTION of this facet now, not a facet of its own — see + // VideoEffectsPane for why the split had nowhere to sit. { id: "effects", labelKey: "effects.title", icon: SlidersHorizontal }, { id: "layout", labelKey: "layout.title", icon: LayoutIcon }, { id: "audio", labelKey: "audio.title", icon: AudioLines }, @@ -1062,7 +1054,6 @@ function FacetBody({ ); - if (facet === "background") return wrap(collapse, ); if (facet === "effects") return wrap(collapse, ); if (facet === "layout") return wrap(collapse, ); if (facet === "audio") return wrap(collapse, ); diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 664131e1d..a185fcdf4 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -74,6 +74,8 @@ "shadow": "ظل", "roundness": "الاستدارة", "padding": "المسافة البادئة", + "frame": "الإطار", + "motion": "الحركة", "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو." }, "background": { diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 4569adbba..83cec83a3 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -80,6 +80,8 @@ "shadow": "Shadow", "roundness": "Roundness", "padding": "Padding", + "frame": "Frame", + "motion": "Motion", "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video." }, "background": { diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 1386e1e1d..0536d1846 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -73,6 +73,8 @@ "shadow": "Sombra", "roundness": "Redondez", "padding": "Relleno", + "frame": "Marco", + "motion": "Movimiento", "on": "activado", "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo." }, diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index ad3dc0dff..31dff449b 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -73,6 +73,8 @@ "shadow": "Ombre", "roundness": "Arrondi", "padding": "Marge", + "frame": "Cadre", + "motion": "Mouvement", "on": "activé", "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo." }, diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 206d40bbb..b4b519fc2 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -74,6 +74,8 @@ "shadow": "Ombra", "roundness": "Arrotondamento", "padding": "Spaziatura", + "frame": "Cornice", + "motion": "Movimento", "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video." }, "background": { diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 8d999dc79..8eae995c7 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -73,6 +73,8 @@ "shadow": "影", "roundness": "丸み", "padding": "余白", + "frame": "フレーム", + "motion": "モーション", "on": "オン", "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。" }, diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index cbbc77ada..75cb2584d 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -74,6 +74,8 @@ "shadow": "그림자", "roundness": "모서리 둥글기", "padding": "여백", + "frame": "프레임", + "motion": "모션", "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백." }, "background": { diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 68a4112c3..54cf659af 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -74,6 +74,8 @@ "shadow": "Sombra", "roundness": "Arredondamento", "padding": "Espaçamento", + "frame": "Moldura", + "motion": "Movimento", "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo." }, "background": { diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index a6798b666..829fb2cf0 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -74,6 +74,8 @@ "shadow": "Тень", "roundness": "Скругление", "padding": "Отступ", + "frame": "Рамка", + "motion": "Движение", "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео." }, "background": { diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index c2ab5477a..57668b5d3 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -73,6 +73,8 @@ "shadow": "Gölge", "roundness": "Yuvarlaklık", "padding": "Dolgu", + "frame": "Çerçeve", + "motion": "Hareket", "on": "açık", "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk." }, diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index d65c801f1..aa2599571 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -73,6 +73,8 @@ "shadow": "Bóng đổ", "roundness": "Độ bo tròn", "padding": "Phần đệm", + "frame": "Khung", + "motion": "Chuyển động", "on": "bật", "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video." }, diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 3fd503d24..7050a58b8 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -73,6 +73,8 @@ "shadow": "阴影", "roundness": "圆角", "padding": "内边距", + "frame": "画框", + "motion": "运动", "on": "开", "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。" }, diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 4a124525d..6e8f63cc9 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -74,6 +74,8 @@ "shadow": "陰影", "roundness": "圓角", "padding": "內邊距", + "frame": "外框", + "motion": "動態", "on": "開", "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。" }, diff --git a/technical-documentation/architecture/decisions.md b/technical-documentation/architecture/decisions.md index e6cd04614..96d97bff1 100644 --- a/technical-documentation/architecture/decisions.md +++ b/technical-documentation/architecture/decisions.md @@ -54,5 +54,6 @@ behaviour lives now. | `Titlebar.tsx`, `Bottombar.tsx` | `src/components/ai-edition/v4/EditorTopBar.tsx` | | `RightPanelStack.tsx` | `src/components/ai-edition/v4/FloatingInspector.tsx` | | `TranscriptEditor.tsx` | `src/components/ai-edition/CaptionsPane.tsx` + `src/lib/ai-edition/captions/` | +| `BackgroundPane`, and the `"background"` inspector facet | the **Background** section of `VideoEffectsPane` (`src/components/ai-edition/RightPanes.tsx`). Four of the five "effects" controls were background controls — the blur blurs it, the shadow falls on it, roundness and padding exist to let it show through — so the split put the answer to "how do I remove the background" in the tab that doesn't say background ([#84](https://github.com/getopenscreen/openscreen/issues/84)). | | The browser-based exporter | the native compositor export path — see [export-pipeline.md](export-pipeline.md) | | CTranslate2 speech-to-text | whisper.cpp — see [transcription-and-captions.md](transcription-and-captions.md) | diff --git a/technical-documentation/architecture/editor-shell.md b/technical-documentation/architecture/editor-shell.md index eed79f8f6..6b9b549c1 100644 --- a/technical-documentation/architecture/editor-shell.md +++ b/technical-documentation/architecture/editor-shell.md @@ -38,7 +38,7 @@ flowchart TD Stage -- "mode === 'media'" --> Media Stage -- "mode === 'rec'" --> Rec - Inspector -- "FacetBody" --> RightPanes["BackgroundPane / VideoEffectsPane /
LayoutPane / CursorPane /
TranscriptPane / CaptionsPane
(RightPanes.tsx · CaptionsPane.tsx)"] + Inspector -- "FacetBody" --> RightPanes["VideoEffectsPane / LayoutPane /
AudioPane / CursorPane /
TranscriptPane / CaptionsPane
(RightPanes.tsx · CaptionsPane.tsx)"] ``` The shell is the single owner of mode, transport (`playing`/`currentTimeSec` come @@ -84,17 +84,17 @@ export type EditorMode = "media" | "edit" | "rec"; ### Facet (`v4/FloatingInspector.tsx:57`) ```ts -export type Facet = "background" | "effects" | "layout" | "cursor" | "captions" | "transcript"; +export type Facet = "effects" | "layout" | "audio" | "cursor" | "captions" | "transcript"; ``` | Facet | Body component | Purpose | |---|---|---| -| `"background"` | `BackgroundPane` (`src/components/ai-edition/RightPanes.tsx:178`) | Wallpaper, shadow intensity, blur, motion blur, corner radius, padding — all read out of `document.legacyEditor`. | -| `"effects"` | `VideoEffectsPane` (`RightPanes.tsx:1218`) | Per-clip / per-document video effects that aren't zoom / speed / annotation (cursor zoom, etc.). | -| `"layout"` | `LayoutPane` (`RightPanes.tsx:1376`) | Webcam layout (PiP / side / full / off), mask shape, mirroring — all also from `legacyEditor`. | -| `"cursor"` | `CursorPane` (`RightPanes.tsx:1544`) | Cursor smoothing, theme, click ring, halo. | +| `"effects"` | `VideoEffectsPane` (`src/components/ai-edition/RightPanes.tsx`) | Everything that shapes the composition, in three sections: **Background** (a swatch trigger opening the wallpaper / colour / gradient picker in a popover, plus the background blur), **Frame** (shadow, roundness, padding) and **Motion** (motion blur). All read out of `document.legacyEditor`. The picker floats so the frame sliders stay above the fold — inline, its 18-swatch grid alone was two thirds of the pane's height. | +| `"layout"` | `LayoutPane` (`RightPanes.tsx`) | Webcam layout (PiP / side / full / off), mask shape, mirroring — all also from `legacyEditor`. | +| `"audio"` | `AudioPane` (`RightPanes.tsx`) | Output gain. | +| `"cursor"` | `CursorPane` (`RightPanes.tsx`) | Cursor smoothing, theme, click ring, halo. | | `"captions"` | `CaptionsPane` (`CaptionsPane.tsx`) | Caption appearance (font, size, background, animation) and translations. The pane owns the `transcribe` action — it's the only place that runs it from the shell. | -| `"transcript"` | `TranscriptPane` (`RightPanes.tsx:475`) | Editable view of the transcript words / segments. Writes back to the document. | +| `"transcript"` | `TranscriptPane` (`RightPanes.tsx`) | Editable view of the transcript words / segments. Writes back to the document. | Selecting a region on the timeline supersedes the current facet body: the inspector opens (if it was closed) and renders the `SelectionPane` for that From 1d4c8b4beea5d2f6a06a55ebe7ed44717b5dcbb5 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 21 Aug 2026 23:20:41 +0200 Subject: [PATCH 02/13] feat(editor): one switch for "no background", derived from the four settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #84 asks for a way to turn the background off. The honest answer was four settings in three places — padding, roundness and shadow to zero, plus an aspect ratio matching the footage — which is why nobody found it. Merging the facets put all four in one pane; this puts them behind one switch. It is DERIVED, not stored. "No background" is a shape those four settings are in, not a fifth setting alongside them, so `fillsFrame()` reads it back instead of persisting a flag that could disagree with them: nudge any slider and the switch turns itself off, with no schema change and nothing to migrate. The aspect ratio is part of the test rather than an afterthought. Padding 0 only fills the WIDTH — a 16:10 capture in a 16:9 project keeps its wallpaper bars at zero padding, which is exactly why the issue reads as unfixable to someone who already found the slider. The target shape comes from collectNativeFormats(), the same source the ratio picker's ORIGINAL section reads, already sorted by clip count then pixel area, so the two can never disagree. Switching off restores the shipped defaults rather than a remembered "before": a hidden slot only one control writes is state that rots, and every value it restores is one slider away. The ratio is left alone — wanting the background back is not asking for the output to be reframed. Toggle gains an optional aria-label; it renders no text of its own, so the switch had no accessible name. Refs #84 Co-Authored-By: Claude Opus 5 --- src/components/ai-edition/RightPanes.tsx | 99 ++++++++++++++++++++- src/components/ai-edition/fillFrame.test.ts | 76 ++++++++++++++++ src/i18n/locales/ar/settings.json | 1 + src/i18n/locales/en/settings.json | 1 + src/i18n/locales/es/settings.json | 1 + src/i18n/locales/fr/settings.json | 1 + src/i18n/locales/it/settings.json | 1 + src/i18n/locales/ja-JP/settings.json | 1 + src/i18n/locales/ko-KR/settings.json | 1 + src/i18n/locales/pt-BR/settings.json | 1 + src/i18n/locales/ru/settings.json | 1 + src/i18n/locales/tr/settings.json | 1 + src/i18n/locales/vi/settings.json | 1 + src/i18n/locales/zh-CN/settings.json | 1 + src/i18n/locales/zh-TW/settings.json | 1 + 15 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 src/components/ai-edition/fillFrame.test.ts diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 988ad0e18..548fea944 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -37,6 +37,7 @@ import defaultCursorPreviewUrl from "@/assets/cursors/Cursor=Default.svg"; import GradientEditor, { type GradientEditorState } from "@/components/ui/gradient-editor"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useScopedT } from "@/contexts/I18nContext"; +import { collectNativeFormats } from "@/lib/ai-edition/document/outputFormat"; import type { AxcutAsset, AxcutClip, @@ -44,7 +45,12 @@ import type { AxcutTrimRange, AxcutWord, } from "@/lib/ai-edition/schema"; -import { AUDIO_GAIN_DB_LIMIT } from "@/lib/ai-edition/store/editorSettings"; +import { + AUDIO_GAIN_DB_LIMIT, + DEFAULT_EDITOR_SETTINGS, + type EditorSettingsPatch, + type EditorSettingsSnapshot, +} from "@/lib/ai-edition/store/editorSettings"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import { @@ -71,6 +77,7 @@ import { WALLPAPER_THUMB_PATHS, } from "@/lib/wallpaper"; import { isNativeCompositorActive, setNativeParam } from "@/native"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; import styles from "./NewEditorShell.module.css"; interface PaneProps { @@ -1439,6 +1446,57 @@ function restoreCaretBeforeWord(editor: HTMLElement | null, wordId: string): voi // pulling the schema into the helpers block. export type { AxcutWord }; +// ─── Fill frame ──────────────────────────────────────────────────── + +/** + * Does the recording cover the whole output frame — i.e. is no background visible? + * + * DERIVED, never stored. "No background" is a fact about four settings, not a fifth setting + * alongside them, and storing it would immediately be able to disagree with them: nudge the + * padding slider and a stored flag still claims the background is off. Reading it back keeps + * the toggle honest for free, needs no schema change, and survives a reload. + * + * The aspect ratio is part of the test and not an afterthought — padding 0 only fills the + * WIDTH. A 16:10 capture in a 16:9 project still shows wallpaper bars top and bottom at zero + * padding, which is exactly why #84 reads as unfixable to someone who found the slider. + */ +export function fillsFrame( + settings: Pick< + EditorSettingsSnapshot, + "padding" | "borderRadius" | "shadowIntensity" | "aspectRatio" + >, + nativeTokens: ReadonlySet, +): boolean { + return ( + settings.padding === 0 && + settings.borderRadius === 0 && + settings.shadowIntensity === 0 && + nativeTokens.has(settings.aspectRatio) + ); +} + +/** + * The patch behind the toggle. + * + * Turning it OFF restores the shipped defaults rather than whatever the user had before: + * "before" would have to be stored somewhere, and a hidden slot that only one control writes + * is the kind of state that rots. Predictable beats clever here — and every value it restores + * is one slider away. + * + * `aspectRatio` is deliberately absent from the OFF patch. Turning the background back on does + * not mean the user wanted their output reframed; the three frame values alone bring it back. + */ +export function fillFramePatch(on: boolean, nativeToken: AspectRatio): EditorSettingsPatch { + if (!on) { + return { + padding: DEFAULT_EDITOR_SETTINGS.padding, + borderRadius: DEFAULT_EDITOR_SETTINGS.borderRadius, + shadowIntensity: DEFAULT_EDITOR_SETTINGS.shadowIntensity, + }; + } + return { padding: 0, borderRadius: 0, shadowIntensity: 0, aspectRatio: nativeToken }; +} + // ─── Video Effects ───────────────────────────────────────────────── /** @@ -1456,7 +1514,15 @@ export type { AxcutWord }; */ export function VideoEffectsPane() { const ts = useScopedT("settings"); - const { settings, setLive, commit, hasDocument } = useEditorSettings(); + const { settings, set, setLive, commit, hasDocument } = useEditorSettings(); + const document = useProjectStore((s) => s.document); + + // Same source the ratio picker reads, so "fill frame" and the ORIGINAL section of that menu + // can never disagree about what shape the footage is. Already sorted by clip count then by + // pixel area, so [0] is "the shape most of this timeline is in" with no heuristic of ours. + const nativeFormats = useMemo(() => (document ? collectNativeFormats(document) : []), [document]); + const nativeTokens = useMemo(() => new Set(nativeFormats.map((f) => f.token)), [nativeFormats]); + const frameIsFilled = fillsFrame(settings, nativeTokens); // Le rayon natif = rayon de base de la fixture (~24px @1920) × cette échelle. Diviser la // valeur px de l'UI par ce même rayon de base fait que le coin natif ≈ les px affichés @@ -1467,6 +1533,16 @@ export function VideoEffectsPane() { // un effet de montage ici ne poussait rien tant que ce panneau precis n'avait pas // ete ouvert. Les handlers par controle ci-dessous poussent toujours leurs diffs. + const toggleFillFrame = (on: boolean) => { + const patch = fillFramePatch(on, nativeFormats[0]?.token ?? settings.aspectRatio); + void set(patch); + if (isNativeCompositorActive()) { + setNativeParam("padding", (patch.padding ?? 0) / 100); + setNativeParam("roundness", (patch.borderRadius ?? 0) / NATIVE_SCREEN_BASE_RADIUS_PX); + setNativeParam("shadow", patch.shadowIntensity ?? 0); + } + }; + return (
{ts("effects.frame")}
+ {/* #84: "how do I turn the background off". The honest answer was four settings in + three places, so nobody found it. This is that answer as one control — and it + stays a derived view of those settings, so moving any slider below simply turns + it back off rather than leaving a lying switch behind. Disabled without a + timeline: there is no footage whose shape we could fill to. */} +
+ {ts("effects.fillFrame")} + +
void; }) { return ( @@ -2051,6 +2145,7 @@ export function Toggle({ type="button" className={`${styles.toggle} ${checked ? styles.isOn : ""}`} aria-pressed={checked} + aria-label={ariaLabel} disabled={disabled} onClick={() => onChange(!checked)} /> diff --git a/src/components/ai-edition/fillFrame.test.ts b/src/components/ai-edition/fillFrame.test.ts new file mode 100644 index 000000000..5afeafa59 --- /dev/null +++ b/src/components/ai-edition/fillFrame.test.ts @@ -0,0 +1,76 @@ +// "Turn the background off" (#84) is not a setting — it is a shape four settings can be in. +// These pin that reading, because the failure mode is silent: a predicate that is too lax +// shows the toggle ON while wallpaper is still visible in the letterbox bars, which is the +// exact confusion the issue reports. + +import { describe, expect, it } from "vitest"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import { fillFramePatch, fillsFrame } from "./RightPanes"; + +const FILLED = { + padding: 0, + borderRadius: 0, + shadowIntensity: 0, + aspectRatio: "16:10" as AspectRatio, +}; +const NATIVE = new Set(["16:10"]); + +describe("fillsFrame reads the four settings, not a stored flag", () => { + it("is true when nothing can show through", () => { + expect(fillsFrame(FILLED, NATIVE)).toBe(true); + }); + + it.each([ + ["padding", { padding: 1 }], + ["roundness", { borderRadius: 0.5 }], + ["shadow", { shadowIntensity: 0.01 }], + ])("is false as soon as the %s lets the background back in", (_label, override) => { + expect(fillsFrame({ ...FILLED, ...override }, NATIVE)).toBe(false); + }); + + it("is false when the output shape is not the footage's, however zeroed the frame is", () => { + // The whole reason #84 reads as unfixable to someone who already found the padding + // slider: a 16:10 capture in a 16:9 project keeps its wallpaper bars at padding 0, + // because zero padding only fills the WIDTH. + expect(fillsFrame({ ...FILLED, aspectRatio: "16:9" as AspectRatio }, NATIVE)).toBe(false); + }); + + it("accepts any of the timeline's shapes, not just the first", () => { + const mixed = new Set(["16:9", "9:16"]); + expect(fillsFrame({ ...FILLED, aspectRatio: "9:16" as AspectRatio }, mixed)).toBe(true); + }); + + it("is false for a timeline whose shapes are unknown", () => { + // No clips probed yet — there is nothing to fill, so the toggle must not read ON. + expect(fillsFrame(FILLED, new Set())).toBe(false); + }); +}); + +describe("fillFramePatch", () => { + it("zeroes the three frame values and adopts the footage's shape", () => { + expect(fillFramePatch(true, "16:10")).toEqual({ + padding: 0, + borderRadius: 0, + shadowIntensity: 0, + aspectRatio: "16:10", + }); + }); + + it("round-trips: what it turns on, fillsFrame reads as on", () => { + const patched = { ...FILLED, ...fillFramePatch(true, "16:10") }; + expect(fillsFrame(patched, NATIVE)).toBe(true); + }); + + it("restores the shipped defaults when switched off", () => { + const off = fillFramePatch(false, "16:10"); + expect(off.padding).toBeGreaterThan(0); + expect(off.borderRadius).toBeGreaterThan(0); + expect(off.shadowIntensity).toBeGreaterThan(0); + }); + + it("leaves the aspect ratio alone when switched off", () => { + // Wanting the background back is not asking for the output to be reframed, and the + // three frame values alone bring it back. + expect(fillFramePatch(false, "16:10")).not.toHaveProperty("aspectRatio"); + }); +}); diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index a185fcdf4..4a139e1e9 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -75,6 +75,7 @@ "roundness": "الاستدارة", "padding": "المسافة البادئة", "frame": "الإطار", + "fillFrame": "ملء الإطار", "motion": "الحركة", "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو." }, diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 83cec83a3..1de8adcfe 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -81,6 +81,7 @@ "roundness": "Roundness", "padding": "Padding", "frame": "Frame", + "fillFrame": "Fill frame", "motion": "Motion", "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video." }, diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 0536d1846..52208d6d1 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -74,6 +74,7 @@ "roundness": "Redondez", "padding": "Relleno", "frame": "Marco", + "fillFrame": "Llenar el marco", "motion": "Movimiento", "on": "activado", "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo." diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 31dff449b..e62aac1ed 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -74,6 +74,7 @@ "roundness": "Arrondi", "padding": "Marge", "frame": "Cadre", + "fillFrame": "Plein cadre", "motion": "Mouvement", "on": "activé", "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo." diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index b4b519fc2..f2b79fa9d 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -75,6 +75,7 @@ "roundness": "Arrotondamento", "padding": "Spaziatura", "frame": "Cornice", + "fillFrame": "Riempi la cornice", "motion": "Movimento", "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video." }, diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 8eae995c7..066b21cc1 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -74,6 +74,7 @@ "roundness": "丸み", "padding": "余白", "frame": "フレーム", + "fillFrame": "フレームいっぱい", "motion": "モーション", "on": "オン", "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。" diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 75cb2584d..3114bad4c 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -75,6 +75,7 @@ "roundness": "모서리 둥글기", "padding": "여백", "frame": "프레임", + "fillFrame": "프레임 채우기", "motion": "모션", "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백." }, diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 54cf659af..e074edc6c 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -75,6 +75,7 @@ "roundness": "Arredondamento", "padding": "Espaçamento", "frame": "Moldura", + "fillFrame": "Preencher a moldura", "motion": "Movimento", "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo." }, diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 829fb2cf0..8c500fd0a 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -75,6 +75,7 @@ "roundness": "Скругление", "padding": "Отступ", "frame": "Рамка", + "fillFrame": "Во весь кадр", "motion": "Движение", "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео." }, diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 57668b5d3..36c5687fa 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -74,6 +74,7 @@ "roundness": "Yuvarlaklık", "padding": "Dolgu", "frame": "Çerçeve", + "fillFrame": "Çerçeveyi doldur", "motion": "Hareket", "on": "açık", "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk." diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index aa2599571..aeffb8a4b 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -74,6 +74,7 @@ "roundness": "Độ bo tròn", "padding": "Phần đệm", "frame": "Khung", + "fillFrame": "Lấp đầy khung", "motion": "Chuyển động", "on": "bật", "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video." diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 7050a58b8..b6a2ad67d 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -74,6 +74,7 @@ "roundness": "圆角", "padding": "内边距", "frame": "画框", + "fillFrame": "填满画框", "motion": "运动", "on": "开", "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。" diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 6e8f63cc9..3aec2f390 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -75,6 +75,7 @@ "roundness": "圓角", "padding": "內邊距", "frame": "外框", + "fillFrame": "填滿外框", "motion": "動態", "on": "開", "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。" From f35499bb1725638a9ce503fcd02f23d5f249102a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 21 Aug 2026 23:36:38 +0200 Subject: [PATCH 03/13] fix(editor): size the background picker to the room it has, not to a guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gradient tab lost its editor. Not to a render bug — the markup was intact and the element was in the DOM — but to the popover's `max-height: min(70vh, 460px)`. That tab is ~640px (tabs + preset grid + the 240px-wide editor, whose colour wheel is square), so 460px cut the wheel in half and left the rest below a scrollbar nobody thinks to look for. It reads as "the gradient panel is gone". 460px was a number picked at authoring time against content I had only measured without the editor, which needs a document to render and so was absent from the harness. Radix already measures the room actually left below the trigger and publishes it as --radix-popover-content-available-height; using that removes the guess. The remaining 680px term is a real ceiling — beyond the gradient tab's own height there is nothing left to reveal. collisionPadding keeps the picker off the window edge, and is the same padding Radix subtracts from the available height. Measured: 1512x982 renders 640px with nothing clipped and no scroll; 1280x720 caps at the 478px actually available, scrolls, and stays on screen. Co-Authored-By: Claude Opus 5 --- src/components/ai-edition/NewEditorShell.module.css | 10 +++++++--- src/components/ai-edition/RightPanes.tsx | 3 +++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index 4afd78cc3..b3855eed3 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -1086,9 +1086,13 @@ border-radius: 12px; background: var(--surface-1); box-shadow: var(--elev-pop); - /* Tall content (18 swatches, or the gradient editor) scrolls itself instead of running - off-screen on a short window. */ - max-height: min(70vh, 460px); + /* Radix measures the room actually left below the trigger and publishes it here, so the + picker uses the screen it has instead of a number guessed at authoring time. The second + term is the tallest it ever needs: the gradient tab is ~640px (tabs + preset grid + the + 240px-wide editor, whose wheel is square). A fixed 460px cap cut that editor in half and + read as "the gradient panel is gone" — the content was there, below the fold, behind a + scroll nobody could see. Beyond 680px there is nothing left to reveal. */ + max-height: min(var(--radix-popover-content-available-height, 80vh), 680px); overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 548fea944..1deb32a52 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -316,6 +316,9 @@ function BackgroundSection() { From 524273b47f0258e78f3c0a2183ff0a213125abc9 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 22 Aug 2026 00:08:45 +0200 Subject: [PATCH 04/13] fix(editor): make the picker's tabs real tabs, and its swatch unable to throw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from review on #451, both introduced by the picker moving into a popover. The tab strip carried role="tablist" over three plain buttons, so a screen reader announced three buttons and never which one was current. role="tab" plus aria-selected is what makes the tablist mean anything. backgroundSwatchStyle could take the pane down during render. resolveImageWallpaperUrl THROWS for an image path outside /wallpapers/ — a deliberate guard against loading arbitrary files. The swatch grid only ever fed it constants, but the collapsed trigger feeds it whatever the document holds, so one project saved by an older build with a path we no longer allow would have thrown mid-render instead of drawing a dull square. It now falls back to one. The review's third point (the facet list in editor-shell.md) was already fixed in b74285d5; it reviewed the file as of that commit's parent. Co-Authored-By: Claude Opus 5 --- src/components/ai-edition/RightPanes.tsx | 26 ++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 1deb32a52..ef1369540 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -323,9 +323,14 @@ function BackgroundSection() { className="w-auto border-0 bg-transparent p-0 shadow-none" >
+ {/* role="tab" + aria-selected are what make the tablist above mean + anything: without them a screen reader announces three plain + buttons and never says which one is current. */}
+ {frameIsFilled && clipsStillFramed > 0 ? ( +
+ {ts("effects.fillFrameMixed")} +
+ ) : null}
{ expect(fillsFrame({ ...FILLED, aspectRatio: "9:16" as AspectRatio }, mixed)).toBe(true); }); + it("is a claim about the SETTINGS, not about every clip on a mixed timeline", () => { + // Deliberate, and the pane says so out loud: one output frame cannot be filled by + // clips of different shapes, because the screen path contain-fits. With 16:9 and 9:16 + // on the timeline, whichever shape is chosen leaves the other letterboxed — so this + // reads true while background is still visible on some clips, and `clipsStillFramed` + // in VideoEffectsPane is what stops that from being a silent lie. + const mixed = new Set(["16:9", "9:16"]); + expect(fillsFrame({ ...FILLED, aspectRatio: "16:9" as AspectRatio }, mixed)).toBe(true); + expect(fillsFrame({ ...FILLED, aspectRatio: "9:16" as AspectRatio }, mixed)).toBe(true); + }); + it("is false for a timeline whose shapes are unknown", () => { // No clips probed yet — there is nothing to fill, so the toggle must not read ON. expect(fillsFrame(FILLED, new Set())).toBe(false); diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 4a139e1e9..e1a9eebf0 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -76,6 +76,7 @@ "padding": "المسافة البادئة", "frame": "الإطار", "fillFrame": "ملء الإطار", + "fillFrameMixed": "المقاطع ذات الشكل المختلف لا تزال تُظهر الخلفية.", "motion": "الحركة", "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو." }, diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 1de8adcfe..f4c16ca36 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -82,6 +82,7 @@ "padding": "Padding", "frame": "Frame", "fillFrame": "Fill frame", + "fillFrameMixed": "Clips in another shape still show the background.", "motion": "Motion", "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video." }, diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 52208d6d1..c0736ddad 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -75,6 +75,7 @@ "padding": "Relleno", "frame": "Marco", "fillFrame": "Llenar el marco", + "fillFrameMixed": "Los clips con otra forma siguen mostrando el fondo.", "motion": "Movimiento", "on": "activado", "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo." diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index e62aac1ed..4fd9c4a08 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -75,6 +75,7 @@ "padding": "Marge", "frame": "Cadre", "fillFrame": "Plein cadre", + "fillFrameMixed": "Les clips d'une autre forme affichent encore le fond.", "motion": "Mouvement", "on": "activé", "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo." diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index f2b79fa9d..e5da5b5a7 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -76,6 +76,7 @@ "padding": "Spaziatura", "frame": "Cornice", "fillFrame": "Riempi la cornice", + "fillFrameMixed": "Le clip con un'altra forma mostrano ancora lo sfondo.", "motion": "Movimento", "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video." }, diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 066b21cc1..ee0246775 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -75,6 +75,7 @@ "padding": "余白", "frame": "フレーム", "fillFrame": "フレームいっぱい", + "fillFrameMixed": "別の形状のクリップには背景が残ります。", "motion": "モーション", "on": "オン", "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。" diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 3114bad4c..b105588e2 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -76,6 +76,7 @@ "padding": "여백", "frame": "프레임", "fillFrame": "프레임 채우기", + "fillFrameMixed": "다른 비율의 클립에는 배경이 그대로 남습니다.", "motion": "모션", "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백." }, diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index e074edc6c..550392d35 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -76,6 +76,7 @@ "padding": "Espaçamento", "frame": "Moldura", "fillFrame": "Preencher a moldura", + "fillFrameMixed": "Clipes com outro formato ainda mostram o fundo.", "motion": "Movimento", "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo." }, diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 8c500fd0a..a704885ad 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -76,6 +76,7 @@ "padding": "Отступ", "frame": "Рамка", "fillFrame": "Во весь кадр", + "fillFrameMixed": "Клипы другой формы по-прежнему показывают фон.", "motion": "Движение", "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео." }, diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 36c5687fa..4e0e7278e 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -75,6 +75,7 @@ "padding": "Dolgu", "frame": "Çerçeve", "fillFrame": "Çerçeveyi doldur", + "fillFrameMixed": "Başka bir orandaki klipler arka planı göstermeye devam eder.", "motion": "Hareket", "on": "açık", "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk." diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index aeffb8a4b..a5b68fac3 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -75,6 +75,7 @@ "padding": "Phần đệm", "frame": "Khung", "fillFrame": "Lấp đầy khung", + "fillFrameMixed": "Các clip có tỉ lệ khác vẫn hiển thị nền.", "motion": "Chuyển động", "on": "bật", "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video." diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index b6a2ad67d..fc7b87c3f 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -75,6 +75,7 @@ "padding": "内边距", "frame": "画框", "fillFrame": "填满画框", + "fillFrameMixed": "其他比例的片段仍会显示背景。", "motion": "运动", "on": "开", "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。" diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 3aec2f390..dc3f01c1a 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -76,6 +76,7 @@ "padding": "內邊距", "frame": "外框", "fillFrame": "填滿外框", + "fillFrameMixed": "其他比例的片段仍會顯示背景。", "motion": "動態", "on": "開", "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。" From 65d174e36fe1085957d892c9a0d26bfadb825d23 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 22 Aug 2026 00:37:31 +0200 Subject: [PATCH 06/13] docs: fix four stale references in editor-shell.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #451 was right and I closed it too fast. I checked the union and the table — the lines I had edited — saw them correct, and called the finding already-fixed. It pointed at two OTHER lines in the same file, which I never looked at. - the Floating inspector row still listed the facets as background / effects / layout / cursor / captions / transcript: the removed facet named, audio missing, contradicting the union twenty lines below - CaptionsPane cited FloatingInspector.tsx:1077, past the end of a 1072-line file; the FacetBody routing is at :1062 Two more in the same paragraphs, found while checking those: SelectionPane is at :434 not :444, and the mode/facet state is at :112/:121 not :75/:84. check-docs.mjs validates content and links, not line numbers, so nothing here is enforced — these only stay true if they are re-checked whenever the target moves. Co-Authored-By: Claude Opus 5 --- technical-documentation/architecture/editor-shell.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/technical-documentation/architecture/editor-shell.md b/technical-documentation/architecture/editor-shell.md index 6b9b549c1..c2efb4297 100644 --- a/technical-documentation/architecture/editor-shell.md +++ b/technical-documentation/architecture/editor-shell.md @@ -57,16 +57,16 @@ of those are local React state, with the document itself read through | **Stage — Media mode** | `src/components/ai-edition/v4/MediaStage.tsx` | Searches, adds, regenerates transcripts for the assets in the project. The variant that the timeline shows in this mode is "media" (timeline height, no lanes). | | **Stage — Rec mode** | `src/components/ai-edition/v4/RecStage.tsx` | Pre-flight config for a new recording — mic / camera / system audio / cursor capture mode — then hands off to the standalone recorder HUD window when the user hits record. | | **Bottom timeline** | `src/components/ai-edition/v4/V4Timeline.tsx` | Renders the clips, the ruler, and the five lanes (`annPills`, `speedPills`, `trimPills`, `zoomPills`, `cameraFullscreenPills`, computed at `:321-363`). Owns transport (play / prev / next / loop), zoom/pan, scrub, drag-and-drop of asset cards, the "smart zooms + cuts" AI prompt, and resize/move/delete of every pill. Pills render through `coalesceRegionsForRuler` and `coalescedTrimGroups` so what the user sees is exactly what the rules in [timeline-model.md](timeline-model.md) describe. | -| **Floating inspector** | `src/components/ai-edition/v4/FloatingInspector.tsx` | Floating facet rail over the stage; the open panel either shows the `FacetBody` for the current facet (`background` / `effects` / `layout` / `cursor` / `captions` / `transcript`) or, when a region is selected, a `SelectionPane` (`:444`) that edits the selected pill by id. The "pencil" rail button opens `EditClipModal` for crop + trim. | +| **Floating inspector** | `src/components/ai-edition/v4/FloatingInspector.tsx` | Floating facet rail over the stage; the open panel either shows the `FacetBody` for the current facet (`effects` / `layout` / `audio` / `cursor` / `captions` / `transcript`) or, when a region is selected, a `SelectionPane` (`:434`) that edits the selected pill by id. The "pencil" rail button opens `EditClipModal` for crop + trim. | | **Left chat column** | `src/components/ai-edition/LeftPanel.tsx` | Only mounted when `mode === "edit"` and `chatOpen` is true (`NewEditorShell.tsx` `:1133-1151`). Sends user messages to the LLM via IPC. Resize handle is `v4.chatResizeHandle`; width persists in `localStorage` as `os-editor-chat-width`. | | **Modals** | `src/components/ai-edition/Modals.tsx` | `OpenProjectModal`, `NewProjectModal`, `EditClipModal` (per-clip crop + in/out), `UnsavedChangesModal`. Mounted at the shell level (`:1286-1332`) so every trigger site reuses the same instance. | | **Export dialog** | `src/components/ai-edition/ExportDialog.tsx` | Format / quality / frame-rate / codec / size; calls `exportAxcutDocument` (GIF path, WebCodecs) or `exportMultiNative` (MP4 path, native D3D compositor). The MP4 path is the one that goes through `src/lib/ai-edition/exporter/documentExporter.ts`'s `projectRegionsToSourceTime` and the multi-clip native bridge. | -| **Captions pane** | `src/components/ai-edition/CaptionsPane.tsx` | Mounted as a facet body from `FloatingInspector.tsx` (`:1077`). Controls caption appearance + translations; the cues themselves are a derived view over `document.transcripts` (see [`src/lib/ai-edition/captions/`](../../src/lib/ai-edition/captions/)). | +| **Captions pane** | `src/components/ai-edition/CaptionsPane.tsx` | Mounted as a facet body from `FloatingInspector.tsx` (`:1062`). Controls caption appearance + translations; the cues themselves are a derived view over `document.transcripts` (see [`src/lib/ai-edition/captions/`](../../src/lib/ai-edition/captions/)). | ## Modes and facets -`mode` is local React state in `NewEditorShell` (`:75`); `facet` is local React -state at `:84`. Both are exported as string unions from the components that +`mode` is local React state in `NewEditorShell` (`:112`); `facet` is local React +state at `:121`. Both are exported as string unions from the components that introduce them. ### EditorMode (`v4/EditorTopBar.tsx:20`) From c985cfd84f55b78fc0c74363d356fe6410a406de Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 22 Aug 2026 00:52:49 +0200 Subject: [PATCH 07/13] feat(editor): let the user pick which shape "fill frame" fills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit told the user that clips in another shape still show the background, and left it there. Telling someone about a decision made for them is not the same as letting them make it: the shape was whichever `collectNativeFormats` sorted first — the majority — so a project that is mostly landscape with two portrait inserts could never be made to fill on the portrait ones. The information was honest and the control was still missing. So when the timeline has more than one shape, the switch grows a chooser: one chip per distinct shape, each carrying its clip count, active one highlighted. Picking one writes that aspect ratio. The majority stays the default on activation — it is the right guess — it is just no longer the only answer. The counts make the trade visible, which is the same information the ratio menu's ORIGINAL section already shows, in the place where the decision is now made. The caveat stays under the chooser, because choosing a shape does not make the others fill; it decides which ones do. Tested against a real two-shape document — the case neither the browser harness (no project) nor the other tests (no timeline) could reach. Refs #84 Co-Authored-By: Claude Opus 5 --- .../ai-edition/NewEditorShell.module.css | 7 ++ src/components/ai-edition/RightPanes.tsx | 30 ++++- .../ai-edition/fillFrameShapes.test.tsx | 115 ++++++++++++++++++ 3 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 src/components/ai-edition/fillFrameShapes.test.tsx diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index b5c5910d9..ea4249683 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -987,6 +987,13 @@ color: var(--fg-2); margin: 4px var(--sp-4) 8px; } +/* Clip count riding along a shape chip — present, but never louder than the shape. */ +.tabCount { + margin-left: 5px; + opacity: 0.6; + font-variant-numeric: tabular-nums; +} + /* Caveat under a control, when the control is true but not the whole story. */ .paneHint { margin: -2px var(--sp-4) 8px; diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 6cae93bc3..d3c000a45 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -1598,10 +1598,32 @@ export function VideoEffectsPane() { onChange={toggleFillFrame} />
- {frameIsFilled && clipsStillFramed > 0 ? ( -
- {ts("effects.fillFrameMixed")} -
+ {/* WHICH shape to fill is only a question when the timeline has more than one, and + then it is the user's to answer, not ours: picking the majority silently means a + project that is mostly landscape with two portrait inserts can never be made to + fill on the portrait ones. Each shape carries its clip count, so the trade is + visible — same information the ratio menu's ORIGINAL section shows. */} + {frameIsFilled && nativeFormats.length > 1 ? ( + <> +
+ {nativeFormats.map((format) => ( + + ))} +
+ {clipsStillFramed > 0 ? ( +
+ {ts("effects.fillFrameMixed")} +
+ ) : null} + ) : null}
({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })); + +/** A timeline whose clips carry the given pixel shapes, one clip per entry. */ +function documentWithShapes(shapes: Array<[number, number]>): AxcutDocument { + const base = createEmptyDocument({ title: "T", projectId: "p1" }); + return { + ...base, + assets: shapes.map(([width, height], i) => ({ + id: `asset_${i}`, + kind: "video" as const, + label: `Clip ${i}`, + originalPath: `/tmp/clip${i}.mp4`, + durationSec: 10, + cameraTrack: null, + video: { width, height }, + })), + timeline: { + ...base.timeline, + clips: shapes.map((_, i) => ({ + id: `clip_${i}`, + assetId: `asset_${i}`, + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: i * 10, + timelineEndSec: (i + 1) * 10, + wordRefs: [], + origin: "user" as const, + reason: "", + })), + }, + // The frame already zeroed, so the switch reads ON and the choice is live. + legacyEditor: { + padding: 0, + borderRadius: 0, + shadowIntensity: 0, + aspectRatio: "16:9", + }, + } as unknown as AxcutDocument; +} + +function mount(doc: AxcutDocument) { + useProjectStore.setState({ document: doc }); + return render( + + + , + ); +} + +beforeEach(() => { + useProjectStore.setState({ document: null }); +}); +afterEach(cleanup); + +describe("a mixed timeline gets a shape to choose, not a shape imposed", () => { + it("offers every distinct shape, with the clip count behind each", () => { + // Five landscape clips and two portrait inserts: picking the majority silently would + // mean the portrait ones can never be made to fill. + mount( + documentWithShapes([ + [1920, 1080], + [1920, 1080], + [1920, 1080], + [1920, 1080], + [1920, 1080], + [1080, 1920], + [1080, 1920], + ]), + ); + + expect(screen.getByRole("button", { name: /16:9/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /9:16/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /16:9\s*5/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /9:16\s*2/ })).toBeInTheDocument(); + }); + + it("says the other shapes still show the background", () => { + mount( + documentWithShapes([ + [1920, 1080], + [1080, 1920], + ]), + ); + expect(screen.getByRole("note")).toHaveTextContent( + "Clips in another shape still show the background.", + ); + }); + + it("offers no choice when every clip is the same shape — there is nothing to arbitrate", () => { + mount( + documentWithShapes([ + [1920, 1080], + [3840, 2160], + ]), + ); + // 1920x1080 and 3840x2160 are both 16:9: one shape, so no chooser and no caveat. + expect(screen.queryByRole("note")).not.toBeInTheDocument(); + expect(screen.queryByRole("group")).not.toBeInTheDocument(); + }); +}); From 6f7c7f2afae8cc77b55217ec2d81e8395f897390 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 22 Aug 2026 01:01:35 +0200 Subject: [PATCH 08/13] feat(editor): make "fill frame" an action with a picker, not a switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip row from the previous commit does not survive contact with real projects, on two counts raised in review. A boolean has room for one outcome, and a timeline with several shapes has several — one per shape. So the switch took the majority silently, and the chips I added to fix that presented the choice as raw ratio tokens: `16:9` is readable, `683:384` and `64:27` are not, and ten of them do not fit a row. So it becomes an action button that says what is filling the frame right now, and opens a list when there is a decision to make — the shape the "edit clip" rail button already has. Rows lead with the RESOLUTION, which is what a user recognises about their own footage, keep the ratio token as secondary text for whoever reads them, and carry their clip count so the trade stays visible. The list scrolls, so ten shapes cost no more room than two. One shape means no decision to delegate: the button acts directly instead of opening a list with a single row in it. And the list carries the way back out — "restore the frame" — which the switch used to provide by being a switch. Shapes still collapse by ratio, labelled with the biggest representative, so a 1080p and a 4K clip of the same shape stay one entry. Refs #84 Co-Authored-By: Claude Opus 5 --- .../ai-edition/NewEditorShell.module.css | 67 ++++++++- src/components/ai-edition/RightPanes.tsx | 136 +++++++++++++----- .../ai-edition/fillFrameShapes.test.tsx | 39 +++-- src/i18n/locales/ar/settings.json | 3 + src/i18n/locales/en/settings.json | 3 + src/i18n/locales/es/settings.json | 3 + src/i18n/locales/fr/settings.json | 3 + src/i18n/locales/it/settings.json | 3 + src/i18n/locales/ja-JP/settings.json | 3 + src/i18n/locales/ko-KR/settings.json | 3 + src/i18n/locales/pt-BR/settings.json | 3 + src/i18n/locales/ru/settings.json | 3 + src/i18n/locales/tr/settings.json | 3 + src/i18n/locales/vi/settings.json | 3 + src/i18n/locales/zh-CN/settings.json | 3 + src/i18n/locales/zh-TW/settings.json | 3 + 16 files changed, 231 insertions(+), 50 deletions(-) diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index ea4249683..92f2790b0 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -987,12 +987,71 @@ color: var(--fg-2); margin: 4px var(--sp-4) 8px; } -/* Clip count riding along a shape chip — present, but never louder than the shape. */ -.tabCount { - margin-left: 5px; - opacity: 0.6; +/* ─── row action + its menu ───────────────────────────────────── */ +/* Sits where a Toggle would, and reads as the current answer rather than as on/off. */ +.rowAction { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--fg-2); + font: 500 var(--fs-app-sm)/1 var(--font-body); font-variant-numeric: tabular-nums; + cursor: pointer; + transition: border-color 150ms ease, color 150ms ease; +} +.rowAction:hover:not(:disabled) { border-color: var(--brand); color: var(--fg); } +.rowAction:disabled { cursor: default; opacity: 0.5; } +.actionMenu { + min-width: 216px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-1); + box-shadow: var(--elev-pop); + /* Ten shapes must cost no more room than two. */ + max-height: min(var(--radix-popover-content-available-height, 60vh), 320px); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +.actionMenuTitle { + margin: 4px 8px 6px; + font: 600 11px/1 var(--font-body); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); +} +.actionMenuRow { + display: flex; + align-items: baseline; + gap: 8px; + width: 100%; + padding: 7px 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--fg); + cursor: pointer; + text-align: left; +} +.actionMenuRow:hover { background: var(--surface-2); } +.actionMenuRow.isActive { background: color-mix(in oklab, var(--brand), transparent 82%); } +/* Resolution leads: it is the thing a user recognises about their own footage. */ +.actionMenuMain { font: 600 12.5px var(--font-display); font-variant-numeric: tabular-nums; } +/* The ratio token second, for whoever does read them — `64:27` means nothing alone. */ +.actionMenuMeta { font: 400 11px var(--font-body); color: var(--muted); } +.actionMenuCount { + margin-left: auto; + font: 500 11px var(--font-body); + font-variant-numeric: tabular-nums; + color: var(--meta); } +.actionMenuReset { border-top: 1px solid var(--border-soft); border-radius: 0 0 8px 8px; margin-top: 4px; padding-top: 10px; } +.actionMenuReset .actionMenuMain { font-weight: 500; color: var(--fg-2); } /* Caveat under a control, when the control is true but not the whole story. */ .paneHint { diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index d3c000a45..b3c93af7e 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -1548,11 +1548,15 @@ export function VideoEffectsPane() { // contain-fits (see compositeLayout.ts), so anything not in the chosen shape keeps its // bars. The switch is honest about the SETTINGS; without this it would also be read as a // claim about the picture, and be wrong on every mixed timeline. + const filledFormat = frameIsFilled + ? nativeFormats.find((f) => f.token === settings.aspectRatio) + : undefined; const clipsStillFramed = useMemo(() => { const filled = nativeFormats.find((f) => f.token === settings.aspectRatio); const total = nativeFormats.reduce((n, f) => n + f.clipCount, 0); return total - (filled?.clipCount ?? 0); }, [nativeFormats, settings.aspectRatio]); + const [fillMenuOpen, setFillMenuOpen] = useState(false); // Le rayon natif = rayon de base de la fixture (~24px @1920) × cette échelle. Diviser la // valeur px de l'UI par ce même rayon de base fait que le coin natif ≈ les px affichés @@ -1563,8 +1567,7 @@ export function VideoEffectsPane() { // un effet de montage ici ne poussait rien tant que ce panneau precis n'avait pas // ete ouvert. Les handlers par controle ci-dessous poussent toujours leurs diffs. - const toggleFillFrame = (on: boolean) => { - const patch = fillFramePatch(on, nativeFormats[0]?.token ?? settings.aspectRatio); + const pushFramePatch = (patch: EditorSettingsPatch) => { void set(patch); if (isNativeCompositorActive()) { setNativeParam("padding", (patch.padding ?? 0) / 100); @@ -1572,6 +1575,11 @@ export function VideoEffectsPane() { setNativeParam("shadow", patch.shadowIntensity ?? 0); } }; + /** Fill to a shape the user named. */ + const applyFillFrame = (token: AspectRatio) => pushFramePatch(fillFramePatch(true, token)); + /** Fill to the majority shape, or restore — the no-decision-to-make path. */ + const toggleFillFrame = (on: boolean) => + pushFramePatch(fillFramePatch(on, nativeFormats[0]?.token ?? settings.aspectRatio)); return (
{ts("effects.frame")}
{/* #84: "how do I turn the background off". The honest answer was four settings in - three places, so nobody found it. This is that answer as one control — and it - stays a derived view of those settings, so moving any slider below simply turns - it back off rather than leaving a lying switch behind. Disabled without a - timeline: there is no footage whose shape we could fill to. */} + three places, so nobody found it. This is that answer as one control. + + An ACTION, not a switch. A switch has room for one outcome, and on a timeline + with several shapes there are several — one per shape — with no way for a + boolean to say which it took. It took the majority, silently. Worse, the shapes + it was choosing between were shown as raw ratio tokens: `16:9` is readable, + `683:384` and `64:27` are not, and ten of them do not fit a row of chips. + + So the button says what is filling the frame right now, and opens a list when + there is a choice to make — the same shape the "edit clip" rail button has. + Rows lead with the RESOLUTION, which users recognise, and carry their clip + count so the trade is visible. The list scrolls, so ten shapes cost no more + than two. */}
{ts("effects.fillFrame")} - -
- {/* WHICH shape to fill is only a question when the timeline has more than one, and - then it is the user's to answer, not ours: picking the majority silently means a - project that is mostly landscape with two portrait inserts can never be made to - fill on the portrait ones. Each shape carries its clip count, so the trade is - visible — same information the ratio menu's ORIGINAL section shows. */} - {frameIsFilled && nativeFormats.length > 1 ? ( - <> -
- {nativeFormats.map((format) => ( - - ))} -
- {clipsStillFramed > 0 ? ( -
- {ts("effects.fillFrameMixed")} + + + + + +
+

{ts("effects.fillFrameChoose")}

+ {nativeFormats.map((format) => ( + + ))} + {frameIsFilled ? ( + + ) : null}
- ) : null} - +
+
+
+ {frameIsFilled && clipsStillFramed > 0 ? ( +
+ {ts("effects.fillFrameMixed")} +
) : null}
{ afterEach(cleanup); describe("a mixed timeline gets a shape to choose, not a shape imposed", () => { + it("names the current fill by RESOLUTION, not by ratio token", () => { + // `16:9` is readable; `683:384` and `64:27` are not. What a user recognises about + // their own footage is its resolution, so that is what the control says. + mount(documentWithShapes([[1920, 1080]])); + expect(screen.getByRole("button", { name: /1920 × 1080/ })).toBeInTheDocument(); + }); + it("offers every distinct shape, with the clip count behind each", () => { // Five landscape clips and two portrait inserts: picking the majority silently would // mean the portrait ones can never be made to fill. @@ -82,11 +89,24 @@ describe("a mixed timeline gets a shape to choose, not a shape imposed", () => { [1080, 1920], ]), ); + fireEvent.click(screen.getByRole("button", { name: /1920 × 1080/ })); - expect(screen.getByRole("button", { name: /16:9/ })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /9:16/ })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /16:9\s*5/ })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /9:16\s*2/ })).toBeInTheDocument(); + const menu = screen.getByRole("menu"); + expect(within(menu).getByRole("menuitem", { name: /1920 × 1080.*5/ })).toBeInTheDocument(); + expect(within(menu).getByRole("menuitem", { name: /1080 × 1920.*2/ })).toBeInTheDocument(); + }); + + it("offers a way back out of the menu it opened", () => { + mount( + documentWithShapes([ + [1920, 1080], + [1080, 1920], + ]), + ); + fireEvent.click(screen.getByRole("button", { name: /1920 × 1080/ })); + expect( + within(screen.getByRole("menu")).getByRole("menuitem", { name: "Restore the frame" }), + ).toBeInTheDocument(); }); it("says the other shapes still show the background", () => { @@ -101,15 +121,18 @@ describe("a mixed timeline gets a shape to choose, not a shape imposed", () => { ); }); - it("offers no choice when every clip is the same shape — there is nothing to arbitrate", () => { + it("asks nothing when every clip is the same shape — the button just acts", () => { mount( documentWithShapes([ [1920, 1080], [3840, 2160], ]), ); - // 1920x1080 and 3840x2160 are both 16:9: one shape, so no chooser and no caveat. + // 1920x1080 and 3840x2160 are both 16:9, so they collapse to ONE shape — labelled with + // the biggest representative, the same rule the ratio menu uses so the label shows the + // best resolution available. One shape means no menu and no caveat. + fireEvent.click(screen.getByRole("button", { name: /3840 × 2160/ })); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); expect(screen.queryByRole("note")).not.toBeInTheDocument(); - expect(screen.queryByRole("group")).not.toBeInTheDocument(); }); }); diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index e1a9eebf0..66fe5098c 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -76,6 +76,9 @@ "padding": "المسافة البادئة", "frame": "الإطار", "fillFrame": "ملء الإطار", + "fillFrameOff": "مُعطَّل", + "fillFrameChoose": "اختر ما يملأ الإطار", + "fillFrameRestore": "استعادة الإطار", "fillFrameMixed": "المقاطع ذات الشكل المختلف لا تزال تُظهر الخلفية.", "motion": "الحركة", "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو." diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index f4c16ca36..4c526baf2 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -82,6 +82,9 @@ "padding": "Padding", "frame": "Frame", "fillFrame": "Fill frame", + "fillFrameOff": "Off", + "fillFrameChoose": "Choose what fills the frame", + "fillFrameRestore": "Restore the frame", "fillFrameMixed": "Clips in another shape still show the background.", "motion": "Motion", "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video." diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index c0736ddad..14a7b88fb 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -75,6 +75,9 @@ "padding": "Relleno", "frame": "Marco", "fillFrame": "Llenar el marco", + "fillFrameOff": "Desactivado", + "fillFrameChoose": "Elige qué llena el marco", + "fillFrameRestore": "Restaurar el marco", "fillFrameMixed": "Los clips con otra forma siguen mostrando el fondo.", "motion": "Movimiento", "on": "activado", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 4fd9c4a08..e88161a9b 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -75,6 +75,9 @@ "padding": "Marge", "frame": "Cadre", "fillFrame": "Plein cadre", + "fillFrameOff": "Désactivé", + "fillFrameChoose": "Choisir ce qui remplit le cadre", + "fillFrameRestore": "Rétablir le cadre", "fillFrameMixed": "Les clips d'une autre forme affichent encore le fond.", "motion": "Mouvement", "on": "activé", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index e5da5b5a7..e5c41dfd8 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -76,6 +76,9 @@ "padding": "Spaziatura", "frame": "Cornice", "fillFrame": "Riempi la cornice", + "fillFrameOff": "Disattivato", + "fillFrameChoose": "Scegli cosa riempie la cornice", + "fillFrameRestore": "Ripristina la cornice", "fillFrameMixed": "Le clip con un'altra forma mostrano ancora lo sfondo.", "motion": "Movimento", "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video." diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index ee0246775..7d79d20c2 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -75,6 +75,9 @@ "padding": "余白", "frame": "フレーム", "fillFrame": "フレームいっぱい", + "fillFrameOff": "オフ", + "fillFrameChoose": "フレームを埋める対象を選択", + "fillFrameRestore": "フレームを元に戻す", "fillFrameMixed": "別の形状のクリップには背景が残ります。", "motion": "モーション", "on": "オン", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index b105588e2..52f9f8933 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -76,6 +76,9 @@ "padding": "여백", "frame": "프레임", "fillFrame": "프레임 채우기", + "fillFrameOff": "끄기", + "fillFrameChoose": "프레임을 채울 대상 선택", + "fillFrameRestore": "프레임 되돌리기", "fillFrameMixed": "다른 비율의 클립에는 배경이 그대로 남습니다.", "motion": "모션", "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백." diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 550392d35..1ed47a38b 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -76,6 +76,9 @@ "padding": "Espaçamento", "frame": "Moldura", "fillFrame": "Preencher a moldura", + "fillFrameOff": "Desativado", + "fillFrameChoose": "Escolha o que preenche a moldura", + "fillFrameRestore": "Restaurar a moldura", "fillFrameMixed": "Clipes com outro formato ainda mostram o fundo.", "motion": "Movimento", "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo." diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index a704885ad..8ed1d6886 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -76,6 +76,9 @@ "padding": "Отступ", "frame": "Рамка", "fillFrame": "Во весь кадр", + "fillFrameOff": "Выключено", + "fillFrameChoose": "Выберите, чем заполнить кадр", + "fillFrameRestore": "Вернуть рамку", "fillFrameMixed": "Клипы другой формы по-прежнему показывают фон.", "motion": "Движение", "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео." diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 4e0e7278e..0b4e0b4bc 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -75,6 +75,9 @@ "padding": "Dolgu", "frame": "Çerçeve", "fillFrame": "Çerçeveyi doldur", + "fillFrameOff": "Kapalı", + "fillFrameChoose": "Çerçeveyi neyin dolduracağını seçin", + "fillFrameRestore": "Çerçeveyi geri yükle", "fillFrameMixed": "Başka bir orandaki klipler arka planı göstermeye devam eder.", "motion": "Hareket", "on": "açık", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index a5b68fac3..729af6b18 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -75,6 +75,9 @@ "padding": "Phần đệm", "frame": "Khung", "fillFrame": "Lấp đầy khung", + "fillFrameOff": "Tắt", + "fillFrameChoose": "Chọn nội dung lấp đầy khung", + "fillFrameRestore": "Khôi phục khung", "fillFrameMixed": "Các clip có tỉ lệ khác vẫn hiển thị nền.", "motion": "Chuyển động", "on": "bật", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index fc7b87c3f..a3c522359 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -75,6 +75,9 @@ "padding": "内边距", "frame": "画框", "fillFrame": "填满画框", + "fillFrameOff": "关闭", + "fillFrameChoose": "选择填满画框的内容", + "fillFrameRestore": "恢复画框", "fillFrameMixed": "其他比例的片段仍会显示背景。", "motion": "运动", "on": "开", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index dc3f01c1a..89f31bcb8 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -76,6 +76,9 @@ "padding": "內邊距", "frame": "外框", "fillFrame": "填滿外框", + "fillFrameOff": "關閉", + "fillFrameChoose": "選擇填滿外框的內容", + "fillFrameRestore": "還原外框", "fillFrameMixed": "其他比例的片段仍會顯示背景。", "motion": "動態", "on": "開", From 5dbebe8162cee58e3093fa1877caaacef52949b0 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 22 Aug 2026 01:16:27 +0200 Subject: [PATCH 09/13] feat(editor): "fit the clip" is an action, not a state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third shape for the same control, and the first one whose model is right. It was a switch, then a switch plus a chip row, and both were trying to report a state that was never the point: what the action did is already visible in the three sliders below it. Reporting it twice is how the switch came to disagree with the picture on a mixed timeline in the first place. So the control does one thing and says so. "Fit the clip" when the timeline has one shape — it acts, no list. "Fit a clip" when it has several — it opens the list, because that is exactly when there is a decision only the user can make. No current-state label, no Off, no restore row: undo already restores, and the sliders are right there. The list keeps what the chips got right and drops what they got wrong. Rows lead with the RESOLUTION — `1920 × 1080` is recognisable, `683:384` and `64:27` are not — with the ratio token as secondary text and the clip count on the right. Counts are now "1 clip" / "2 clips", chosen through Intl.PluralRules rather than count === 1. The catalog has no plural machinery, so the two forms are two keys; selecting by CLDR category is what makes French say "0 clip", and it degrades honestly for locales needing more than two forms instead of assuming English's rules are universal. Refs #84 Co-Authored-By: Claude Opus 5 --- .../ai-edition/NewEditorShell.module.css | 29 +-- src/components/ai-edition/RightPanes.tsx | 199 ++++++++---------- src/components/ai-edition/fillFrame.test.ts | 32 ++- .../ai-edition/fillFrameShapes.test.tsx | 45 ++-- src/i18n/locales/ar/settings.json | 10 +- src/i18n/locales/en/settings.json | 10 +- src/i18n/locales/es/settings.json | 10 +- src/i18n/locales/fr/settings.json | 10 +- src/i18n/locales/it/settings.json | 10 +- src/i18n/locales/ja-JP/settings.json | 10 +- src/i18n/locales/ko-KR/settings.json | 10 +- src/i18n/locales/pt-BR/settings.json | 10 +- src/i18n/locales/ru/settings.json | 10 +- src/i18n/locales/tr/settings.json | 10 +- src/i18n/locales/vi/settings.json | 10 +- src/i18n/locales/zh-CN/settings.json | 10 +- src/i18n/locales/zh-TW/settings.json | 10 +- 17 files changed, 191 insertions(+), 244 deletions(-) diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index 92f2790b0..414d39b8a 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -987,24 +987,7 @@ color: var(--fg-2); margin: 4px var(--sp-4) 8px; } -/* ─── row action + its menu ───────────────────────────────────── */ -/* Sits where a Toggle would, and reads as the current answer rather than as on/off. */ -.rowAction { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 5px 8px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg); - color: var(--fg-2); - font: 500 var(--fs-app-sm)/1 var(--font-body); - font-variant-numeric: tabular-nums; - cursor: pointer; - transition: border-color 150ms ease, color 150ms ease; -} -.rowAction:hover:not(:disabled) { border-color: var(--brand); color: var(--fg); } -.rowAction:disabled { cursor: default; opacity: 0.5; } +/* ─── action menu (a button's list of things it could do) ─────── */ .actionMenu { min-width: 216px; padding: 6px; @@ -1018,13 +1001,6 @@ scrollbar-width: thin; scrollbar-color: var(--border) transparent; } -.actionMenuTitle { - margin: 4px 8px 6px; - font: 600 11px/1 var(--font-body); - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--muted); -} .actionMenuRow { display: flex; align-items: baseline; @@ -1039,7 +1015,6 @@ text-align: left; } .actionMenuRow:hover { background: var(--surface-2); } -.actionMenuRow.isActive { background: color-mix(in oklab, var(--brand), transparent 82%); } /* Resolution leads: it is the thing a user recognises about their own footage. */ .actionMenuMain { font: 600 12.5px var(--font-display); font-variant-numeric: tabular-nums; } /* The ratio token second, for whoever does read them — `64:27` means nothing alone. */ @@ -1050,8 +1025,6 @@ font-variant-numeric: tabular-nums; color: var(--meta); } -.actionMenuReset { border-top: 1px solid var(--border-soft); border-radius: 0 0 8px 8px; margin-top: 4px; padding-top: 10px; } -.actionMenuReset .actionMenuMain { font-weight: 500; color: var(--fg-2); } /* Caveat under a control, when the control is true but not the whole story. */ .paneHint { diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index b3c93af7e..efab27541 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -36,7 +36,7 @@ import { toast } from "sonner"; import defaultCursorPreviewUrl from "@/assets/cursors/Cursor=Default.svg"; import GradientEditor, { type GradientEditorState } from "@/components/ui/gradient-editor"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { useScopedT } from "@/contexts/I18nContext"; +import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { collectNativeFormats } from "@/lib/ai-edition/document/outputFormat"; import type { AxcutAsset, @@ -47,7 +47,6 @@ import type { } from "@/lib/ai-edition/schema"; import { AUDIO_GAIN_DB_LIMIT, - DEFAULT_EDITOR_SETTINGS, type EditorSettingsPatch, type EditorSettingsSnapshot, } from "@/lib/ai-edition/store/editorSettings"; @@ -1497,27 +1496,31 @@ export function fillsFrame( } /** - * The patch behind the toggle. + * The patch behind the action. * - * Turning it OFF restores the shipped defaults rather than whatever the user had before: - * "before" would have to be stored somewhere, and a hidden slot that only one control writes - * is the kind of state that rots. Predictable beats clever here — and every value it restores - * is one slider away. - * - * `aspectRatio` is deliberately absent from the OFF patch. Turning the background back on does - * not mean the user wanted their output reframed; the three frame values alone bring it back. + * There is no inverse. It was a toggle once, and the OFF branch restored the shipped defaults + * — which was already a guess dressed as a memory, since nothing stored what the user had + * before. Undo does that job properly, and the three sliders it writes sit directly below the + * button, so "put it back" was never missing; it was being modelled twice. */ -export function fillFramePatch(on: boolean, nativeToken: AspectRatio): EditorSettingsPatch { - if (!on) { - return { - padding: DEFAULT_EDITOR_SETTINGS.padding, - borderRadius: DEFAULT_EDITOR_SETTINGS.borderRadius, - shadowIntensity: DEFAULT_EDITOR_SETTINGS.shadowIntensity, - }; - } +export function fitClipPatch(nativeToken: AspectRatio): EditorSettingsPatch { return { padding: 0, borderRadius: 0, shadowIntensity: 0, aspectRatio: nativeToken }; } +/** + * "1 clip" / "2 clips", picked through Intl.PluralRules rather than `count === 1`. + * + * The catalog has no plural machinery — `translate` interpolates and nothing else — so the two + * forms are two keys. Choosing between them by CLDR category instead of by equality is what + * makes French say "0 clip" and not "0 clips", and it degrades honestly for the locales whose + * rules need more than two forms rather than pretending English's rules are universal. + */ +function pluralKey(locale: string, count: number): "effects.fitClipOne" | "effects.fitClipMany" { + return new Intl.PluralRules(locale).select(count) === "one" + ? "effects.fitClipOne" + : "effects.fitClipMany"; +} + // ─── Video Effects ───────────────────────────────────────────────── /** @@ -1548,15 +1551,14 @@ export function VideoEffectsPane() { // contain-fits (see compositeLayout.ts), so anything not in the chosen shape keeps its // bars. The switch is honest about the SETTINGS; without this it would also be read as a // claim about the picture, and be wrong on every mixed timeline. - const filledFormat = frameIsFilled - ? nativeFormats.find((f) => f.token === settings.aspectRatio) - : undefined; const clipsStillFramed = useMemo(() => { const filled = nativeFormats.find((f) => f.token === settings.aspectRatio); const total = nativeFormats.reduce((n, f) => n + f.clipCount, 0); return total - (filled?.clipCount ?? 0); }, [nativeFormats, settings.aspectRatio]); - const [fillMenuOpen, setFillMenuOpen] = useState(false); + const [fitMenuOpen, setFitMenuOpen] = useState(false); + const { locale } = useI18n(); + const clipCountLabel = (count: number) => ts(pluralKey(locale, count), { count }); // Le rayon natif = rayon de base de la fixture (~24px @1920) × cette échelle. Diviser la // valeur px de l'UI par ce même rayon de base fait que le coin natif ≈ les px affichés @@ -1567,19 +1569,15 @@ export function VideoEffectsPane() { // un effet de montage ici ne poussait rien tant que ce panneau precis n'avait pas // ete ouvert. Les handlers par controle ci-dessous poussent toujours leurs diffs. - const pushFramePatch = (patch: EditorSettingsPatch) => { + const applyFitClip = (token: AspectRatio) => { + const patch = fitClipPatch(token); void set(patch); if (isNativeCompositorActive()) { - setNativeParam("padding", (patch.padding ?? 0) / 100); - setNativeParam("roundness", (patch.borderRadius ?? 0) / NATIVE_SCREEN_BASE_RADIUS_PX); - setNativeParam("shadow", patch.shadowIntensity ?? 0); + setNativeParam("padding", 0); + setNativeParam("roundness", 0); + setNativeParam("shadow", 0); } }; - /** Fill to a shape the user named. */ - const applyFillFrame = (token: AspectRatio) => pushFramePatch(fillFramePatch(true, token)); - /** Fill to the majority shape, or restore — the no-decision-to-make path. */ - const toggleFillFrame = (on: boolean) => - pushFramePatch(fillFramePatch(on, nativeFormats[0]?.token ?? settings.aspectRatio)); return ( - {ts("effects.fillFrame")} - - - - - + + - ))} - {frameIsFilled ? ( - - ) : null} -
- - -
+ {nativeFormats.length > 1 ? ts("effects.fitAClip") : ts("effects.fitClip")} + + + +
+ {nativeFormats.map((format) => ( + + ))} +
+
+ {frameIsFilled && clipsStillFramed > 0 ? (
- {ts("effects.fillFrameMixed")} + {ts("effects.fitClipMixed")}
) : null}
diff --git a/src/components/ai-edition/fillFrame.test.ts b/src/components/ai-edition/fillFrame.test.ts index 78011e5bc..1c1e37dbb 100644 --- a/src/components/ai-edition/fillFrame.test.ts +++ b/src/components/ai-edition/fillFrame.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; -import { fillFramePatch, fillsFrame } from "./RightPanes"; +import { fillsFrame, fitClipPatch } from "./RightPanes"; const FILLED = { padding: 0, @@ -57,9 +57,9 @@ describe("fillsFrame reads the four settings, not a stored flag", () => { }); }); -describe("fillFramePatch", () => { +describe("fitClipPatch", () => { it("zeroes the three frame values and adopts the footage's shape", () => { - expect(fillFramePatch(true, "16:10")).toEqual({ + expect(fitClipPatch("16:10")).toEqual({ padding: 0, borderRadius: 0, shadowIntensity: 0, @@ -67,21 +67,19 @@ describe("fillFramePatch", () => { }); }); - it("round-trips: what it turns on, fillsFrame reads as on", () => { - const patched = { ...FILLED, ...fillFramePatch(true, "16:10") }; - expect(fillsFrame(patched, NATIVE)).toBe(true); + it("round-trips: what it applies, fillsFrame reads as filled", () => { + expect(fillsFrame({ ...FILLED, ...fitClipPatch("16:10") }, NATIVE)).toBe(true); }); - it("restores the shipped defaults when switched off", () => { - const off = fillFramePatch(false, "16:10"); - expect(off.padding).toBeGreaterThan(0); - expect(off.borderRadius).toBeGreaterThan(0); - expect(off.shadowIntensity).toBeGreaterThan(0); - }); - - it("leaves the aspect ratio alone when switched off", () => { - // Wanting the background back is not asking for the output to be reframed, and the - // three frame values alone bring it back. - expect(fillFramePatch(false, "16:10")).not.toHaveProperty("aspectRatio"); + it("has no inverse, deliberately", () => { + // It was a toggle, and its OFF branch restored the shipped defaults — a guess dressed + // as a memory, since nothing stored what the user actually had. Undo does that job, + // and the three sliders it writes sit right below the button. + expect(Object.keys(fitClipPatch("16:10")).sort()).toEqual([ + "aspectRatio", + "borderRadius", + "padding", + "shadowIntensity", + ]); }); }); diff --git a/src/components/ai-edition/fillFrameShapes.test.tsx b/src/components/ai-edition/fillFrameShapes.test.tsx index 258b699a3..930cb5cdc 100644 --- a/src/components/ai-edition/fillFrameShapes.test.tsx +++ b/src/components/ai-edition/fillFrameShapes.test.tsx @@ -67,17 +67,16 @@ beforeEach(() => { }); afterEach(cleanup); -describe("a mixed timeline gets a shape to choose, not a shape imposed", () => { - it("names the current fill by RESOLUTION, not by ratio token", () => { - // `16:9` is readable; `683:384` and `64:27` are not. What a user recognises about - // their own footage is its resolution, so that is what the control says. +describe("fitting a clip is an action, and a choice only when there is one", () => { + it("labels the button for the single-shape case and acts without asking", () => { mount(documentWithShapes([[1920, 1080]])); - expect(screen.getByRole("button", { name: /1920 × 1080/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Fit the clip" })); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); }); - it("offers every distinct shape, with the clip count behind each", () => { + it("asks which clip when the timeline holds more than one shape", () => { // Five landscape clips and two portrait inserts: picking the majority silently would - // mean the portrait ones can never be made to fill. + // mean the portrait ones can never be fitted. mount( documentWithShapes([ [1920, 1080], @@ -89,23 +88,33 @@ describe("a mixed timeline gets a shape to choose, not a shape imposed", () => { [1080, 1920], ]), ); - fireEvent.click(screen.getByRole("button", { name: /1920 × 1080/ })); + fireEvent.click(screen.getByRole("button", { name: "Fit a clip" })); const menu = screen.getByRole("menu"); - expect(within(menu).getByRole("menuitem", { name: /1920 × 1080.*5/ })).toBeInTheDocument(); - expect(within(menu).getByRole("menuitem", { name: /1080 × 1920.*2/ })).toBeInTheDocument(); + // Resolution leads — `683:384` and `64:27` mean nothing to a user, `1920 × 1080` does. + expect( + within(menu).getByRole("menuitem", { name: /1920 × 1080.*5 clips/ }), + ).toBeInTheDocument(); + expect( + within(menu).getByRole("menuitem", { name: /1080 × 1920.*2 clips/ }), + ).toBeInTheDocument(); }); - it("offers a way back out of the menu it opened", () => { + it('counts one clip as "1 clip"', () => { mount( documentWithShapes([ + [1920, 1080], [1920, 1080], [1080, 1920], ]), ); - fireEvent.click(screen.getByRole("button", { name: /1920 × 1080/ })); + fireEvent.click(screen.getByRole("button", { name: "Fit a clip" })); + const menu = screen.getByRole("menu"); expect( - within(screen.getByRole("menu")).getByRole("menuitem", { name: "Restore the frame" }), + within(menu).getByRole("menuitem", { name: /1080 × 1920.*1 clip$/ }), + ).toBeInTheDocument(); + expect( + within(menu).getByRole("menuitem", { name: /1920 × 1080.*2 clips/ }), ).toBeInTheDocument(); }); @@ -121,18 +130,16 @@ describe("a mixed timeline gets a shape to choose, not a shape imposed", () => { ); }); - it("asks nothing when every clip is the same shape — the button just acts", () => { + it("collapses same-shape clips to one entry, labelled with the biggest", () => { mount( documentWithShapes([ [1920, 1080], [3840, 2160], ]), ); - // 1920x1080 and 3840x2160 are both 16:9, so they collapse to ONE shape — labelled with - // the biggest representative, the same rule the ratio menu uses so the label shows the - // best resolution available. One shape means no menu and no caveat. - fireEvent.click(screen.getByRole("button", { name: /3840 × 2160/ })); - expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + // Both are 16:9, so there is one shape and nothing to arbitrate — and the ratio menu's + // rule applies: the label shows the best resolution available. + expect(screen.getByRole("button", { name: "Fit the clip" })).toBeInTheDocument(); expect(screen.queryByRole("note")).not.toBeInTheDocument(); }); }); diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 66fe5098c..be871d263 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -75,11 +75,11 @@ "roundness": "الاستدارة", "padding": "المسافة البادئة", "frame": "الإطار", - "fillFrame": "ملء الإطار", - "fillFrameOff": "مُعطَّل", - "fillFrameChoose": "اختر ما يملأ الإطار", - "fillFrameRestore": "استعادة الإطار", - "fillFrameMixed": "المقاطع ذات الشكل المختلف لا تزال تُظهر الخلفية.", + "fitClip": "ملاءمة المقطع", + "fitAClip": "ملاءمة مقطع", + "fitClipOne": "مقطع واحد", + "fitClipMany": "{{count}} مقاطع", + "fitClipMixed": "المقاطع ذات الشكل المختلف لا تزال تُظهر الخلفية.", "motion": "الحركة", "help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو." }, diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 4c526baf2..97d733d38 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -81,11 +81,11 @@ "roundness": "Roundness", "padding": "Padding", "frame": "Frame", - "fillFrame": "Fill frame", - "fillFrameOff": "Off", - "fillFrameChoose": "Choose what fills the frame", - "fillFrameRestore": "Restore the frame", - "fillFrameMixed": "Clips in another shape still show the background.", + "fitClip": "Fit the clip", + "fitAClip": "Fit a clip", + "fitClipOne": "{{count}} clip", + "fitClipMany": "{{count}} clips", + "fitClipMixed": "Clips in another shape still show the background.", "motion": "Motion", "help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video." }, diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 14a7b88fb..616f49229 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -74,11 +74,11 @@ "roundness": "Redondez", "padding": "Relleno", "frame": "Marco", - "fillFrame": "Llenar el marco", - "fillFrameOff": "Desactivado", - "fillFrameChoose": "Elige qué llena el marco", - "fillFrameRestore": "Restaurar el marco", - "fillFrameMixed": "Los clips con otra forma siguen mostrando el fondo.", + "fitClip": "Ajustar al clip", + "fitAClip": "Ajustar a un clip", + "fitClipOne": "{{count}} clip", + "fitClipMany": "{{count}} clips", + "fitClipMixed": "Los clips con otra forma siguen mostrando el fondo.", "motion": "Movimiento", "on": "activado", "help": "Estilo del marco de la grabación: desenfoque de fondo, sombra, desenfoque de movimiento, radio de esquinas y margen alrededor del vídeo." diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index e88161a9b..bbfda1c70 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -74,11 +74,11 @@ "roundness": "Arrondi", "padding": "Marge", "frame": "Cadre", - "fillFrame": "Plein cadre", - "fillFrameOff": "Désactivé", - "fillFrameChoose": "Choisir ce qui remplit le cadre", - "fillFrameRestore": "Rétablir le cadre", - "fillFrameMixed": "Les clips d'une autre forme affichent encore le fond.", + "fitClip": "Ajuster au clip", + "fitAClip": "Ajuster à un clip", + "fitClipOne": "{{count}} clip", + "fitClipMany": "{{count}} clips", + "fitClipMixed": "Les clips d'une autre forme affichent encore le fond.", "motion": "Mouvement", "on": "activé", "help": "Mise en forme du cadre de l'enregistrement : flou d'arrière-plan, ombre portée, flou de mouvement, arrondi des coins et marge autour de la vidéo." diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index e5c41dfd8..58d453759 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -75,11 +75,11 @@ "roundness": "Arrotondamento", "padding": "Spaziatura", "frame": "Cornice", - "fillFrame": "Riempi la cornice", - "fillFrameOff": "Disattivato", - "fillFrameChoose": "Scegli cosa riempie la cornice", - "fillFrameRestore": "Ripristina la cornice", - "fillFrameMixed": "Le clip con un'altra forma mostrano ancora lo sfondo.", + "fitClip": "Adatta alla clip", + "fitAClip": "Adatta a una clip", + "fitClipOne": "{{count}} clip", + "fitClipMany": "{{count}} clip", + "fitClipMixed": "Le clip con un'altra forma mostrano ancora lo sfondo.", "motion": "Movimento", "help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video." }, diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 7d79d20c2..241776ed7 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -74,11 +74,11 @@ "roundness": "丸み", "padding": "余白", "frame": "フレーム", - "fillFrame": "フレームいっぱい", - "fillFrameOff": "オフ", - "fillFrameChoose": "フレームを埋める対象を選択", - "fillFrameRestore": "フレームを元に戻す", - "fillFrameMixed": "別の形状のクリップには背景が残ります。", + "fitClip": "クリップに合わせる", + "fitAClip": "クリップを選んで合わせる", + "fitClipOne": "{{count}} クリップ", + "fitClipMany": "{{count}} クリップ", + "fitClipMixed": "別の形状のクリップには背景が残ります。", "motion": "モーション", "on": "オン", "help": "録画フレームのスタイル設定: 背景ぼかし、ドロップシャドウ、モーションブラー、角丸、動画まわりの余白。" diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 52f9f8933..24f779f43 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -75,11 +75,11 @@ "roundness": "모서리 둥글기", "padding": "여백", "frame": "프레임", - "fillFrame": "프레임 채우기", - "fillFrameOff": "끄기", - "fillFrameChoose": "프레임을 채울 대상 선택", - "fillFrameRestore": "프레임 되돌리기", - "fillFrameMixed": "다른 비율의 클립에는 배경이 그대로 남습니다.", + "fitClip": "클립에 맞추기", + "fitAClip": "클립을 선택해 맞추기", + "fitClipOne": "{{count}}개 클립", + "fitClipMany": "{{count}}개 클립", + "fitClipMixed": "다른 비율의 클립에는 배경이 그대로 남습니다.", "motion": "모션", "help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백." }, diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 1ed47a38b..c6d6b1774 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -75,11 +75,11 @@ "roundness": "Arredondamento", "padding": "Espaçamento", "frame": "Moldura", - "fillFrame": "Preencher a moldura", - "fillFrameOff": "Desativado", - "fillFrameChoose": "Escolha o que preenche a moldura", - "fillFrameRestore": "Restaurar a moldura", - "fillFrameMixed": "Clipes com outro formato ainda mostram o fundo.", + "fitClip": "Ajustar ao clipe", + "fitAClip": "Ajustar a um clipe", + "fitClipOne": "{{count}} clipe", + "fitClipMany": "{{count}} clipes", + "fitClipMixed": "Clipes com outro formato ainda mostram o fundo.", "motion": "Movimento", "help": "Estilo do quadro da gravação: desfoque de fundo, sombra, desfoque de movimento, raio dos cantos e margem em volta do vídeo." }, diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 8ed1d6886..92b95002c 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -75,11 +75,11 @@ "roundness": "Скругление", "padding": "Отступ", "frame": "Рамка", - "fillFrame": "Во весь кадр", - "fillFrameOff": "Выключено", - "fillFrameChoose": "Выберите, чем заполнить кадр", - "fillFrameRestore": "Вернуть рамку", - "fillFrameMixed": "Клипы другой формы по-прежнему показывают фон.", + "fitClip": "Подогнать под клип", + "fitAClip": "Подогнать под клип", + "fitClipOne": "{{count}} клип", + "fitClipMany": "{{count}} клипов", + "fitClipMixed": "Клипы другой формы по-прежнему показывают фон.", "motion": "Движение", "help": "Оформление кадра записи: размытие фона, тень, размытие движения, скругление углов и отступ вокруг видео." }, diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 0b4e0b4bc..4c3f3d6ec 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -74,11 +74,11 @@ "roundness": "Yuvarlaklık", "padding": "Dolgu", "frame": "Çerçeve", - "fillFrame": "Çerçeveyi doldur", - "fillFrameOff": "Kapalı", - "fillFrameChoose": "Çerçeveyi neyin dolduracağını seçin", - "fillFrameRestore": "Çerçeveyi geri yükle", - "fillFrameMixed": "Başka bir orandaki klipler arka planı göstermeye devam eder.", + "fitClip": "Klibe sığdır", + "fitAClip": "Bir klibe sığdır", + "fitClipOne": "{{count}} klip", + "fitClipMany": "{{count}} klip", + "fitClipMixed": "Başka bir orandaki klipler arka planı göstermeye devam eder.", "motion": "Hareket", "on": "açık", "help": "Kayıt çerçevesinin biçimi: arka plan bulanıklığı, gölge, hareket bulanıklığı, köşe yuvarlaklığı ve videonun çevresindeki boşluk." diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 729af6b18..0fb34db7a 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -74,11 +74,11 @@ "roundness": "Độ bo tròn", "padding": "Phần đệm", "frame": "Khung", - "fillFrame": "Lấp đầy khung", - "fillFrameOff": "Tắt", - "fillFrameChoose": "Chọn nội dung lấp đầy khung", - "fillFrameRestore": "Khôi phục khung", - "fillFrameMixed": "Các clip có tỉ lệ khác vẫn hiển thị nền.", + "fitClip": "Vừa khít clip", + "fitAClip": "Vừa khít một clip", + "fitClipOne": "{{count}} clip", + "fitClipMany": "{{count}} clip", + "fitClipMixed": "Các clip có tỉ lệ khác vẫn hiển thị nền.", "motion": "Chuyển động", "on": "bật", "help": "Kiểu khung của bản ghi: làm mờ nền, đổ bóng, mờ chuyển động, bo góc và khoảng đệm quanh video." diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index a3c522359..11bbfb65f 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -74,11 +74,11 @@ "roundness": "圆角", "padding": "内边距", "frame": "画框", - "fillFrame": "填满画框", - "fillFrameOff": "关闭", - "fillFrameChoose": "选择填满画框的内容", - "fillFrameRestore": "恢复画框", - "fillFrameMixed": "其他比例的片段仍会显示背景。", + "fitClip": "适配片段", + "fitAClip": "选择要适配的片段", + "fitClipOne": "{{count}} 个片段", + "fitClipMany": "{{count}} 个片段", + "fitClipMixed": "其他比例的片段仍会显示背景。", "motion": "运动", "on": "开", "help": "录制画面的边框样式:背景模糊、投影、运动模糊、圆角半径以及视频四周的内边距。" diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 89f31bcb8..b3fa2eff7 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -75,11 +75,11 @@ "roundness": "圓角", "padding": "內邊距", "frame": "外框", - "fillFrame": "填滿外框", - "fillFrameOff": "關閉", - "fillFrameChoose": "選擇填滿外框的內容", - "fillFrameRestore": "還原外框", - "fillFrameMixed": "其他比例的片段仍會顯示背景。", + "fitClip": "符合片段", + "fitAClip": "選擇要符合的片段", + "fitClipOne": "{{count}} 個片段", + "fitClipMany": "{{count}} 個片段", + "fitClipMixed": "其他比例的片段仍會顯示背景。", "motion": "動態", "on": "開", "help": "錄製畫面的外框樣式:背景模糊、陰影、動態模糊、圓角半徑,以及影片四周的內距。" From 52d38556078cd50487b0d9430e41cdfb40ee9080 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 22 Aug 2026 01:48:12 +0200 Subject: [PATCH 10/13] feat(editor): move the output format next to the frame it shapes, rename the pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aspect ratio lived in the timeline toolbar, among Trim / Speed / Zoom / Full Camera and the transport. Everything around it was about the timeline, so a shape selector there read as a viewport control — something that changes what you are looking at, not what gets exported. That placement was never argued for: it arrived inside 1f25410b, a commit about per-clip crop export and a HUD redesign, and no decision record mentions it. It belongs with padding, roundness and shadow, which only mean anything measured against a known frame. With it here, the answer to #84 is one panel instead of three places — which is what this PR set out to do and had, until now, only half done: the one-click action covered the common case while the manual path still crossed two surfaces. The timeline's own shapes stay listed under the presets, and deliberately not only behind "fit a clip": that action also zeroes the frame styling, so without those rows there would be no way to export at the footage's native shape while keeping a padded, rounded look. Nothing replaces it in the toolbar. An indicator there would either be dead weight or a second way to author the same setting, and the canvas already shows its own shape. "Video Effects" stopped being true once a format moved in — an output shape is not an effect. The pane is Composition. That name was too close to "Layout" until Layout became "Camera layout", which is what that facet has always actually been (webcam presets, mask shape, mirroring; it is already disabled without a webcam). Its preset field re-used the pane title and now uses layout.preset, which is what the field is. Refs #84 Co-Authored-By: Claude Opus 5 --- .../ai-edition/NewEditorShell.module.css | 26 +++++ .../ai-edition/RightPanes.i18n.test.tsx | 8 +- .../ai-edition/RightPanes.layout.test.tsx | 4 +- src/components/ai-edition/RightPanes.tsx | 88 ++++++++++++++- src/components/ai-edition/v4/V4Timeline.tsx | 105 ------------------ src/i18n/locales/ar/settings.json | 6 +- src/i18n/locales/ar/timeline.json | 2 - src/i18n/locales/en/settings.json | 6 +- src/i18n/locales/en/timeline.json | 2 - src/i18n/locales/es/settings.json | 6 +- src/i18n/locales/es/timeline.json | 2 - src/i18n/locales/fr/settings.json | 6 +- src/i18n/locales/fr/timeline.json | 2 - src/i18n/locales/it/settings.json | 6 +- src/i18n/locales/it/timeline.json | 2 - src/i18n/locales/ja-JP/settings.json | 6 +- src/i18n/locales/ja-JP/timeline.json | 2 - src/i18n/locales/ko-KR/settings.json | 6 +- src/i18n/locales/ko-KR/timeline.json | 2 - src/i18n/locales/pt-BR/settings.json | 6 +- src/i18n/locales/pt-BR/timeline.json | 2 - src/i18n/locales/ru/settings.json | 6 +- src/i18n/locales/ru/timeline.json | 2 - src/i18n/locales/tr/settings.json | 6 +- src/i18n/locales/tr/timeline.json | 2 - src/i18n/locales/vi/settings.json | 6 +- src/i18n/locales/vi/timeline.json | 2 - src/i18n/locales/zh-CN/settings.json | 6 +- src/i18n/locales/zh-CN/timeline.json | 2 - src/i18n/locales/zh-TW/settings.json | 6 +- src/i18n/locales/zh-TW/timeline.json | 2 - 31 files changed, 170 insertions(+), 165 deletions(-) diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index 414d39b8a..baeac629e 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -988,6 +988,23 @@ margin: 4px var(--sp-4) 8px; } /* ─── action menu (a button's list of things it could do) ─────── */ +/* Sits where a Toggle would; reads as the current value, not as on/off. */ +.rowAction { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--fg-2); + font: 500 var(--fs-app-sm)/1 var(--font-body); + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: border-color 150ms ease, color 150ms ease; +} +.rowAction:hover:not(:disabled) { border-color: var(--brand); color: var(--fg); } +.rowAction:disabled { cursor: default; opacity: 0.5; } .actionMenu { min-width: 216px; padding: 6px; @@ -1015,6 +1032,15 @@ text-align: left; } .actionMenuRow:hover { background: var(--surface-2); } +.actionMenuRow.isActive { background: color-mix(in oklab, var(--brand), transparent 82%); } +/* Separates the fixed presets from the shapes this timeline actually holds. */ +.actionMenuGroup { + padding: 8px 8px 4px; + font: 600 10px/1 var(--font-body); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} /* Resolution leads: it is the thing a user recognises about their own footage. */ .actionMenuMain { font: 600 12.5px var(--font-display); font-variant-numeric: tabular-nums; } /* The ratio token second, for whoever does read them — `64:27` means nothing alone. */ diff --git a/src/components/ai-edition/RightPanes.i18n.test.tsx b/src/components/ai-edition/RightPanes.i18n.test.tsx index 51441b255..64e40e7d6 100644 --- a/src/components/ai-edition/RightPanes.i18n.test.tsx +++ b/src/components/ai-edition/RightPanes.i18n.test.tsx @@ -32,7 +32,7 @@ describe("right-rail panes are localized", () => { // Background stopped being its own pane when the two facets merged; it is a section // of the effects pane now, so the same strings must survive under the new heading. renderIn("fr", ); - expect(screen.getByRole("heading", { name: "Effets vidéo" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Composition" })).toBeInTheDocument(); expect(screen.getByText("Arrière-plan")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Aide" })).toBeInTheDocument(); }); @@ -50,7 +50,7 @@ describe("right-rail panes are localized", () => { it("renders the video-effects pane in Spanish", () => { renderIn("es", ); - expect(screen.getByRole("heading", { name: "Efectos de video" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Composición" })).toBeInTheDocument(); expect(screen.getByText("Desenfoque de movimiento")).toBeInTheDocument(); expect(screen.getByText("Sombra")).toBeInTheDocument(); // the merged pane's own section headers @@ -60,7 +60,7 @@ describe("right-rail panes are localized", () => { it("renders the layout pane in Japanese", () => { renderIn("ja-JP", ); - expect(screen.getByRole("heading", { name: "レイアウト" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "カメラレイアウト" })).toBeInTheDocument(); // the preset