55// the saved preference is left untouched on disk and the help popover says so.
66
77import "@testing-library/jest-dom" ;
8- import { cleanup , fireEvent , render , screen , within } from "@testing-library/react" ;
8+ import { cleanup , fireEvent , render , screen , waitFor , within } from "@testing-library/react" ;
99import userEvent from "@testing-library/user-event" ;
1010import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
1111import { I18nProvider } from "@/contexts/I18nContext" ;
@@ -14,6 +14,15 @@ import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema
1414import { useProjectStore } from "@/lib/ai-edition/store/projectStore" ;
1515import { LayoutPane } from "./RightPanes" ;
1616
17+ const segmentation = vi . hoisted ( ( ) => ( { supported : false , segmentCameraFrame : vi . fn ( ) } ) ) ;
18+ vi . mock ( "@/native/hooks/useSegmentationSupport" , ( ) => ( {
19+ useCanSegmentCamera : ( ) => segmentation . supported ,
20+ } ) ) ;
21+ vi . mock ( "@/native/compositorViewClient" , async ( importOriginal ) => ( {
22+ ...( await importOriginal < typeof import ( "@/native/compositorViewClient" ) > ( ) ) ,
23+ segmentCameraFrame : segmentation . segmentCameraFrame ,
24+ } ) ) ;
25+
1726function seedProject ( hasCamera : boolean ) : AxcutDocument {
1827 const base = createEmptyDocument ( { projectId : "project_layout" , title : "Layout" } ) ;
1928 return {
@@ -154,6 +163,72 @@ describe("LayoutPane webcam framing picture", () => {
154163 } ) ;
155164} ) ;
156165
166+ // The thumbnail shows the camera background the preview draws. The promise that makes it
167+ // cheap: nothing runs while the background is the camera's own, and the frame is segmented
168+ // once, every later change of mode, blur or wallpaper being CSS over the same mask.
169+ describe ( "LayoutPane webcam framing background" , ( ) => {
170+ beforeEach ( ( ) => {
171+ segmentation . supported = true ;
172+ segmentation . segmentCameraFrame . mockResolvedValue ( new Uint8Array ( 256 * 144 ) . fill ( 255 ) ) ;
173+ // jsdom has no 2D canvas; the thumbnail only draws into one and reads it back.
174+ vi . spyOn ( HTMLCanvasElement . prototype , "getContext" ) . mockReturnValue ( {
175+ drawImage : vi . fn ( ) ,
176+ getImageData : ( _x : number , _y : number , w : number , h : number ) => ( {
177+ data : new Uint8ClampedArray ( w * h * 4 ) ,
178+ } ) ,
179+ createImageData : ( w : number , h : number ) => ( { data : new Uint8ClampedArray ( w * h * 4 ) } ) ,
180+ putImageData : vi . fn ( ) ,
181+ } as unknown as CanvasRenderingContext2D ) ;
182+ vi . spyOn ( HTMLCanvasElement . prototype , "toDataURL" ) . mockReturnValue ( "data:image/png;base64,AA" ) ;
183+ } ) ;
184+ afterEach ( ( ) => {
185+ segmentation . supported = false ;
186+ segmentation . segmentCameraFrame . mockReset ( ) ;
187+ vi . restoreAllMocks ( ) ;
188+ } ) ;
189+
190+ const withBackground = ( webcamBackgroundMode : string ) => {
191+ const doc = seedProject ( true ) ;
192+ return { ...doc , legacyEditor : { ...doc . legacyEditor , webcamBackgroundMode } } ;
193+ } ;
194+ /** Loads the thumbnail's frame the way the browser would: metadata, then the seek. */
195+ const showFrame = ( ) => {
196+ const video = document . querySelector ( '[class*="framingVideo"]' ) as HTMLVideoElement ;
197+ Object . defineProperty ( video , "videoWidth" , { configurable : true , value : 640 } ) ;
198+ Object . defineProperty ( video , "videoHeight" , { configurable : true , value : 480 } ) ;
199+ fireEvent . loadedMetadata ( video ) ;
200+ fireEvent . seeked ( video ) ;
201+ return video ;
202+ } ;
203+ const backdrop = ( ) =>
204+ document . querySelector ( '[class*="framingBox"] > div[aria-hidden="true"]' ) as HTMLElement | null ;
205+
206+ it ( "segments nothing while the camera keeps its own background" , ( ) => {
207+ renderLayout ( withBackground ( "none" ) ) ;
208+ const video = showFrame ( ) ;
209+
210+ expect ( segmentation . segmentCameraFrame ) . not . toHaveBeenCalled ( ) ;
211+ expect ( video . style . maskImage ) . toBe ( "" ) ;
212+ expect ( backdrop ( ) ) . toBeNull ( ) ;
213+ } ) ;
214+
215+ it ( "cuts the subject out once, then restyles the background around it" , async ( ) => {
216+ renderLayout ( withBackground ( "blur" ) ) ;
217+ const video = showFrame ( ) ;
218+
219+ await waitFor ( ( ) => expect ( video . style . maskImage ) . toContain ( "data:image/png" ) ) ;
220+ expect ( backdrop ( ) ?. style . filter ) . toMatch ( / ^ b l u r \( / ) ;
221+
222+ fireEvent . click ( screen . getByRole ( "button" , { name : "Custom" } ) ) ;
223+ expect ( backdrop ( ) ?. style . filter ) . toBe ( "" ) ;
224+ fireEvent . click ( screen . getByRole ( "button" , { name : "Cutout" } ) ) ;
225+ expect ( backdrop ( ) ) . toBeNull ( ) ;
226+ expect ( video . style . maskImage ) . toContain ( "data:image/png" ) ;
227+
228+ expect ( segmentation . segmentCameraFrame ) . toHaveBeenCalledTimes ( 1 ) ;
229+ } ) ;
230+ } ) ;
231+
157232describe ( "LayoutPane picture-in-picture camera" , ( ) => {
158233 const stored = ( ) => useProjectStore . getState ( ) . document ?. legacyEditor as Record < string , unknown > ;
159234
0 commit comments