Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/compositor/src/compositor_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,12 @@ impl Compositor {
*self.live_params.borrow_mut() = p;
}

/// Cf. `compositor_windows::set_has_webcam` — le seul champ de `LiveParams` qui dépend du
/// clip courant, rebranché par `walk_composited_timeline` sans écraser le reste.
pub fn set_has_webcam(&self, v: bool) {
self.live_params.borrow_mut().has_webcam = v;
}

pub fn set_scene(&self, s: Option<Scene>) {
*self.scene.borrow_mut() = s;
}
Expand Down
6 changes: 6 additions & 0 deletions crates/compositor/src/compositor_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,12 @@ impl Compositor {
*self.live_params.borrow_mut() = p;
}

/// Cf. `compositor_windows::set_has_webcam` — le seul champ de `LiveParams` qui dépend du
/// clip courant, rebranché par `walk_composited_timeline` sans écraser le reste.
pub fn set_has_webcam(&self, v: bool) {
self.live_params.borrow_mut().has_webcam = v;
}

pub fn set_scene(&self, s: Option<Scene>) {
*self.scene.borrow_mut() = s;
}
Expand Down
8 changes: 8 additions & 0 deletions crates/compositor/src/compositor_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,14 @@ impl Compositor {
*self.live_params.borrow_mut() = p;
}

/// Rebranche le seul champ qui dépend du CLIP et non des réglages (cf. `LiveParams::has_webcam`).
/// L'export pose ses `LiveParams` une fois pour toute la timeline, mais chaque clip a sa propre
/// réponse à « y a-t-il une caméra ? » : d'où un setter ciblé plutôt qu'un `set_live_params`
/// par clip, qui écraserait les réglages posés par l'appelant.
pub fn set_has_webcam(&self, v: bool) {
self.live_params.borrow_mut().has_webcam = v;
}

/// Installe (ou retire) la scène de l'app. Présente → `compose_frame` prend ses placements
/// depuis le layout preset au lieu du planning fixture.
pub fn set_scene(&self, s: Option<Scene>) {
Expand Down
31 changes: 28 additions & 3 deletions crates/compositor/src/frame_geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -601,12 +601,37 @@ pub struct LiveParams {
/// False when the "webcam" decoder is actually just the screen video again (the TS side
/// falls `webcamPath` back to the screen asset's own path when a clip has no real camera,
/// purely so the decoder pipeline has something valid to open) — drawing the PiP box in
/// that case duplicates the screen video into its own corner. Live-only: derived in
/// `live.rs` by comparing the active clip's screen/webcam paths; defaults `true` (draw)
/// so fixture/bench renders and any caller that never sets it keep their old behavior.
/// that case duplicates the screen video into its own corner. Derived per clip from the
/// screen/webcam paths via `webcam_is_real`: in `live.rs` for the preview, in
/// `timeline_walk.rs` for every export. Defaults `true` (draw) so fixture/bench renders
/// and any caller that never sets it keep their old behavior.
pub has_webcam: bool,
}

fn same_source_path(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b)
}

/// True when this clip really has a camera to draw.
///
/// TWO ways the app says "no camera", and both must be caught here, because the
/// webcam decoder is opened either way — the live path falls back to the SCREEN
/// file when the webcam path won't open, and `ExportDialog` sends the screen path
/// outright, so the decoder always yields frames. Whether those frames are the
/// camera or a second copy of the screen is decided HERE and nowhere else.
///
/// - the empty string, which is what `sceneDescription.ts` and
/// `NativeCompositorOverlay` send for an asset with no `cameraTrack`;
/// - the screen's own path, which `ExportDialog.tsx` sends and which older
/// scenes still use.
///
/// Missing the empty-string case is what put the screen recording inside the PiP
/// box: `"" != "/…/recording.mp4"`, so the box was drawn, and the decoder behind
/// it was the screen fallback.
pub fn webcam_is_real(webcam_path: &str, screen_path: &str) -> bool {
!webcam_path.trim().is_empty() && !same_source_path(webcam_path, screen_path)
}

impl Default for LiveParams {
fn default() -> Self {
Self {
Expand Down
21 changes: 1 addition & 20 deletions crates/compositor/src/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use crate::scene::Scene;
use crate::config::{self, Cfg};
use crate::cursor::CursorTrack;
use crate::d3d::Gpu;
use crate::frame_geometry::webcam_is_real;
use crate::pipeline::Decoder;
use crate::timeline_walk::{frame_step, FrameStep, NextFrameTime};
use anyhow::Result;
Expand Down Expand Up @@ -590,26 +591,6 @@ fn same_source_path(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b)
}

/// True when the active clip really has a camera to draw.
///
/// TWO ways the app says "no camera", and both must be caught here, because the
/// webcam decoder is opened either way — `open_and_seek_clip` falls back to the
/// SCREEN file when the webcam path won't open, so `wdec` always yields frames.
/// Whether those frames are the camera or a second copy of the screen is decided
/// HERE and nowhere else.
///
/// - the empty string, which is what `sceneDescription.ts` and
/// `NativeCompositorOverlay` send for an asset with no `cameraTrack`;
/// - the screen's own path, the older convention kept working for scenes that
/// still use it.
///
/// Missing the empty-string case is what put the screen recording inside the PiP
/// box: `"" != "/…/recording.mp4"`, so the box was drawn, and the decoder behind
/// it was the screen fallback.
fn webcam_is_real(webcam_path: &str, screen_path: &str) -> bool {
!webcam_path.trim().is_empty() && !same_source_path(webcam_path, screen_path)
}

fn scene_clip_matches(
clip: &crate::scene::SceneClip,
screen_path: &str,
Expand Down
8 changes: 8 additions & 0 deletions crates/compositor/src/timeline_walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::compositor::Compositor;
use crate::config::Cfg;
use crate::cursor::CursorTrack;
use crate::d3d::Gpu;
use crate::frame_geometry::webcam_is_real;
use crate::pipeline::{ClipSource, Decoder};
use crate::regions::{speed_segments_for_window, SpeedSegment};
use crate::scene::Scene;
Expand Down Expand Up @@ -164,6 +165,13 @@ pub(crate) unsafe fn walk_composited_timeline(
let mut frames: u64 = 0;

for (clip_index, clip) in clips.iter().enumerate() {
// Le preset de layout est GLOBAL (un seul panneau pour toute la timeline) mais la
// caméra est PAR CLIP : un projet mélange sans problème un enregistrement avec webcam
// et un import qui n'en a pas. Le preset ne doit donc s'appliquer qu'aux clips qui ont
// vraiment une caméra — sinon la boîte PiP est dessinée avec, derrière, le décodeur de
// repli, c'est-à-dire l'écran lui-même recopié dans son propre coin (issue #248).
// La preview vive fait exactement ça dans `live.rs` ; c'est ici l'équivalent export.
comp.set_has_webcam(webcam_is_real(&clip.webcam, &clip.screen));
if !screen_decs.contains_key(&clip.screen) {
screen_decs.insert(clip.screen.clone(), Decoder::open(&clip.screen, gpu)?);
}
Expand Down
7 changes: 6 additions & 1 deletion crates/poc-d3d/src/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub fn run() -> Result<()> {

// poc-d3d.exe --cfg C0..C8 --fixture <dir> --repeat 3 --out out/
// --cfg GIF → bench natif GIF (slice 1)
// --webcam <path> → force le chemin caméra (défaut `<fixture>/webcam.mp4`)
fn run_bench(args: &[String]) -> Result<()> {
let get = |k: &str, d: &str| -> String { arg(args, k, d) };
let fixture = get("--fixture", "fixture");
Expand All @@ -56,7 +57,11 @@ fn run_bench(args: &[String]) -> Result<()> {
let cfg_arg = get("--cfg", "C0..C8");

let screen = format!("{fixture}/screen.mp4");
let webcam = format!("{fixture}/webcam.mp4");
// Override explicite parce que le cas « pas de caméra » n'est PAS un fichier
// différent : l'app renvoie le chemin de l'écran lui-même (`ExportDialog`) ou la
// chaîne vide (`sceneDescription`). Le reproduire demande donc de piloter le chemin,
// pas le contenu — `--webcam <screen.mp4>` rejoue exactement l'issue #248.
let webcam = get("--webcam", &format!("{fixture}/webcam.mp4"));
std::fs::create_dir_all(&out).ok();

// sélection des cfg
Expand Down
32 changes: 19 additions & 13 deletions src/components/ai-edition/PreviewCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,21 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
);
const cropRegion: CropRegion = activeClip?.cropRegion ?? DEFAULT_CROP_REGION;

// P4 — the layout preset is global (one panel for the whole timeline) but the camera
// is per clip, so the layout has to be resolved against the clip under the playhead.
const activeCameraTrack = useMemo(
() => resolveActiveCameraTrack(assets, props.clips, props.currentTimeSec),
[assets, props.clips, props.currentTimeSec],
);
const activeClipHasCamera = Boolean(activeCameraTrack?.visible && activeCameraTrack.sourcePath);

const layout = useMemo(() => {
const preset = settings.webcamLayoutPreset as WebcamLayoutPreset;
// A clip with no camera lays out as "no-webcam", whatever the panel says. Hiding
// only the webcam slot is not enough: the block presets size the SCREEN off the
// block, so the screen stayed squeezed into its half with nothing beside it.
const preset = (
activeClipHasCamera ? settings.webcamLayoutPreset : "no-webcam"
) as WebcamLayoutPreset;
const mask = settings.webcamMaskShape as WebcamMaskShape;
// ponytail: padding shrinks the available content area for ALL layouts
// (PiP/dual/stack) so the screen doesn't fill the canvas edge-to-edge.
Expand All @@ -240,7 +253,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
canvasSize: frameSize,
maxContentSize,
screenSize: croppedScreenSize,
webcamSize: settings.webcamLayoutPreset === "no-webcam" ? null : WEBCAM_SOURCE_SIZE,
webcamSize: preset === "no-webcam" ? null : WEBCAM_SOURCE_SIZE,
layoutPreset: preset,
webcamSizePreset: settings.webcamSizePreset,
// ponytail: PiP webcam is grabbable. Pass through the user's
Expand All @@ -254,6 +267,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
frameSize,
screenNativeSize,
cropRegion,
activeClipHasCamera,
settings.webcamLayoutPreset,
settings.webcamMaskShape,
settings.webcamSizePreset,
Expand Down Expand Up @@ -293,17 +307,9 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
() => buildWebcamStyle(effectiveLayout, settings, frameSize),
[effectiveLayout, settings, frameSize],
);
// P4 — the layout math above only knows the user's chosen preset
// (PiP/dual/stack), not whether the clip under the playhead actually has a
// camera. Without this, an empty (but styled — shadow, background) webcam
// slot stays visible for clips with no camera attached.
const activeCameraTrack = useMemo(
() => resolveActiveCameraTrack(assets, props.clips, props.currentTimeSec),
[assets, props.clips, props.currentTimeSec],
);
const showWebcamSlot = Boolean(
layout?.webcamRect && activeCameraTrack?.visible && activeCameraTrack.sourcePath,
);
// `layout` already resolves to "no-webcam" (hence `webcamRect: null`) for a
// camera-less clip, so this is belt-and-braces rather than the only guard.
const showWebcamSlot = Boolean(layout?.webcamRect && activeClipHasCamera);
const [isPlaying, setIsPlaying] = useState(false);
const handleVideoElement = useMemo(() => props.onVideoElement, [props.onVideoElement]);
// L'élément `<video>` lui-même n'est plus retenu : il ne servait qu'à échantillonner des pixels
Expand Down
74 changes: 74 additions & 0 deletions src/native/sceneDescription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,10 @@ describe("buildSceneDescription.settings mapping", () => {
id: "a",
originalPath: "/a.mp4",
video: { codec: "h264", width: 1920, height: 1080, fps: 30 },
// The camera the preset presupposes. A clip WITHOUT one lays out as
// "no-webcam" whatever the panel says (see the camera-less cases below),
// so there would be no rect here to convert.
cameraTrack: { sourcePath: "/a-webcam.mp4", startMs: 0, offsetMs: 0, visible: true },
});
const doc = makeDoc({
assets: [asset],
Expand Down Expand Up @@ -1275,6 +1279,76 @@ describe("buildSceneDescription.layout.layoutByClip", () => {
const scene = buildSceneDescription(twoClipDoc());
expect(scene.layout.screenRect).toEqual(scene.layout.layoutByClip?.[0]?.screenRect);
});

// issue #248 — le preset est GLOBAL (un seul panneau) mais la camera est PAR CLIP.
// Un projet melange sans probleme un enregistrement avec webcam et un import qui n'en
// a pas ; le reglage ne doit s'appliquer qu'aux clips qui ont vraiment une camera.
const mixedDoc = (preset: string) => {
const withCam = makeAsset({
id: "cam",
originalPath: "/rec.mp4",
video: { codec: "h264", width: 1920, height: 1080, fps: 30 },
cameraTrack: { sourcePath: "/rec-webcam.webm", startMs: 0, offsetMs: 0, visible: true },
});
const noCam = makeAsset({
id: "nocam",
originalPath: "/import.mp4",
video: { codec: "h264", width: 1920, height: 1080, fps: 30 },
});
return makeDoc({
assets: [withCam, noCam],
clips: [
makeClip({
id: "c1",
assetId: "cam",
sourceStartSec: 0,
sourceEndSec: 4,
timelineStartSec: 0,
timelineEndSec: 4,
}),
makeClip({
id: "c2",
assetId: "nocam",
sourceStartSec: 0,
sourceEndSec: 4,
timelineStartSec: 4,
timelineEndSec: 8,
}),
],
legacyEditor: { webcamLayoutPreset: preset },
});
};

it.each([
"picture-in-picture",
"dual-frame",
"vertical-stack",
])("under %s, only the clip that HAS a camera gets a webcam rect", (preset) => {
const byClip = buildSceneDescription(mixedDoc(preset)).layout.layoutByClip;
if (!byClip) throw new Error("layoutByClip absent");
expect(byClip[0]?.webcamRect).not.toBeNull();
expect(byClip[1]?.webcamRect).toBeNull();
});

// Le coeur du bug rapporte : les presets en bloc dimensionnent l'ECRAN sur le bloc.
// Masquer la seule vignette ne suffit pas — sans ca le clip sans camera gardait un
// ecran comprime dans sa moitie, avec du vide a cote.
it.each([
"dual-frame",
"vertical-stack",
])("under %s, the camera-less clip gets its full frame back", (preset) => {
const scene = buildSceneDescription(mixedDoc(preset));
const byClip = scene.layout.layoutByClip;
if (!byClip) throw new Error("layoutByClip absent");
const blocked = byClip[0]?.screenRect;
const alone = byClip[1]?.screenRect;
if (!blocked || !alone) throw new Error("entree manquante");
// Meme source (1920x1080) des deux cotes : la seule difference est la camera.
const area = (r: { width: number; height: number }) => r.width * r.height;
expect(area(alone)).toBeGreaterThan(area(blocked) * 1.2);
// Et il retrouve le ratio de sa source, au lieu du slot du bloc.
expect(aspectOf(alone, scene.output)).toBeCloseTo(1920 / 1080, 1);
});
});

// --- annotations -----------------------------------------------------------
Expand Down
40 changes: 28 additions & 12 deletions src/native/sceneDescription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,24 +571,40 @@ export function buildSceneDescription(
height: Math.max(1, Math.round((video?.height || 1080) * (crop?.height ?? 1))),
};
};
const layoutForScreenSize = (screenSize: { width: number; height: number }) =>
computeCompositeLayout({
/** Does THIS clip have a camera to lay out? Same expression as the `webcamPath` sent
* with the clip above, so the layout and the decoder can never disagree about it.
* Note this is NOT `hasAnyClipWithCamera` (which gates the Layout panel): that one
* ignores `visible` on purpose, so the panel stays reachable to un-hide a camera. */
const clipHasCamera = (clip: AxcutClip) => {
const cam = assetById.get(clip.assetId)?.cameraTrack;
return Boolean(cam?.visible && cam.sourcePath);
};
/**
* The layout preset is GLOBAL — one panel for the whole timeline — but the camera is
* per clip: a project mixes a screen+webcam recording with a plain import that has
* none. A clip with no camera must lay out as if the preset were "no-webcam".
*
* Skipping this is not merely a stray thumbnail: the block presets (`dual-frame`,
* `vertical-stack`) size the SCREEN off the block, so a camera-less clip kept the
* screen squeezed into its half with nothing beside it. `has_webcam` (native) only
* gates the camera's own draw — it cannot give the screen its frame back.
*/
const layoutForClip = (screenSize: { width: number; height: number }, hasCamera: boolean) => {
const preset = hasCamera ? settings.webcamLayoutPreset : "no-webcam";
return computeCompositeLayout({
canvasSize: outputDims,
maxContentSize: {
width: Math.round(outputDims.width * paddingFit),
height: Math.round(outputDims.height * paddingFit),
},
screenSize,
webcamSize:
settings.webcamLayoutPreset === "no-webcam"
? null
: (webcamSourceSize ?? { width: 960, height: 720 }),
layoutPreset: settings.webcamLayoutPreset,
webcamSize: preset === "no-webcam" ? null : (webcamSourceSize ?? { width: 960, height: 720 }),
layoutPreset: preset,
webcamSizePreset: settings.webcamSizePreset,
webcamPosition:
settings.webcamLayoutPreset === "picture-in-picture" ? settings.webcamPosition : null,
webcamPosition: preset === "picture-in-picture" ? settings.webcamPosition : null,
webcamMaskShape: settings.webcamMaskShape,
});
};
const toFrameFractions = (r: RenderRect) => ({
x: r.x / outputDims.width,
y: r.y / outputDims.height,
Expand All @@ -603,7 +619,7 @@ export function buildSceneDescription(
const shortSide = box ? Math.min(box.width, box.height) : 0;
return box && shortSide > 0 && radius != null ? radius / shortSide : null;
};
const resolvedLayoutOf = (layout: ReturnType<typeof layoutForScreenSize>) =>
const resolvedLayoutOf = (layout: ReturnType<typeof layoutForClip>) =>
layout
? {
screenRect: toFrameFractions(layout.screenRect),
Expand All @@ -618,12 +634,12 @@ export function buildSceneDescription(
// `for_clip_window` (Rust) selects the entry for the clip being composed, so the
// draw path keeps reading a single `layout` and needs no per-clip branch of its own.
const layoutByClip = visibleClips.map((clip, index) =>
resolvedLayoutOf(layoutForScreenSize(screenSourceSizeOf(clip, index))),
resolvedLayoutOf(layoutForClip(screenSourceSizeOf(clip, index), clipHasCamera(clip))),
);
// Scalar fields stay the FIRST clip's layout: they are the fallback for a payload
// without `layoutByClip`, and the value native starts from before any clip is active.
const computedLayout = visibleClips[0]
? layoutForScreenSize(screenSourceSizeOf(visibleClips[0], 0))
? layoutForClip(screenSourceSizeOf(visibleClips[0], 0), clipHasCamera(visibleClips[0]))
: null;
const webcamRect = computedLayout?.webcamRect
? toFrameFractions(computedLayout.webcamRect)
Expand Down
Loading