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
29 changes: 25 additions & 4 deletions crates/compositor/src/timeline_walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,15 +171,36 @@ pub(crate) unsafe fn walk_composited_timeline(
// 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));
// Source webcam, clé de cache et dessin de la PiP sont décidés ENSEMBLE, sinon
// ils divergent :
//
// - Un clip SANS caméra arrive avec un chemin webcam vide, que `Decoder::open`
// refuse. Le décodeur n'existe que parce que `compose_frame` échantillonne
// deux flux inconditionnellement, donc on lui redonne l'écran (même repli que
// `live.rs::open_and_seek_clip`) et la PiP n'est pas dessinée. Sans ça,
// exporter un projet sans caméra échouerait net — le cas le plus courant
// (issue #348).
// - La clé DOIT être le fichier réellement ouvert. Tous les clips sans caméra
// portent le même chemin vide : indexer dessus faisait que le deuxième
// récupérait le décodeur du premier, donc l'écran d'un AUTRE clip. Pas
// anodin même sans PiP, `webcam_available_duration` plus bas borne
// `source_end_sec` — un clip de 60s derrière un clip de 41s finissait à 41s.
// - Un chemin NON vide qui refuse de s'ouvrir n'est pas un repli : c'est une
// caméra que le document réclame et qu'on ne peut pas fournir. L'erreur
// remonte, comme avant l'ajout du repli. La rattraper par l'écran donnerait
// exactement #265 — `webcam_is_real` reste vrai pour ce chemin, donc l'écran
// serait recopié dans sa propre vignette.
let has_camera = webcam_is_real(&clip.webcam, &clip.screen);
comp.set_has_webcam(has_camera);
let webcam_key = if has_camera { &clip.webcam } else { &clip.screen };
if !screen_decs.contains_key(&clip.screen) {
screen_decs.insert(clip.screen.clone(), Decoder::open(&clip.screen, gpu)?);
}
if !webcam_decs.contains_key(&clip.webcam) {
webcam_decs.insert(clip.webcam.clone(), Decoder::open(&clip.webcam, gpu)?);
if !webcam_decs.contains_key(webcam_key) {
webcam_decs.insert(webcam_key.clone(), Decoder::open(webcam_key, gpu)?);
}
let sdec = screen_decs.get_mut(&clip.screen).unwrap();
let wdec = webcam_decs.get_mut(&clip.webcam).unwrap();
let wdec = webcam_decs.get_mut(webcam_key).unwrap();

let screen_available_duration = sdec.available_duration_sec();
let webcam_available_duration = wdec.available_duration_sec();
Expand Down
71 changes: 71 additions & 0 deletions electron/ai-edition/document-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,77 @@ describe("DocumentService", () => {
await expect(service.getProject("proj/with/slash")).rejects.toBeInstanceOf(ProjectFileError);
});

// Issue #348 — recording with no camera AND no microphone is the default for
// anyone capturing a screen demo, and the failure lands at REOPEN, where the
// recording exists on disk but the user cannot get to it. The recorder writes
// no audio stream at all in that configuration (confirmed with ffprobe on real
// captures) and `cameraTrack: null`, so this is the exact on-disk shape.
describe("camera-less, microphone-less recordings", () => {
// Windows path separators on purpose: the reporter is on Windows 11 and
// `path.join` gives us the host's, so this stays honest on all three.
async function writeCamlessProject(originalPath: string, sizeBytes?: number) {
const doc = await service.createProject("Screen demo, no cam no mic");
const asset: AxcutAsset = {
id: "asset_camless",
kind: "video",
label: path.basename(originalPath),
originalPath,
sizeBytes,
// No `audio` (the probe never populates it) and no camera link.
cameraTrack: null,
transcriptionFailure: {
kind: "no-audio",
message: "No audio track found in this video.",
},
};
await service.saveProject({
...doc,
assets: [asset],
project: { ...doc.project, primaryAssetId: asset.id },
});
return doc.project.id;
}

it("reopens, and stays listed", async () => {
const screenPath = path.join(mediaDir, "screen-demo.mp4");
await fs.writeFile(screenPath, "screen bytes", "utf8");
const projectId = await writeCamlessProject(screenPath);

const reopened = await service.getProject(projectId);
expect(reopened.assets[0]?.cameraTrack).toBeNull();
expect(reopened.assets[0]?.originalPath).toBe(screenPath);
// A document that throws here is dropped by listProjects' skip-on-error
// catch, which presents to the user as "my project vanished" rather than
// as an error — so the absence of a throw is not enough to assert.
const summaries = await service.listProjects();
expect(summaries.map((s) => s.id)).toContain(projectId);
// Re-decided on every open, so it must survive the round trip or the
// whole recording is re-extracted for transcription each time.
expect(reopened.assets[0]?.transcriptionFailure?.kind).toBe("no-audio");
});

it("does not hand the relinker's webcam to an asset that never had one", async () => {
// The relink only runs when something is actually broken, so move the
// screen file — and register a link that DOES carry a webcam, which is
// the shape that produced #265 (screen recording used as the webcam).
const screenBytes = "screen bytes";
const screenPath = path.join(mediaDir, "moved-screen-demo.mp4");
const webcamPath = path.join(mediaDir, "moved-screen-demo-webcam.mp4");
await fs.writeFile(screenPath, screenBytes, "utf8");
await fs.writeFile(webcamPath, "webcam bytes", "utf8");
await registerMediaLinks(mediaDir, screenPath, { webcamVideoPath: webcamPath });

const projectId = await writeCamlessProject(
path.join(mediaDir, "gone", "moved-screen-demo.mp4"),
Buffer.byteLength(screenBytes),
);

const reopened = await service.getProject(projectId);
expect(reopened.assets[0]?.originalPath).toBe(screenPath);
expect(reopened.assets[0]?.cameraTrack).toBeNull();
});
});

// Issue #212 — a project authored on another machine opens with every asset
// pointing at a path that does not exist here. The relink runs on this read,
// not on import, so a document already saved broken still recovers.
Expand Down
7 changes: 7 additions & 0 deletions electron/media/cursorSidecar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
readCursorSidecar,
readCursorTelemetryFile,
} from "./cursorSidecar";
import { whenRegistryIdle } from "./mediaLinksRegistry";

let dir: string;

Expand All @@ -25,6 +26,12 @@ beforeEach(async () => {
});

afterEach(async () => {
// The registry fallback below starts a path-refresh write that the lookup
// deliberately does not await, so it can still be queued when the test ends.
// Removing the tree underneath it made `fs.rm` fail with ENOTEMPTY — the write
// recreating an entry between rm's recursive walk and its final rmdir — which
// failed this hook, intermittently, only in the full parallel suite.
await whenRegistryIdle();
await fs.rm(dir, { recursive: true, force: true });
vi.restoreAllMocks();
});
Expand Down
27 changes: 27 additions & 0 deletions electron/media/mediaLinksRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
findMediaLinksByFingerprint,
findRelocatedMediaByStoredPath,
registerMediaLinks,
whenRegistryIdle,
} from "./mediaLinksRegistry";

async function makeTempDir(): Promise<string> {
Expand Down Expand Up @@ -262,6 +263,12 @@ describe("mediaLinksRegistry", () => {
process.on("unhandledRejection", onRejection);
try {
await fn();
// The refresh these cases are about is deliberately not awaited by the
// lookup, so `fn` returns while it is still queued. Waiting for the
// queue to drain is what makes "did the refresh warn / write?"
// answerable at all — the 50 ms below used to be doing that job by
// accident, and lost the race whenever the suite ran under load.
await whenRegistryIdle();
// Node decides a rejection is unhandled a tick after the microtask
// queue drains, so the assertion needs a real timer, not a flush.
await new Promise((resolve) => setTimeout(resolve, 50));
Expand Down Expand Up @@ -304,6 +311,26 @@ describe("mediaLinksRegistry", () => {
}
});

// The drain the two cases above rely on. Without it there is no way to know
// the refresh has landed: the lookup returns while the write is still
// queued, so a caller that removes the directory races it and a test that
// asserts on its outcome is asserting on a coin flip. Both were real
// intermittent failures in the full suite (this file, and cursorSidecar's
// `afterEach` failing with ENOTEMPTY), green in isolation every time.
it("whenRegistryIdle waits for a refresh the lookup did not await", async () => {
const { original, moved } = await registerThenMove();
const recorded = async () =>
JSON.parse(await fs.readFile(path.join(tempDir, "media-links.registry.json"), "utf-8"))
.entries[0].lastKnownPath;

expect(await recorded()).toBe(original);
await findMediaLinksByFingerprint(tempDir, moved);
await whenRegistryIdle(tempDir);

// Durably on disk, not "probably by now".
expect(await recorded()).toBe(moved);
});

it("survives the directory disappearing while the refresh is queued", async () => {
// The CI shape: a suite's `afterEach` removes its temp dir while a write
// is still in the queue. Whoever wins the race is fine — what must not
Expand Down
27 changes: 27 additions & 0 deletions electron/media/mediaLinksRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,33 @@ function withWriteLock<T>(baseDir: string, fn: () => Promise<T>): Promise<T> {
return result;
}

/**
* Resolves once every write queued for `baseDir` — or for every directory, with
* no argument — has drained.
*
* `findMediaLinksByFingerprint` refreshes a drifted path WITHOUT awaiting it, on
* purpose (a lookup must not pay for a write it does not need). That leaves work
* running after the call that started it returned, and nothing could wait for it:
* a caller that then removed the directory raced the write, and a test that
* asserted on the write's outcome was asserting on a coin flip. Both showed up as
* intermittent failures in the full suite and passed in isolation, which is the
* signature of exactly this.
*
* The queue tails never reject (see `withWriteLock`), so this never throws — it is
* "the writes are done", not "the writes succeeded".
*/
export async function whenRegistryIdle(baseDir?: string): Promise<void> {
for (;;) {
const tails = baseDir ? [writeQueues.get(baseDir)] : [...writeQueues.values()];
const pending = tails.filter((t): t is Promise<unknown> => t !== undefined);
if (pending.length === 0) return;
// A drained write can have queued another behind it, so loop rather than
// await once. `withWriteLock` drops its own key when the chain goes idle,
// which is what eventually empties the map and ends this.
await Promise.all(pending);
}
}

async function updateRegistry(
baseDir: string,
mutator: (file: MediaLinksRegistryFile) => MediaLinksRegistryFile,
Expand Down
7 changes: 4 additions & 3 deletions src/cli/CliExportRunner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { applyProbedDuration } from "@/lib/ai-edition/document/timeline";
import type { AxcutDocument } from "@/lib/ai-edition/schema";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration";
import { DEFAULT_ZOOM_DEPTH, ZOOM_DEPTH_SCALES } from "@/lib/ai-edition/timeline/zoom-scale";
import { buildAutoZoomSuggestions } from "@/lib/ai-edition/timeline/zoom-suggestions";
Expand Down Expand Up @@ -83,15 +84,15 @@ function buildNativeClipList(axcutDocument: AxcutDocument): CompositorClipInput[
if (!asset?.originalPath) {
return [];
}
const cam = asset.cameraTrack;
const camera = assetCameraSource(asset);
const sourceEndSec = resolveClipSourceEndSec(clip, asset);
return [
{
screenPath: asset.originalPath,
webcamPath: cam?.sourcePath ?? asset.originalPath,
webcamPath: camera.path,
sourceStartSec: clip.sourceStartSec,
sourceEndSec,
webcamOffsetSec: cam ? (cam.startMs + cam.offsetMs) / 1000 : 0,
webcamOffsetSec: camera.offsetSec,
hasAudio: true,
},
];
Expand Down
16 changes: 7 additions & 9 deletions src/components/ai-edition/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "@/lib/ai-edition/document/outputFormat";
import type { AxcutDocument } from "@/lib/ai-edition/schema";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration";
import {
type ExportFormat,
Expand Down Expand Up @@ -62,24 +63,21 @@ function buildNativeClipList(document: AxcutDocument): CompositorClipInput[] {
if (!asset?.originalPath) {
return [];
}
const cam = asset.cameraTrack;
const camera = assetCameraSource(asset);
// sourceEndSec is optional in the schema (unknown until probed) — fall back through
// the single canonical precedence used by every consumer (clip.probe → asset.duration
// → timeline-length guess). See `resolveClipSourceEndSec` for the full order.
const sourceEndSec = resolveClipSourceEndSec(clip, asset);
// ponytail: matches the rule in `buildSceneDescription` — screen recordings
// from this app always carry a decodable audio track (the webcam path
// never does), so the only clips that reach this branch already have audio.
// If a per-asset audio-probe flag lands on the schema later, swap to
// `Boolean(asset.audio)` here too and keep these two derivation paths in
// lock-step with `buildSceneDescription` in src/native/sceneDescription.ts.
// ponytail: `hasAudio` stays optimistic for the same reason as in
// `buildSceneDescription` — nothing populates `asset.audio` yet, and the
// native side degrades cleanly on a stream-less file.
return [
{
screenPath: asset.originalPath,
webcamPath: cam?.sourcePath ?? asset.originalPath,
webcamPath: camera.path,
sourceStartSec: clip.sourceStartSec,
sourceEndSec,
webcamOffsetSec: cam ? (cam.startMs + cam.offsetMs) / 1000 : 0,
webcamOffsetSec: camera.offsetSec,
hasAudio: true,
},
];
Expand Down
16 changes: 8 additions & 8 deletions src/components/ai-edition/NativeCompositorOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useScopedT } from "@/contexts/I18nContext";
import { noteUiProbeClipSwitch } from "@/lib/ai-edition/perf/uiFrameProbe";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { assetCameraSource } from "@/lib/ai-edition/timeline/camera";
import { resolveNativePosition } from "@/lib/ai-edition/timeline/timelineMap";
import {
pushAllNativeParams,
Expand Down Expand Up @@ -89,12 +90,12 @@ export function NativeCompositorOverlay() {
if (!primary?.originalPath) {
return {};
}
// `undefined` rather than `""` here ONLY because `useNativeCompositorView`
// treats the key's absence as "no webcam source"; the value still comes from
// the one accessor, so it can never disagree with the scene or the export.
return {
screenPath: primary.originalPath,
webcamPath:
primary.cameraTrack?.visible && primary.cameraTrack.sourcePath
? primary.cameraTrack.sourcePath
: undefined,
webcamPath: assetCameraSource(primary).path || undefined,
// sidecar convention (electron/ipc/handlers.ts readCursorRecordingFile) : la
// télémétrie curseur vit à côté de la vidéo tant qu'elle n'a pas bougé. Absente →
// le natif ignore juste le curseur (CursorTrack::load échoue silencieusement).
Expand Down Expand Up @@ -196,8 +197,7 @@ export function NativeCompositorOverlay() {
if (!asset?.originalPath) {
return;
}
const cam = asset.cameraTrack;
const webcamPath = cam && cam.visible && cam.sourcePath ? cam.sourcePath : "";
const camera = assetCameraSource(asset);
const targetClipId = activeClipId;
// Sonde de fluidité (diagnostic) : sépare les mesures d'avant et d'après un
// franchissement de clip, qui se sont déjà révélées non comparables.
Expand All @@ -214,8 +214,8 @@ export function NativeCompositorOverlay() {
setActiveClip(
viewId,
asset.originalPath,
webcamPath,
cam ? (cam.startMs + cam.offsetMs) / 1000 : 0,
camera.path,
camera.offsetSec,
activeClipIndex,
activeSourceTimeSec,
)
Expand Down
Loading
Loading