Skip to content

Commit cec4973

Browse files
committed
fix(compositor): steep roll-free Left and Right, iso folded into Left
The maintainer picked the steep look for the fixed angles: Left [-23, -25, 0] (the old iso look) and its mirror Right [-23, 25, 0], zero roll. Both sit in the high pitch band, so the dynamic budget is back to +-1.9 X / +-3 Y and the click impact to 1.9 degrees. iso is no longer offered. A stored iso reads as left at the document schema (every load parses through it) and in the legacy reader (readRotation3DPreset); the compositor also maps it. The picker, the label map and the 15 locales drop iso; Left and Right read "turned left/right and seen from above". Tests: the budget-tightness test is re-derived for the combined sweep (Y alone stays tight), and the 15 px corner-click floor is back. The modelled cursor's box bounds its penumbra by the shader's real walk: under Right the camera-fixed light grazes the plane (lz 0.2) and the shadow overran the box.
1 parent 127b0ee commit cec4973

28 files changed

Lines changed: 172 additions & 169 deletions

‎crates/compositor/src/frame_geometry.rs‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3689,7 +3689,10 @@ impl ModelView {
36893689
])
36903690
});
36913691
let top = corners.iter().fold(0.0f32, |m, p| m.max(p[2])) / self.unit;
3692-
let penumbra = (top / lz / MODEL_SOFTNESS).min(MODEL_SHADOW_PAD);
3692+
// La marche du shader court jusqu'à la sortie de la boîte ÉLARGIE de `MODEL_SHADOW_PAD` :
3693+
// sous une lumière rasante (`right`, `lz` ≈ 0,2), le rayon y va bien au-delà de `top / lz`,
3694+
// et la pénombre avec lui.
3695+
let penumbra = ((top + MODEL_SHADOW_PAD) / lz / MODEL_SOFTNESS).min(MODEL_SHADOW_PAD);
36933696
let reach = (penumbra / lz + MODEL_CONTACT_RADIUS) * self.unit;
36943697
let mut b = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
36953698
let mut add = |p: [f32; 2]| {
@@ -6577,7 +6580,7 @@ mod tests {
65776580
}
65786581

65796582
const ISO: [f32; 3] = [-23.0, -25.0, 0.0];
6580-
const LEFT: [f32; 3] = [-6.5, -17.0, 0.0];
6583+
const LEFT: [f32; 3] = [-23.0, -25.0, 0.0];
65816584

65826585
/// Le `LayerCB` du sprite incliné est, octet pour octet, celui que chaque backend construisait
65836586
/// avant de le partager. La référence est le corps d'origine de `draw_cursor_sprite`.
@@ -6635,7 +6638,7 @@ mod tests {
66356638
let len = std::mem::size_of::<LayerCB>();
66366639
unsafe { std::slice::from_raw_parts(cb as *const LayerCB as *const u8, len) }.to_vec()
66376640
};
6638-
for rot in [ISO, LEFT, [-6.5, 17.0, 0.0]] {
6641+
for rot in [ISO, LEFT, [-23.0, 25.0, 0.0]] {
66396642
let plan = plan_with(rot);
66406643
let clip = [0.1, 0.2, 0.7, 0.6];
66416644
let got = cursor_sprite_cb(
@@ -6994,7 +6997,7 @@ mod tests {
69946997
assert!(model_plan([0.0; 3], &bare, &track, 0.3, true).expect("plan").model.is_none());
69956998
}
69966999

6997-
const LEFT_ROT: [f32; 3] = [-6.5, -17.0, 0.0];
7000+
const LEFT_ROT: [f32; 3] = [-23.0, -25.0, 0.0];
69987001
const ISO_ROT: [f32; 3] = [-23.0, -25.0, 0.0];
69997002

70007003
/// Les poses d'essai, pour chaque état de `MODEL_STATES` : au repos, posé, tourné.
@@ -7008,7 +7011,7 @@ mod tests {
70087011
vec![],
70097012
vec![(0.0, key.to_string())],
70107013
);
7011-
for (name, rot) in [("flat", [0.0; 3]), ("iso", ISO_ROT), ("left", LEFT_ROT), ("right", [-6.5, 17.0, 0.0])] {
7014+
for (name, rot) in [("flat", [0.0; 3]), ("iso", ISO_ROT), ("left", LEFT_ROT), ("right", [-23.0, 25.0, 0.0])] {
70127015
for (pose, track, t) in [("hover", &clicked, 0.3), ("touch", &clicked, 0.5 + CONTACT_S), ("yaw", &moving, 1.0)] {
70137016
let plan = model_plan(rot, &scene, track, t, true).expect("plan");
70147017
let (sdf, shape) = sprite_model(key);

‎crates/compositor/src/regions.rs‎

Lines changed: 59 additions & 54 deletions
Large diffs are not rendered by default.

‎docs/3d-effects-v2.md‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,9 @@ Ce document tranche les décisions que la v1 laissait ouvertes et découpe les P
1515

1616
### A.1 Ce qui existe
1717

18-
Un zoom porte **une attitude figée** (`rotationPreset` : `iso`, `left`, `right`), animée par
18+
Un zoom porte **une attitude figée** (`rotationPreset` : `left`, `right`), animée par
1919
deux effets qui partagent un unique budget d'angle dynamique (`DYNAMIC_TILT_BUDGET`
20-
= ±1,2° X, ±1,8° Y, 0° Z, `clamp_dynamic_tilt` ; ±1,9° X et ±3° Y tant que les angles fixes
21-
roulaient) :
20+
= ±1,9° X, ±3° Y, 0° Z, `clamp_dynamic_tilt`) :
2221

2322
- **la parallaxe** (`dynamic_tilt`, PR 1) : pilotée par la **vitesse** lissée du curseur ;
2423
- **l'impact du clic** (PR 2b) : piloté par la **position** du clic, `regions::tap`.
@@ -40,24 +39,26 @@ mobile, qui tourne autour de l'écran.
4039
| groupe | valeur | libellé (EN) | ce que ça fait |
4140
|---|---|---|---|
4241
| — | absent | Off | écran droit |
43-
| Angle fixe | `iso` | Angled from above | tourné vers la gauche, plongée marquée |
44-
| Angle fixe | `left` | Turned left | tourné vers la gauche |
45-
| Angle fixe | `right` | Turned right | tourné vers la droite |
42+
| Angle fixe | `left` | Screen turned left | tourné vers la gauche, vu d'en haut |
43+
| Angle fixe | `right` | Screen turned right | tourné vers la droite, vu d'en haut |
4644
| Caméra mobile | `follow-cursor` | Orbits with the cursor | l'écran est immobile, une vraie caméra tourne autour de lui avec le curseur |
4745

4846
`swing-clicks` et `orbit` sont retirés (jamais livrés). La caméra mobile qui reste est l'orbite
4947
ci-dessous, sous l'identifiant `follow-cursor`. Un projet qui les porte encore s'ouvre à plat
5048
(valeur inconnue).
5149

52-
Les trois angles fixes ne roulent plus : Z vaut 0, parce que le tilt entre avec le zoom et qu'une
50+
Les angles fixes ne roulent plus : Z vaut 0, parce que le tilt entre avec le zoom et qu'une
5351
caméra qui bouge ne roule jamais le métrage (l'ancien roulis de −2°/±1° penchait l'image pendant
54-
chaque zoom). Sans roulis, la règle des 2° ne laisse que deux bandes de tangage, ≤ 9° ou ≥ 15° :
55-
`left` [−6,5, −17, 0], `right` [−6,5, 17, 0], `iso` [−23, −25, 0] (`regions::rotation3d_for`). Ils
56-
se dessinent au warp **projectif** exact, comme la caméra réelle : le warp bilinéaire penchait à
57-
lui seul la verticale du milieu de l'écran.
58-
59-
« Turned right » veut dire que la face de l'écran regarde vers la droite : le bord droit
60-
recule. C'est ce que fait `right` [−6,5, 17, 0].
52+
chaque zoom). Sans roulis, la règle des 2° ne laisse que deux bandes de tangage, ≤ 9° ou ≥ 15°.
53+
Les deux angles prennent la bande haute, le regard de l'ancien `iso` : `left` [−23, −25, 0] et son
54+
miroir `right` [−23, 25, 0] (`regions::rotation3d_for`), qui rendent au budget dynamique ses
55+
±1,9° / ±3°. `iso` n'est plus proposé : c'était déjà ce regard, tourné à gauche, et un projet qui
56+
le porte se lit comme `left` (`readRotation3DPreset` et le schéma du document). Ils se dessinent
57+
au warp **projectif** exact, comme la caméra réelle : le warp bilinéaire penchait à lui seul la
58+
verticale du milieu de l'écran.
59+
60+
« Screen turned right » veut dire que la face de l'écran regarde vers la droite : le bord droit
61+
recule. C'est ce que fait `right` [−23, 25, 0], vu d'en haut.
6162

6263
### A.3 `follow-cursor` : une caméra en orbite
6364

‎src/components/ai-edition/depthOfFieldToggle.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { VideoEffectsPane } from "./RightPanes";
1313

1414
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }));
1515

16-
function documentWithZoom(rotationPreset?: "iso"): AxcutDocument {
16+
function documentWithZoom(rotationPreset?: "left"): AxcutDocument {
1717
const base = createEmptyDocument({ title: "T", projectId: "p1" });
1818
return {
1919
...base,
@@ -61,7 +61,7 @@ describe("depth of field toggle", () => {
6161
});
6262

6363
it("is on by default with a tilted zoom, and turns off then on again", async () => {
64-
const toggle = mount(documentWithZoom("iso"));
64+
const toggle = mount(documentWithZoom("left"));
6565
expect(toggle).toBeEnabled();
6666
expect(toggle).toHaveAttribute("aria-pressed", "true");
6767
expect(screen.getByText("3D zooms")).toBeInTheDocument();

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe("FloatingInspector", () => {
147147
});
148148

149149
it("is disabled with its reason when the region hides the cursor", () => {
150-
const { tl } = zoomTl({ rotationPreset: "iso", hideCursor: true });
150+
const { tl } = zoomTl({ rotationPreset: "left", hideCursor: true });
151151
render(<FloatingInspector {...defaultProps} tl={tl} />);
152152
expect(
153153
screen.getByRole("button", { name: "settings.zoom.clickImpact.title" }),
@@ -168,7 +168,7 @@ describe("FloatingInspector", () => {
168168
it("is disabled with its reason when the cursor is hidden globally", () => {
169169
editorSettings.cursorShow = false;
170170
try {
171-
const { tl } = zoomTl({ rotationPreset: "iso" });
171+
const { tl } = zoomTl({ rotationPreset: "left" });
172172
render(<FloatingInspector {...defaultProps} tl={tl} />);
173173
expect(
174174
screen.getByRole("button", { name: "settings.zoom.clickImpact.title" }),
@@ -180,7 +180,7 @@ describe("FloatingInspector", () => {
180180
});
181181

182182
it("toggles the region's clickImpact under a 3D preset", () => {
183-
const { tl, updateZoomClickImpact } = zoomTl({ rotationPreset: "iso" });
183+
const { tl, updateZoomClickImpact } = zoomTl({ rotationPreset: "left" });
184184
render(<FloatingInspector {...defaultProps} tl={tl} />);
185185
const box = screen.getByRole("button", { name: "settings.zoom.clickImpact.title" });
186186
expect(box).toBeEnabled();
@@ -226,7 +226,7 @@ describe("FloatingInspector", () => {
226226
[...g.querySelectorAll("option")].map((o) => o.value),
227227
]);
228228
expect(groups).toEqual([
229-
["settings.zoom.camera.fixed", ["iso", "left", "right"]],
229+
["settings.zoom.camera.fixed", ["left", "right"]],
230230
["settings.zoom.camera.moving", ["follow-cursor"]],
231231
]);
232232
});
@@ -237,8 +237,8 @@ describe("FloatingInspector", () => {
237237
const select = screen.getByRole("combobox", { name: "settings.zoom.camera.title" });
238238
expect(select).toHaveValue("follow-cursor");
239239
expect(screen.getByText("settings.zoom.camera.description.followCursor")).toBeInTheDocument();
240-
fireEvent.change(select, { target: { value: "iso" } });
241-
expect(updateZoomRotation).toHaveBeenCalledWith("z", "iso");
240+
fireEvent.change(select, { target: { value: "left" } });
241+
expect(updateZoomRotation).toHaveBeenCalledWith("z", "left");
242242
fireEvent.change(select, { target: { value: "off" } });
243243
expect(updateZoomRotation).toHaveBeenLastCalledWith("z", undefined);
244244
});

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,6 @@ function paneStack(label: string, control: React.ReactNode) {
341341

342342
/** Clé i18n (`zoom.camera.preset.*` / `zoom.camera.description.*`) de chaque caméra 3D. */
343343
const CAMERA_KEYS: Record<Rotation3DPreset, string> = {
344-
iso: "iso",
345344
left: "left",
346345
right: "right",
347346
"follow-cursor": "followCursor",

‎src/components/video-editor/projectPersistence.test.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ describe("projectPersistence media compatibility", () => {
194194
const zoom = { startMs: 0, endMs: 1000, depth: 3 as const, focus: { cx: 0.5, cy: 0.5 } };
195195
const [on, off, junk] = normalizeProjectEditor({
196196
zoomRegions: [
197-
{ ...zoom, id: "on", rotationPreset: "iso", clickImpact: true },
197+
{ ...zoom, id: "on", rotationPreset: "left", clickImpact: true },
198198
{ ...zoom, id: "off" },
199199
{ ...zoom, id: "junk", clickImpact: "yes" as never },
200200
],
@@ -204,6 +204,18 @@ describe("projectPersistence media compatibility", () => {
204204
expect("clickImpact" in junk).toBe(false);
205205
});
206206

207+
it("reads the retired iso camera as Left, which kept its look", () => {
208+
const zoom = { startMs: 0, endMs: 1000, depth: 3 as const, focus: { cx: 0.5, cy: 0.5 } };
209+
const [iso, unknown] = normalizeProjectEditor({
210+
zoomRegions: [
211+
{ ...zoom, id: "iso", rotationPreset: "iso" as never },
212+
{ ...zoom, id: "unknown", rotationPreset: "orbit" as never },
213+
],
214+
}).zoomRegions;
215+
expect(iso.rotationPreset).toBe("left");
216+
expect("rotationPreset" in unknown).toBe(false);
217+
});
218+
207219
it("accepts the dual frame webcam layout preset", () => {
208220
expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe(
209221
"dual-frame",

‎src/components/video-editor/projectPersistence.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,14 @@ import {
3333
DEFAULT_WEBCAM_REACTIVE_ZOOM,
3434
DEFAULT_ZOOM_DEPTH,
3535
DEFAULT_ZOOM_MOTION_BLUR,
36-
isRotation3DPreset,
3736
isWallpaperMotion,
3837
MAX_BLUR_BLOCK_SIZE,
3938
MAX_BLUR_INTENSITY,
4039
MAX_PLAYBACK_SPEED,
4140
MIN_BLUR_BLOCK_SIZE,
4241
MIN_BLUR_INTENSITY,
4342
MIN_PLAYBACK_SPEED,
43+
readRotation3DPreset,
4444
type SpeedRegion,
4545
type TrimRegion,
4646
type WallpaperMotion,
@@ -270,9 +270,8 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
270270
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
271271
const endMs = Math.max(startMs + 1, rawEnd);
272272

273-
const validPreset = isRotation3DPreset(region.rotationPreset)
274-
? region.rotationPreset
275-
: undefined;
273+
// A retired angle reads as the one that kept its look (`iso` → Left).
274+
const validPreset = readRotation3DPreset(region.rotationPreset);
276275
return {
277276
id: region.id,
278277
startMs,

‎src/components/video-editor/types.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export const DEFAULT_ROTATION_3D: Rotation3D = {
7676
};
7777

7878
/** A fixed 3D angle: the screen holds one pose for the whole zoom. */
79-
export const FIXED_ROTATION_3D_PRESETS = ["iso", "left", "right"] as const;
79+
export const FIXED_ROTATION_3D_PRESETS = ["left", "right"] as const;
8080
export type FixedRotation3DPreset = (typeof FIXED_ROTATION_3D_PRESETS)[number];
8181

8282
/**
@@ -100,23 +100,31 @@ export function isRotation3DPreset(value: unknown): value is Rotation3DPreset {
100100
return typeof value === "string" && (ROTATION_3D_PRESET_ORDER as string[]).includes(value);
101101
}
102102

103+
/**
104+
* A stored 3D camera, read: the presets that no longer exist map to the one that kept their look.
105+
* `iso` (turned left, seen from above) became Left. Anything else unknown reads as a flat screen.
106+
*/
107+
export function readRotation3DPreset(value: unknown): Rotation3DPreset | undefined {
108+
if (value === "iso") return "left";
109+
return isRotation3DPreset(value) ? value : undefined;
110+
}
111+
103112
// No preset rolls (Z is 0): the tilt enters with the zoom, and a camera that moves never rolls the
104113
// footage. X and Y are chosen so that no edge of the projected quad comes within 2° of an axis — a
105114
// perfectly vertical edge cutting through text is indistinguishable from `overflow: hidden`, which
106115
// got reported three times as "the recording is truncated". A pure Y rotation keeps both vertical
107116
// edges vertical, so every preset also pitches; and between 10° and 14° of pitch the top edge of a
108-
// turned screen reads level, so a preset pitches either less or more than that. `regions.rs` holds
109-
// the same numbers and the tests.
117+
// turned screen reads level, so both presets pitch well beyond it: turned and seen from above,
118+
// the old `iso` look. `regions.rs` holds the same numbers and the tests.
110119
//
111120
// A moving camera has no single pose. Its entry is its resting angle (`ELEVATION_DEG` in
112121
// `camera.rs`: cursor centred, the camera 4° above the screen, facing it), what a renderer without
113122
// the cursor track draws. A camera above tips the top edge toward the viewer, which is a negative X
114123
// rotation of the screen. It is a camera angle, not a screen rotation, so this is the nearest
115124
// equivalent rather than the same picture.
116125
export const ROTATION_3D_PRESETS: Record<Rotation3DPreset, Rotation3D> = {
117-
iso: { rotationX: -23, rotationY: -25, rotationZ: 0 },
118-
left: { rotationX: -6.5, rotationY: -17, rotationZ: 0 },
119-
right: { rotationX: -6.5, rotationY: 17, rotationZ: 0 },
126+
left: { rotationX: -23, rotationY: -25, rotationZ: 0 },
127+
right: { rotationX: -23, rotationY: 25, rotationZ: 0 },
120128
"follow-cursor": { rotationX: -4, rotationY: 0, rotationZ: 0 },
121129
};
122130

‎src/i18n/locales/ar/settings.json‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -359,16 +359,14 @@
359359
"fixed": "زاوية ثابتة",
360360
"moving": "كاميرا متحركة",
361361
"preset": {
362-
"iso": "مائلة من الأعلى",
363362
"left": "الشاشة مستديرة لليسار",
364363
"right": "الشاشة مستديرة لليمين",
365364
"followCursor": "يدور مع المؤشر"
366365
},
367366
"description": {
368367
"off": "تبقى الشاشة مسطحة.",
369-
"iso": "وضعية واحدة ثابتة: مستديرة لليسار ومرئية من الأعلى.",
370-
"left": "وضعية واحدة ثابتة: الشاشة تتجه إلى اليسار وحافتها اليمنى تتقدم.",
371-
"right": "وضعية واحدة ثابتة: الشاشة تتجه إلى اليمين وحافتها اليسرى تتقدم.",
368+
"left": "وضعية واحدة ثابتة: مستديرة لليسار ومرئية من الأعلى.",
369+
"right": "وضعية واحدة ثابتة: مستديرة لليمين ومرئية من الأعلى.",
372370
"followCursor": "تبقى الشاشة ثابتة، وتدور الكاميرا حولها نحو جهة المؤشر وترتفع أو تنخفض معه."
373371
},
374372
"needsCursor": "المؤشر مخفي: لا شيء تتبعه هذه الكاميرا، فتحافظ على زاويتها الابتدائية."

0 commit comments

Comments
 (0)