Preserve edited timelines and stabilize webcam crop controls

This commit is contained in:
webadderall committed 2026-11-09 12:25:51 +11:00
1 parent 870391aaef
commit 01b682a461
19 files changed
+100 -27

No files matched your search

@@ -388,7 +388,7 @@ export function WebcamCropControl({
ref={containerRef}
variant="secondary"
data-webcam-crop-control
className="relative w-full touch-none select-none overflow-hidden rounded-lg border border-foreground/10"
className="relative w-full touch-none select-none overflow-clip rounded-lg border border-foreground/10"
style={{ aspectRatio: previewAspectRatio }}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
@@ -37,6 +37,7 @@ it("waits for ready media and matching cursor data before applying", () => {
{ ready: false },
{ totalMs: 0 },
{ cursorTelemetry: [] },
{ clipRegions: [] },
{ cursorTelemetrySourcePath: "previous.mp4" },
]) {
const params = input(patch);
@@ -56,3 +57,21 @@ it("keeps automatic zooms off when disabled and preserves reopened projects and
expect(params.onZoomSuggested).not.toHaveBeenCalled();
}
});
it("preserves imported sequences and clip edits made while cursor data was loading", () => {
const original = input().clipRegions[0];
for (const clipRegions of [
[
original,
{ ...original, id: "imported", startMs: 6000, endMs: 7000, sourceStartMs: 6000 },
],
[{ ...original, sourceStartMs: 1000 }],
[{ ...original, startMs: 500 }],
[{ ...original, endMs: 5000 }],
[{ ...original, speed: 2 }],
]) {
const params = input({ clipRegions });
expect(applyAutomaticZooms(params)).toBe(true);
expect(params.onZoomSuggested).not.toHaveBeenCalled();
}
});
@@ -22,6 +22,18 @@ export function applyAutomaticZooms(input: Input): boolean {
if (!input.ready || !input.sourcePath || !Number.isFinite(input.totalMs) || input.totalMs <= 0)
return false;
if (!input.enabled || input.isSavedProject || input.zoomRegions.length > 0) return true;
// Imported sequences get new media paths too. Only start on an unedited take;
// adding/trimming clips while telemetry loads must preserve the user's timeline.
const clip = input.clipRegions[0];
if (!clip) return false;
if (
input.clipRegions.length !== 1 ||
clip.startMs !== 0 ||
Math.abs(clip.endMs - input.totalMs) > 1 ||
(clip.sourceStartMs ?? 0) !== 0 ||
(clip.speed ?? 1) !== 1
)
return true;
if (input.cursorTelemetrySourcePath !== input.sourcePath || input.cursorTelemetry.length < 2)
return false;
const result = buildInteractionZoomSuggestions({
@@ -254,7 +254,10 @@ export function CursorSettings(props: Props) {
</div>
{advanced && (
<SliderControl
label={tSettings("effects.cursorClickBounce", "Scale on click")}
label={tSettings(
"effects.cursorClickBounceStrength",
"Scale on click strength",
)}
value={cursorClickBounce}
min={0}
max={5}
+1
View File
@@ -131,6 +131,7 @@
},
"cursorMotionBlur": "Cursor-Bewegungsunschärfe",
"cursorClickBounce": "Beim Klicken skalieren",
"cursorClickBounceStrength": "Stärke der Skalierung beim Klicken",
"cursorClickBounceDuration": "Sprunggeschwindigkeit",
"cursorSway": "Cursor-Schwankung",
"cursorIdleHide": "Bei Inaktivität ausblenden",
+1
View File
@@ -149,6 +149,7 @@
},
"cursorMotionBlur": "Cursor Motion Blur",
"cursorClickBounce": "Scale on click",
"cursorClickBounceStrength": "Scale on click strength",
"cursorClickBounceDuration": "Bounce Speed",
"cursorSway": "Cursor Sway",
"cursorIdleHide": "Hide when idle",
+1
View File
@@ -131,6 +131,7 @@
},
"cursorMotionBlur": "Desenfoque de movimiento del cursor",
"cursorClickBounce": "Escalar al hacer clic",
"cursorClickBounceStrength": "Intensidad de escala al hacer clic",
"cursorClickBounceDuration": "Velocidad del rebote",
"cursorSway": "Balanceo del cursor",
"cursorIdleHide": "Ocultar al estar inactivo",
+1
View File
@@ -131,6 +131,7 @@
},
"cursorMotionBlur": "Flou de mouvement du curseur",
"cursorClickBounce": "Agrandir au clic",
"cursorClickBounceStrength": "Intensité de mise à l’échelle au clic",
"cursorClickBounceDuration": "Vitesse du rebond",
"cursorSway": "Oscillation du curseur",
"cursorIdleHide": "Masquer en cas d’inactivité",
+1
View File
@@ -131,6 +131,7 @@
},
"cursorMotionBlur": "Motion blur del cursore",
"cursorClickBounce": "Scala al clic",
"cursorClickBounceStrength": "Intensità della scala al clic",
"cursorClickBounceDuration": "Velocità rimbalzo",
"cursorSway": "Oscillazione cursore",
"cursorIdleHide": "Nascondi quando inattivo",
+1
View File
@@ -149,6 +149,7 @@
},
"cursorMotionBlur": "カーソルのモーションブラー",
"cursorClickBounce": "クリック時に拡大",
"cursorClickBounceStrength": "クリック時の拡大率",
"cursorClickBounceDuration": "バウンドの速度",
"cursorSway": "カーソルの揺れ",
"cursorIdleHide": "操作がないときに非表示",
+1
View File
@@ -131,6 +131,7 @@
},
"cursorMotionBlur": "커서 모션 블러",
"cursorClickBounce": "클릭 시 크기 조절",
"cursorClickBounceStrength": "클릭 시 크기 조절 강도",
"cursorClickBounceDuration": "바운스 속도",
"cursorSway": "커서 흔들림",
"cursorIdleHide": "대기 시 숨기기",
+1
View File
@@ -131,6 +131,7 @@
},
"cursorMotionBlur": "Cursor-bewegingsonscherpte",
"cursorClickBounce": "Vergroten bij klikken",
"cursorClickBounceStrength": "Sterkte van schalen bij klikken",
"cursorClickBounceDuration": "Stuitsnelheid",
"cursorSway": "Cursorbeweging",
"cursorIdleHide": "Verbergen bij inactiviteit",
+1
View File
@@ -131,6 +131,7 @@
},
"cursorMotionBlur": "Desfoque de movimento do cursor",
"cursorClickBounce": "Redimensionar ao clicar",
"cursorClickBounceStrength": "Intensidade da escala ao clicar",
"cursorClickBounceDuration": "Velocidade do salto",
"cursorSway": "Balanço do cursor",
"cursorIdleHide": "Ocultar quando inativo",
+1
View File
@@ -131,6 +131,7 @@
},
"cursorMotionBlur": "光标运动模糊",
"cursorClickBounce": "点击时缩放",
"cursorClickBounceStrength": "点击缩放强度",
"cursorClickBounceDuration": "弹跳速度",
"cursorSway": "光标摆动",
"cursorIdleHide": "闲置时隐藏",
+1
View File
@@ -131,6 +131,7 @@
},
"cursorMotionBlur": "游標動態模糊",
"cursorClickBounce": "點擊時縮放",
"cursorClickBounceStrength": "點擊縮放強度",
"cursorClickBounceDuration": "彈跳速度",
"cursorSway": "游標擺動",
"cursorIdleHide": "閒置時隱藏",
+4
View File
@@ -13,6 +13,10 @@ test("cursor idle toggle stays available in basic and advanced settings without
await expect(hide).not.toBeChecked();
await page.getByRole("switch", { name: "Advanced settings", exact: true }).press("Space");
await expect(hide).not.toBeChecked();
await expect(page.getByRole("switch", { name: "Scale on click", exact: true })).toBeVisible();
await expect(
page.getByRole("slider", { name: "Scale on click strength", exact: true }),
).toBeVisible();
for (const name of ["Idle detection", "Fade delay", "Fade duration", "Movement tolerance"])
await expect(page.getByRole("slider", { name, exact: true })).toHaveCount(0);
await hide.press("Space");
+29 -23
View File
@@ -44,6 +44,11 @@ async function installSceneBridge(page: Page) {
});
}
async function openScene(page: Page, url: string) {
await page.goto(url);
await expect(page.locator('[data-variant="clip"]')).toHaveCount(1, { timeout: 20_000 });
}
async function storedPreferences(page: Page) {
return page.evaluate(() =>
JSON.parse(localStorage.getItem("recordly.editor.preferences") ?? "{}"),
@@ -61,8 +66,7 @@ test("Scene controls carry to new projects; loading an older look does not repla
page,
}) => {
await installSceneBridge(page);
await page.goto("/?windowType=editor&project=first");
await expect(page.locator('[data-variant="clip"]')).toHaveCount(1, { timeout: 20_000 });
await openScene(page, "/?windowType=editor&project=first");
await setSlider(page, "Padding", 8);
const blur = await setSlider(page, "Blur", 3);
const shadow = await setSlider(page, "Shadow", 4);
@@ -91,7 +95,7 @@ test("Scene controls carry to new projects; loading an older look does not repla
await expect.poll(() => storedPreferences(page)).toMatchObject(expected);
// A second recording reads the controls actually saved by the first editor.
await page.goto("/?windowType=editor&project=second");
await openScene(page, "/?windowType=editor&project=second");
for (const [index, side] of ["Top", "Bottom", "Left", "Right"].entries()) {
await expect(page.getByRole("slider", { name: side, exact: true })).toHaveValue(
String(index + 1),
@@ -108,7 +112,7 @@ test("Scene controls carry to new projects; loading an older look does not repla
"true",
);
await page.goto("/?windowType=editor&older=1");
await openScene(page, "/?windowType=editor&older=1");
await expect(
page.getByRole("button", { name: "Aspect ratio: 16:9", exact: true }),
).toBeVisible();
@@ -123,7 +127,7 @@ test("Scene controls carry to new projects; loading an older look does not repla
await expect
.poll(() => storedPreferences(page))
.toMatchObject({ ...expected, shadowIntensity: 0.71 });
await page.goto("/?windowType=editor&project=third");
await openScene(page, "/?windowType=editor&project=third");
await expect(page.getByRole("slider", { name: "Top", exact: true })).toHaveValue("1");
await expect(page.getByRole("slider", { name: "Shadow", exact: true })).toHaveValue("0.71");
await expect(page.getByRole("button", { name: "Gradient 1", exact: true })).toHaveAttribute(
@@ -136,27 +140,29 @@ test("image, video, solid color and gradient selections survive creating another
page,
}) => {
await installSceneBridge(page);
await page.goto("/?windowType=editor&project=initial");
for (const [index, tab] of ["Image", "Video", "Colors", "Colors"].entries()) {
await openScene(page, "/?windowType=editor&project=initial");
const cases = [
{
tab: "Image",
label: "Golden Gate Day",
wallpaper: "/wallpapers/golden-gate-day-no-vignette.png",
},
{ tab: "Video", label: "Wispy Sky", wallpaper: "/wallpapers/wispysky.mp4" },
{ tab: "Colors", label: "Background color #E2E8F0", wallpaper: "#e2e8f0" },
{
tab: "Colors",
label: "Gradient 2",
wallpaper: "linear-gradient(135deg, #e0e7ff 0%, #c4b5fd 100%)",
},
];
for (const [index, { tab, label, wallpaper }] of cases.entries()) {
await page.getByRole("row", { name: tab, exact: true }).click();
const tile =
index === 2
? page.getByRole("button", { name: "Background color #E2E8F0", exact: true })
: index === 3
? page.getByRole("button", { name: "Gradient 2", exact: true })
: page
.locator(
`[data-background-panel="${tab.toLowerCase()}"] .wallpaper-tile`,
)
.nth(index === 0 ? 1 : 0);
const label = await tile.getAttribute("aria-label");
expect(label).toBeTruthy();
const tile = page.getByRole("button", { name: label, exact: true });
await tile.click();
await expect(tile).toHaveAttribute("aria-pressed", "true");
const wallpaper = (await storedPreferences(page)).wallpaper;
expect(wallpaper).toBeTruthy();
await page.goto(`/?windowType=editor&project=background-${index}`);
await expect(page.getByRole("button", { name: label!, exact: true })).toHaveAttribute(
await expect.poll(() => storedPreferences(page)).toMatchObject({ wallpaper });
await openScene(page, `/?windowType=editor&project=background-${index}`);
await expect(page.getByRole("button", { name: label, exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
+19 -2
View File
@@ -361,10 +361,17 @@ test("Clips shows original filenames and truncates long names beside real still
});
for (const enabled of [true, false]) {
test(`imported recordings stay unzoomed with legacy auto zooms ${enabled ? "on" : "off"}`, async ({
test(`imported recordings stay unzoomed with automatic zooms ${enabled ? "on" : "off"}`, async ({
page,
}) => {
await setup(page);
await installDesktopBridgeOverrides(page, () => {
const get = window.electronAPI.getAppSetting;
window.electronAPI.getAppSetting = (key) =>
key === "recordly.editor.preferences"
? JSON.parse(localStorage.getItem(key) ?? "{}")
: get(key);
});
await page.evaluate((enabled) => {
const prefs = JSON.parse(localStorage.getItem("recordly.editor.preferences") || "{}");
localStorage.setItem(
@@ -373,7 +380,17 @@ for (const enabled of [true, false]) {
);
}, enabled);
await page.reload();
await expect(page.locator('[data-variant="clip"]')).toHaveCount(1);
await expect(page.locator('[data-variant="clip"]')).toHaveCount(1, { timeout: 20_000 });
await page.getByRole("button", { name: "Settings", exact: true }).click();
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
await settings.getByRole("tab", { name: "Motion", exact: true }).click();
const automatic = settings.getByRole("switch", {
name: "Automatically add zooms",
exact: true,
});
if (enabled) await expect(automatic).toBeChecked();
else await expect(automatic).not.toBeChecked();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Clips", exact: true }).click();
await page.evaluate(() => {
let count = 0;
+1
View File
@@ -61,6 +61,7 @@ test("webcam preview frame choices persist and inline crop updates immediately",
exact: true,
});
await handle.focus();
await expect.poll(() => crop.evaluate((element) => element.scrollTop)).toBe(0);
await handle.press("ArrowLeft");
await expect
.poll(() =>