Skip to content

Commit 0236f8f

Browse files
committed
fix(editor): offer 3D Orbit only with a recorded cursor
The moving camera follows the cursor under Auto focus, and a take recorded with the system cursor writes no .cursor.json for the compositor to read, so picking 3D Orbit silently held still (#1397). Not offered under Auto focus when the take carries no cursor data; Manual focus still offers it, where the focus point poses it, and a stored orbit stays listed so the row can show it.
1 parent c85e3c7 commit 0236f8f

2 files changed

Lines changed: 91 additions & 7 deletions

File tree

‎src/components/ai-edition/v4/FloatingInspector.test.tsx‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,53 @@ describe("FloatingInspector", () => {
367367
"button",
368368
);
369369

370+
/** A project whose one take recorded `samples` cursor samples, and the read answering it. */
371+
function openTake(samples: number) {
372+
const originalPath = `/recordings/${crypto.randomUUID()}.mp4`;
373+
const asset = assetSchema.parse({ id: "a1", label: "take", originalPath });
374+
const clip = clipSchema.parse({
375+
id: "c1",
376+
assetId: "a1",
377+
sourceStartSec: 0,
378+
sourceEndSec: 10,
379+
timelineStartSec: 0,
380+
timelineEndSec: 10,
381+
origin: "user",
382+
});
383+
const read = vi
384+
.spyOn(nativeBridgeClient.cursor, "getRecordingData")
385+
.mockImplementation(async () => ({
386+
version: 2,
387+
provider: "native",
388+
assets: [],
389+
samples: Array.from({ length: samples }, (_, i) => ({
390+
timeMs: i * 100,
391+
cx: 0.5,
392+
cy: 0.5,
393+
})),
394+
}));
395+
const document = createEmptyDocument({ projectId: "p", title: "t" });
396+
useProjectStore.setState({
397+
projectId: "p",
398+
document: {
399+
...document,
400+
assets: [asset],
401+
timeline: { ...document.timeline, clips: [clip] },
402+
},
403+
});
404+
return read;
405+
}
406+
407+
/** Lets the take's cursor file be read, so an absence is an answer and not a wait. */
408+
async function readTake(read: ReturnType<typeof openTake>) {
409+
await waitFor(() => expect(read).toHaveBeenCalled());
410+
await act(async () => {
411+
await Promise.all(read.mock.results.map((result) => result.value));
412+
});
413+
}
414+
370415
afterEach(() => {
416+
vi.restoreAllMocks();
371417
useProjectStore.setState({ document: null });
372418
});
373419

@@ -459,6 +505,33 @@ describe("FloatingInspector", () => {
459505
editorSettings.autoFocusAll = false;
460506
}
461507
});
508+
509+
// Issue #1397. A take recorded with the system cursor has no cursor file, so Auto focus
510+
// has nothing to read and 3D Orbit would hold still — exactly the camera the reporter
511+
// picked and then watched do nothing.
512+
it("leaves the orbit out under Auto focus when the take has no recorded cursor", async () => {
513+
const read = openTake(0);
514+
const { tl } = zoomTl({ focusMode: "auto" });
515+
render(<FloatingInspector {...defaultProps} tl={tl} />);
516+
await readTake(read);
517+
expect(
518+
screen.queryByRole("button", { name: "settings.zoom.camera.preset.orbit" }),
519+
).toBeNull();
520+
// The fixed angles need no cursor, so they stay.
521+
expect(
522+
screen.getByRole("button", { name: "settings.zoom.camera.preset.left" }),
523+
).toBeInTheDocument();
524+
});
525+
526+
it("offers the orbit under Auto focus once the take has a recorded cursor", async () => {
527+
const read = openTake(3);
528+
const { tl } = zoomTl({ focusMode: "auto" });
529+
render(<FloatingInspector {...defaultProps} tl={tl} />);
530+
await readTake(read);
531+
expect(
532+
await screen.findByRole("button", { name: "settings.zoom.camera.preset.orbit" }),
533+
).toBeInTheDocument();
534+
});
462535
});
463536

464537
describe("cursor facet", () => {

‎src/components/ai-edition/v4/FloatingInspector.tsx‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export function FloatingInspector({
173173
{effectiveOpen ? (
174174
<div className={styles.inspector}>
175175
{selection ? (
176-
<SelectionPane tl={tl} onClose={() => tl.clearSelection()} />
176+
<SelectionPane tl={tl} onClose={() => tl.clearSelection()} hasCursor={hasCursor} />
177177
) : audioTrackSelected ? (
178178
<AudioTrackPane tl={tl} onClose={() => tl.clearSelection()} />
179179
) : (
@@ -743,7 +743,17 @@ export function AnnotationSizeField({
743743
);
744744
}
745745

746-
function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void }) {
746+
function SelectionPane({
747+
tl,
748+
onClose,
749+
hasCursor,
750+
}: {
751+
tl: TimelineApi;
752+
onClose: () => void;
753+
/** Whether a recording on the timeline carries cursor data, as decided once for the whole
754+
* inspector: the 3D Orbit reads it, so a take without one cannot use it under Auto focus. */
755+
hasCursor: boolean;
756+
}) {
747757
const ts = useScopedT("settings");
748758
const tt = useScopedT("timeline");
749759
const tc = useScopedT("common");
@@ -852,11 +862,12 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void }
852862
tiles
853863
options={[
854864
"off" as const,
855-
// The orbit follows the cursor under auto focus, and a hidden cursor drives
856-
// nothing: not offered then, rather than a camera that silently holds still.
857-
// Under manual focus the focus point poses it, cursor or not. Still listed
858-
// once picked, so the row can show it.
859-
...(settings.cursorShow ||
865+
// The orbit follows the cursor under auto focus, so it needs one the compositor
866+
// can read: a recording made with the system cursor writes no cursor file, and
867+
// a hidden cursor drives nothing. Not offered then, rather than a camera that
868+
// silently holds still. Under manual focus the focus point poses it, cursor or
869+
// not. Still listed once picked, so the row can show it.
870+
...((hasCursor && settings.cursorShow) ||
860871
(!autoFocusAll && (region.focusMode ?? "manual") === "manual") ||
861872
region.rotationPreset === "orbit"
862873
? MOVING_ROTATION_3D_PRESETS

0 commit comments

Comments
 (0)