From af934fce910ac71a0c69116762d1ab0a89e7ace4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E9=94=9F?= Date: Sat, 7 Nov 2026 02:22:20 +0800 Subject: [PATCH 1/2] feat: add camera shadow intensity control --- crates/compositor/src/compositor_windows.rs | 4 ++-- crates/compositor/src/frame_geometry.rs | 16 ++++++++++++- crates/compositor/src/scene.rs | 2 ++ .../ai-edition/RightPanes.layout.test.tsx | 13 +++++++++++ src/components/ai-edition/RightPanes.tsx | 7 ++++++ .../ai-edition/store/editorSettings.test.ts | 23 +++++++++++++++++++ src/lib/ai-edition/store/editorSettings.ts | 8 +++++++ src/lib/projectDefaults.ts | 3 +++ src/native/sceneDescription.test.ts | 13 +++++++++++ src/native/sceneDescription.ts | 3 +++ 10 files changed, 89 insertions(+), 3 deletions(-) diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 586cfc19b3..aca4c6dad9 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -2808,8 +2808,8 @@ impl Compositor { if lp.has_webcam { // L'ombre portée appartient à la bulle flottante PiP : elle se retire avec elle // (`shape_fade`), pour qu'au plein écran plus rien n'encadre la caméra. C'est une - // ombre légère NON paramétrable — indépendante du slider Shadow, qui ne pilote plus - // que l'écran (`WEBCAM_SHADOW_OPACITY`, pas `shadow_scale`) — et propre au PiP : les + // ombre indépendante du slider Shadow de l'écran, réglable via `g.webcam_shadow`, + // et propre au PiP : les // blocs side-by-side / top-bottom soudent la caméra à l'écran et n'en portent aucune // (parité `preset.shadow` web, `null` hors PiP dans `compositeLayout.ts`). // L'ombre appartient à la bulle PiP. En détourage il n'y a plus de bulle — une diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index e7410ad558..cf980cd51a 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -3677,7 +3677,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { FrameGeometry { webcam_shadow: sampled_layout.as_ref().map(|s| s.webcam_shadow) - .unwrap_or_else(|| if matches!(scene_preset.as_deref(), Some("dual-frame") | Some("vertical-stack") | Some("camera-only")) { 0.0 } else { 1.0 }), + .unwrap_or_else(|| if matches!(scene_preset.as_deref(), Some("dual-frame") | Some("vertical-stack") | Some("camera-only")) { 0.0 } else { 1.0 }) + * input.scene.and_then(|scene| scene.effects.webcam_shadow).unwrap_or(0.6).clamp(0.0, 1.0) / 0.6, scene_preset, mb_taps, mb_amount, @@ -5187,6 +5188,19 @@ mod tests { } } + #[test] + fn webcam_shadow_preserves_old_scenes_and_scales_independently() { + let mut scene = golden_scene(); + let cfg = crate::config::all().pop().expect("compositor config"); + let baseline = plan_frame(&golden_input(&scene, &cfg)); + for (strength, scale) in [(0.0, 0.0), (0.3, 0.5), (0.6, 1.0), (0.9, 1.5)] { + scene.effects.webcam_shadow = Some(strength); + let frame = plan_frame(&golden_input(&scene, &cfg)); + assert!((frame.webcam_shadow - baseline.webcam_shadow * scale).abs() < 1e-5); + assert_eq!(frame.w_dst, baseline.w_dst); + } + } + /// Le JSON de la scène zoomée, brut : `tilted_golden_scene` n'en change QUE la /// rotation, et le faire par substitution garantit que les deux scènes ne diffèrent /// pas ailleurs sans qu'on s'en aperçoive. diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 2a479c8dd8..a8f3b08ec7 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -282,6 +282,8 @@ pub struct SceneEffects { pub blur: f32, /// 0..1 force de l'ombre. pub shadow: f32, + #[serde(default)] + pub webcam_shadow: Option, /// Slider Roundness, en FRACTION du petit côté du cadre de sortie. /// /// Toute longueur qui traverse ce contrat est une fraction, jamais un nombre de pixels, et diff --git a/src/components/ai-edition/RightPanes.layout.test.tsx b/src/components/ai-edition/RightPanes.layout.test.tsx index e810351530..cc26dbd81c 100644 --- a/src/components/ai-edition/RightPanes.layout.test.tsx +++ b/src/components/ai-edition/RightPanes.layout.test.tsx @@ -366,6 +366,19 @@ describe("LayoutPane picture-in-picture camera", () => { expect(stored()).toMatchObject({ webcamAnchor: "top-left" }); }); + it("changes the camera shadow independently of composition shadow", () => { + renderLayout(seedProject(true)); + const shadow = screen.getByRole("group", { name: "Shadow" }); + expect(within(shadow).getByRole("button", { name: "Medium" })).toHaveAttribute( + "aria-pressed", + "true", + ); + fireEvent.click(within(shadow).getByRole("button", { name: "None" })); + expect(stored()).toMatchObject({ webcamShadowIntensity: 0 }); + fireEvent.click(within(shadow).getByRole("button", { name: "Strong" })); + expect(stored()).toMatchObject({ webcamShadowIntensity: 0.9 }); + }); + // The middle of the frame is a hole in the grid, not a ninth place to land. it("steps over the middle of the grid with the arrow keys", () => { renderLayout(seedProject(true)); diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 1e0c674108..cea7bb804f 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -2931,6 +2931,13 @@ export function LayoutPane() { ) : null} {isPip ? ( <> + {namedLevelRow( + ts("effects.shadow"), + SHADOW_LEVELS.map((level) => ({ value: level.value, label: ts(level.labelKey) })), + settings.webcamShadowIntensity, + layoutControlsDisabled || settings.webcamBackgroundMode === "transparent", + (webcamShadowIntensity) => void set({ webcamShadowIntensity }), + )}
{ts("layout.webcamShape")}
diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 7acecab2a9..84c027f3f3 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -39,6 +39,29 @@ const baseDoc: AxcutDocument = { }; describe("getEditorSettings", () => { + it("preserves the historical camera shadow by default and round-trips an independent strength", () => { + expect(getEditorSettings(baseDoc).webcamShadowIntensity).toBe(0.6); + for (const strength of [0, 0.3, 0.6, 0.9]) { + const saved = patchEditorSettings(baseDoc, { + webcamShadowIntensity: strength, + shadowIntensity: 0.3, + }); + const reopened = getEditorSettings(JSON.parse(JSON.stringify(saved))); + expect(reopened.webcamShadowIntensity).toBe(strength); + expect(reopened.shadowIntensity).toBe(0.3); + } + }); + it.each([ + [-1, 0], + [5, 1], + ["invalid", 0.6], + [null, 0.6], + ])("bounds camera shadow %s as %s", (value, expected) => { + expect( + getEditorSettings({ ...baseDoc, legacyEditor: { webcamShadowIntensity: value } }) + .webcamShadowIntensity, + ).toBe(expected); + }); it("returns the defaults when the document has no legacyEditor", () => { const snap = getEditorSettings(baseDoc); expect(snap.wallpaper).toBe(DEFAULT_EDITOR_SETTINGS.wallpaper); diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index 61284e2119..a8d343260d 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -116,6 +116,7 @@ export interface EditorSettingsSnapshot { webcamMirrored: boolean; webcamReactiveZoom: boolean; webcamSizePreset: WebcamSizePreset; + webcamShadowIntensity: number; /** Where the picture-in-picture camera sits, at a constant distance from the border. */ webcamAnchor: WebcamAnchor; webcamCropRegion: CropRegion; @@ -170,6 +171,7 @@ interface LegacyShape { webcamMirrored?: boolean; webcamReactiveZoom?: boolean; webcamSizePreset?: WebcamSizePreset; + webcamShadowIntensity?: number; webcamAnchor?: WebcamAnchor; /** Written by builds that let the camera be dragged anywhere; read as the nearest anchor. */ webcamPosition?: WebcamPosition | null; @@ -300,6 +302,11 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor webcamMaskShape: webcamMask.shape, webcamRoundness: webcamMask.roundness, webcamMirrored: bool(legacy?.webcamMirrored, DEFAULT_EDITOR_SETTINGS.webcamMirrored), + webcamShadowIntensity: readBounded( + legacy?.webcamShadowIntensity, + "webcamShadowIntensity", + defaults.webcamShadowIntensity, + ), webcamReactiveZoom: bool( legacy?.webcamReactiveZoom, DEFAULT_EDITOR_SETTINGS.webcamReactiveZoom, @@ -360,6 +367,7 @@ export interface EditorSettingsPatch { webcamMirrored?: boolean; webcamReactiveZoom?: boolean; webcamSizePreset?: WebcamSizePreset; + webcamShadowIntensity?: number; webcamAnchor?: WebcamAnchor; webcamCropRegion?: CropRegion; webcamCropPan?: CropPan; diff --git a/src/lib/projectDefaults.ts b/src/lib/projectDefaults.ts index 69439f76cd..b99ea63802 100644 --- a/src/lib/projectDefaults.ts +++ b/src/lib/projectDefaults.ts @@ -210,6 +210,7 @@ export interface ProjectAppearanceDefaults { webcamMirrored: boolean; webcamReactiveZoom: boolean; webcamSizePreset: number; + webcamShadowIntensity: number; webcamAnchor: WebcamAnchor; webcamBackgroundMode: "none" | "transparent" | "blur" | "custom"; webcamWallpaper: string; @@ -256,6 +257,7 @@ export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { webcamMirrored: false, webcamReactiveZoom: true, webcamSizePreset: 40, + webcamShadowIntensity: 0.6, webcamAnchor: "bottom-right", webcamBackgroundMode: "none", webcamWallpaper: "/wallpapers/wallpaper11.jpg", @@ -295,6 +297,7 @@ export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { */ export const SETTING_BOUNDS = { shadowIntensity: [0, 1], + webcamShadowIntensity: [0, 1], backgroundBlur: [0, 1], motionBlurAmount: [0, 1], // `ROUNDNESS_SLIDER_MAX_PX` (src/native/paramUnits.ts); a test holds the two together. diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index 761eadcb29..b1917812ba 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -110,6 +110,19 @@ function makeDoc( // --- background ------------------------------------------------------------ +describe("camera shadow scene contract", () => { + it("serializes independent camera shadow strength for preview and export", () => { + for (const strength of [0, 0.3, 0.6, 0.9]) { + const scene = buildSceneDescription( + makeDoc({ legacyEditor: { webcamShadowIntensity: strength, shadowIntensity: 0.3 } }), + ); + expect(scene.effects.webcamShadow).toBe(strength); + expect(scene.effects.shadow).toBe(0.3); + } + expect(buildSceneDescription(makeDoc()).effects.webcamShadow).toBe(0.6); + }); +}); + describe("buildSceneDescription.background", () => { it('"#123456" → color', () => { const doc = makeDoc({ legacyEditor: { wallpaper: "#123456" } }); diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 3eba47b44d..4cb228f023 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -435,6 +435,8 @@ export interface SceneEffects { blur: number; /** 0..1 drop-shadow strength. */ shadow: number; + /** Camera shadow strength, 0..1; absent payloads retain the historical medium shadow. */ + webcamShadow?: number; /** * Roundness slider, as a fraction of a fixed 1080 px reference (`ROUNDNESS_REFERENCE_PX`). * @@ -1487,6 +1489,7 @@ export function buildSceneDescription( padding: settings.padding / 100, blur: settings.backgroundBlur, shadow: settings.shadowIntensity, + webcamShadow: settings.webcamShadowIntensity, // The slider is in pixels of a 1080 reference, whatever the source resolution — // see `roundnessFrac`. No padding and no frame: the footage meets the scene's edges, // with no background to round against, so the corners go square and the stored value From edadecc7f005e6b5f54a1c4ef486fb4a23ec518c Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 6 Nov 2026 22:37:55 +0100 Subject: [PATCH 2/2] fix: hide the camera shadow control in cutout mode A cutout camera has no bubble, so the compositor draws no shadow there. A disabled row named a setting with no effect; hide it instead. --- .../ai-edition/RightPanes.layout.test.tsx | 10 ++++++++++ src/components/ai-edition/RightPanes.tsx | 17 ++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/components/ai-edition/RightPanes.layout.test.tsx b/src/components/ai-edition/RightPanes.layout.test.tsx index cc26dbd81c..f4cd47e9f9 100644 --- a/src/components/ai-edition/RightPanes.layout.test.tsx +++ b/src/components/ai-edition/RightPanes.layout.test.tsx @@ -379,6 +379,16 @@ describe("LayoutPane picture-in-picture camera", () => { expect(stored()).toMatchObject({ webcamShadowIntensity: 0.9 }); }); + // A cutout camera has no bubble to cast a shadow, so the setting is hidden, not disabled. + it("hides the camera shadow in cutout mode", () => { + const doc = seedProject(true); + renderLayout({ + ...doc, + legacyEditor: { ...doc.legacyEditor, webcamBackgroundMode: "transparent" }, + }); + expect(screen.queryByRole("group", { name: "Shadow" })).toBeNull(); + }); + // The middle of the frame is a hole in the grid, not a ninth place to land. it("steps over the middle of the grid with the arrow keys", () => { renderLayout(seedProject(true)); diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index cea7bb804f..8bed94405e 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -2931,13 +2931,16 @@ export function LayoutPane() { ) : null} {isPip ? ( <> - {namedLevelRow( - ts("effects.shadow"), - SHADOW_LEVELS.map((level) => ({ value: level.value, label: ts(level.labelKey) })), - settings.webcamShadowIntensity, - layoutControlsDisabled || settings.webcamBackgroundMode === "transparent", - (webcamShadowIntensity) => void set({ webcamShadowIntensity }), - )} + {/* A cutout camera has no bubble, so no shadow to tune. */} + {settings.webcamBackgroundMode !== "transparent" + ? namedLevelRow( + ts("effects.shadow"), + SHADOW_LEVELS.map((level) => ({ value: level.value, label: ts(level.labelKey) })), + settings.webcamShadowIntensity, + layoutControlsDisabled, + (webcamShadowIntensity) => void set({ webcamShadowIntensity }), + ) + : null}
{ts("layout.webcamShape")}