diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 586cfc19b..aca4c6dad 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 e7410ad55..cf980cd51 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 2a479c8dd..a8f3b08ec 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 e81035153..f4cd47e9f 100644 --- a/src/components/ai-edition/RightPanes.layout.test.tsx +++ b/src/components/ai-edition/RightPanes.layout.test.tsx @@ -366,6 +366,29 @@ 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 }); + }); + + // 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 1e0c67410..8bed94405 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -2931,6 +2931,16 @@ export function LayoutPane() { ) : null} {isPip ? ( <> + {/* 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")}
diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 7acecab2a..84c027f3f 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 61284e211..a8d343260 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 69439f76c..b99ea6380 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 761eadcb2..b1917812b 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 3eba47b44..4cb228f02 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