Skip to content

Commit db8a31c

Browse files
committed
feat(timeline): draw each zoom's move-in and move-out as trails (#1028)
A zoom pill shows the hold, while its moves run outside it. The zoom lane now draws a trail on each side, sized on the speed-aware clock the compositor uses (zoomTransitions), and live while a zoom, trim or speed pill is dragged. The part a trim cuts (transitionCutsMs) is hatched, and the trail's tooltip says how much and that the export jumps there. Strings in all 15 locales, machine-translated outside en and fr.
1 parent 0ef120a commit db8a31c

19 files changed

Lines changed: 266 additions & 8 deletions

‎src/components/ai-edition/v4/EditorShellV4.module.css‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1665,6 +1665,35 @@
16651665
.laneCameraFullscreen {
16661666
--_c: #a855f7;
16671667
}
1668+
/* A zoom's camera moves (#1028): a trail on each side of the pill, fading away from it, BELOW
1669+
it (a chained neighbour's pill stays on top). Thinner than the pill so it reads as part of
1670+
it, not as another region. The part a trim cuts is hatched in the trim lane's red. */
1671+
.zoomTrail {
1672+
position: absolute;
1673+
top: 5px;
1674+
height: 14px;
1675+
min-width: 1px;
1676+
overflow: hidden;
1677+
z-index: 1;
1678+
}
1679+
.zoomTrail[data-zoom-trail="in"] {
1680+
border-radius: 7px 0 0 7px;
1681+
background: linear-gradient(90deg, transparent, color-mix(in oklab, var(--_c) 55%, transparent));
1682+
}
1683+
.zoomTrail[data-zoom-trail="out"] {
1684+
border-radius: 0 7px 7px 0;
1685+
background: linear-gradient(90deg, color-mix(in oklab, var(--_c) 55%, transparent), transparent);
1686+
}
1687+
.zoomTrailCut {
1688+
position: absolute;
1689+
top: 0;
1690+
bottom: 0;
1691+
background: repeating-linear-gradient(
1692+
-45deg,
1693+
color-mix(in srgb, var(--danger) 70%, transparent) 0 3px,
1694+
transparent 3px 6px
1695+
);
1696+
}
16681697
/* Imported audio tracks (issue #350). A distinct pink so a BGM/voiceover track
16691698
reads apart from the effect pills, and taller than a lane pill so the waveform
16701699
inside it is legible. */

‎src/components/ai-edition/v4/V4Timeline.geometry.test.tsx‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import type { I18nNamespace } from "@/i18n/config";
3737
import { getAvailableLocales, translate } from "@/i18n/loader";
3838
import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
3939
import { DEFAULT_SHORTCUTS, formatBinding } from "@/lib/shortcuts";
40+
import { zoomTransitionMs } from "@/lib/zoomMath/constants";
4041
import { V4Timeline } from "./V4Timeline";
4142

4243
beforeAll(() => {
@@ -1083,3 +1084,54 @@ describe("V4Timeline toolbar tooltips", () => {
10831084
expect(wide).not.toHaveAttribute("title");
10841085
});
10851086
});
1087+
1088+
// A zoom pill shows the hold; its camera moves run outside it (#1028).
1089+
describe("V4Timeline zoom trails", () => {
1090+
afterEach(() => {
1091+
i18n.translate = null;
1092+
});
1093+
1094+
const W_SEC = zoomTransitionMs(1.8) / 1000; // ≈ 0.92 s at the default depth
1095+
const ZOOM = { id: "z1", startMs: 10_000, endMs: 12_000, depth: 3 };
1096+
const pct = (sec: number) => (sec / TOTAL_SEC) * 100;
1097+
const trail = (side: "in" | "out") =>
1098+
document.querySelector<HTMLElement>(`[data-zoom-trail="${side}"]`) as HTMLElement;
1099+
1100+
function renderZoom(overrides: Record<string, unknown>) {
1101+
i18n.translate = (namespace, key, vars) =>
1102+
translate("en", namespace as I18nNamespace, key, vars);
1103+
renderTimeline(undefined, undefined, [NO_CAMERA_ASSET], undefined, {
1104+
zoomRegions: [ZOOM],
1105+
...overrides,
1106+
});
1107+
}
1108+
1109+
it("hatches the zoom-out a trim at the pill's end removes (issue #1028 repro)", () => {
1110+
renderZoom({
1111+
trimRanges: [
1112+
{ id: "t1", assetId: "a1", clipId: "c@0", startSec: 12, endSec: 14, origin: "user" },
1113+
],
1114+
});
1115+
expect(trail("in")).toHaveAccessibleName("Zoom-in: the view moves in until the zoom starts");
1116+
expect(trail("in").querySelector("[data-zoom-trail-cut]")).toBeNull();
1117+
expect(Number.parseFloat(trail("in").style.left)).toBeCloseTo(pct(10 - W_SEC), 6);
1118+
1119+
expect(trail("out")).toHaveAccessibleName(
1120+
`A trim removes ${W_SEC.toFixed(2)} s of the zoom-out. The export jumps at the cut.`,
1121+
);
1122+
expect(Number.parseFloat(trail("out").style.left)).toBeCloseTo(pct(12), 6);
1123+
const cut = trail("out").querySelector<HTMLElement>("[data-zoom-trail-cut]");
1124+
expect(Number.parseFloat(cut?.style.left ?? "")).toBeCloseTo(0, 6);
1125+
expect(Number.parseFloat(cut?.style.width ?? "")).toBeCloseTo(100, 6);
1126+
});
1127+
1128+
it("stretches the zoom-out across a speed region, uncut", () => {
1129+
renderZoom({ speedRegions: [{ id: "s1", startMs: 12_000, endMs: 20_000, speed: 3 }] });
1130+
expect(Number.parseFloat(trail("out").style.width)).toBeCloseTo(pct(3 * W_SEC), 6);
1131+
expect(Number.parseFloat(trail("in").style.width)).toBeCloseTo(pct(W_SEC), 6);
1132+
expect(trail("out")).toHaveAccessibleName(
1133+
"Zoom-out: the view moves back out after the zoom ends",
1134+
);
1135+
expect(trail("out").querySelector("[data-zoom-trail-cut]")).toBeNull();
1136+
});
1137+
});

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

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ import {
6767
resolveTimelineSpanToTrim,
6868
ventilateTimelineSpanToTrims,
6969
} from "@/lib/ai-edition/timeline/trim-mapping";
70+
import { effectiveZoomScale } from "@/lib/ai-edition/timeline/zoom-scale";
71+
import { transitionCutsMs, zoomTransitions } from "@/lib/ai-edition/timeline/zoom-transitions";
7072
import { formatBinding } from "@/lib/shortcuts";
7173
import { nativeBridgeClient } from "@/native/client";
7274
import { TransportBar } from "../TransportBar";
@@ -742,7 +744,8 @@ export function V4Timeline({
742744
label: t("toolbar.newAnnotation"),
743745
sourceIds: p.ids,
744746
}));
745-
const speedPills: LanePill[] = coalesceRegionsForRuler(tl.speedRegions).map((p) => ({
747+
const speedRuns = coalesceRegionsForRuler(tl.speedRegions);
748+
const speedPills: LanePill[] = speedRuns.map((p) => ({
746749
id: p.ids[0],
747750
kind: "speed",
748751
start: p.start,
@@ -760,7 +763,8 @@ export function V4Timeline({
760763
sourceIds: p.ids,
761764
}),
762765
);
763-
const zoomPills: LanePill[] = coalesceRegionsForRuler(tl.zoomRegions).map((p) => ({
766+
const zoomRuns = coalesceRegionsForRuler(tl.zoomRegions);
767+
const zoomPills: LanePill[] = zoomRuns.map((p) => ({
764768
id: p.ids[0],
765769
kind: "zoom",
766770
start: p.start,
@@ -1748,13 +1752,14 @@ export function V4Timeline({
17481752
);
17491753
};
17501754

1755+
// A pill's span as drawn: the live drag geometry while it is being dragged.
1756+
const liveSpan = <T extends { start: number; end: number }>(id: string, p: T): T =>
1757+
activePillDrag && activePillDrag.id === id
1758+
? { ...p, start: activePillDrag.start, end: activePillDrag.end }
1759+
: p;
1760+
17511761
const renderPills = (pills: LanePill[], emptyLabel: string) => {
1752-
const effectivePills = pills.map((p) => {
1753-
if (activePillDrag && activePillDrag.id === p.id) {
1754-
return { ...p, start: activePillDrag.start, end: activePillDrag.end };
1755-
}
1756-
return p;
1757-
});
1762+
const effectivePills = pills.map((p) => liveSpan(p.id, p));
17581763
return (
17591764
<>
17601765
{effectivePills.length === 0 ? (
@@ -1831,6 +1836,85 @@ export function V4Timeline({
18311836
);
18321837
};
18331838

1839+
// Each zoom's camera moves (#1028): the pill shows the hold, the zoom-in runs in the trail
1840+
// before it and the zoom-out in the trail after it, on the speed-aware clock the compositor
1841+
// uses. The part a trim cuts is hatched: the export jumps there instead of easing. Display
1842+
// only, not a control. Hidden while a clip is dragged, when the pills split and slide.
1843+
const renderZoomTrails = () => {
1844+
if (clipDrag) return null;
1845+
const ms = (span: { start: number; end: number }) => ({
1846+
startMs: span.start * 1000,
1847+
endMs: span.end * 1000,
1848+
});
1849+
const speeds = speedRuns.map((r) => ({
1850+
id: r.ids[0],
1851+
speed: r.member.speed,
1852+
...ms(liveSpan(r.ids[0], r)),
1853+
}));
1854+
const trims = trimPills.map((p) => ms(liveSpan(p.id, p)));
1855+
const totalMs = total * 1000;
1856+
const sec = (cutMs: number) => (cutMs / 1000).toFixed(2);
1857+
return zoomRuns.flatMap((r) => {
1858+
const zoom = ms(liveSpan(r.ids[0], r));
1859+
const moves = zoomTransitions({ ...zoom, scale: effectiveZoomScale(r.member) }, speeds);
1860+
const cut = transitionCutsMs(zoom, moves, trims);
1861+
const outUntilMs = Math.min(moves.outUntilMs, totalMs);
1862+
const trails = [
1863+
{
1864+
side: "in",
1865+
fromMs: moves.inFromMs,
1866+
toMs: zoom.startMs,
1867+
cutFromMs: moves.inFromMs,
1868+
cutToMs: moves.inFromMs + cut.inMs,
1869+
text:
1870+
cut.inMs > 0 ? t("trails.zoomInCut", { seconds: sec(cut.inMs) }) : t("trails.zoomIn"),
1871+
},
1872+
{
1873+
side: "out",
1874+
fromMs: zoom.endMs,
1875+
toMs: outUntilMs,
1876+
cutFromMs: moves.outUntilMs - cut.outMs,
1877+
cutToMs: outUntilMs,
1878+
text:
1879+
cut.outMs > 0
1880+
? t("trails.zoomOutCut", { seconds: sec(cut.outMs) })
1881+
: t("trails.zoomOut"),
1882+
},
1883+
];
1884+
return trails
1885+
.filter((trail) => trail.toMs > trail.fromMs)
1886+
.map((trail) => {
1887+
const lengthMs = trail.toMs - trail.fromMs;
1888+
const cutMs = trail.cutToMs - trail.cutFromMs;
1889+
return (
1890+
<Tooltip key={`${r.ids[0]}:${trail.side}`} content={trail.text}>
1891+
<div
1892+
role="img"
1893+
aria-label={trail.text}
1894+
data-zoom-trail={trail.side}
1895+
className={`${styles.zoomTrail} ${styles.laneZoom}`}
1896+
style={{
1897+
left: `${pctAt(trail.fromMs / 1000)}%`,
1898+
width: `${pctOf(lengthMs / 1000)}%`,
1899+
}}
1900+
>
1901+
{cutMs > 0 ? (
1902+
<span
1903+
data-zoom-trail-cut=""
1904+
className={styles.zoomTrailCut}
1905+
style={{
1906+
left: `${((trail.cutFromMs - trail.fromMs) / lengthMs) * 100}%`,
1907+
width: `${(cutMs / lengthMs) * 100}%`,
1908+
}}
1909+
/>
1910+
) : null}
1911+
</div>
1912+
</Tooltip>
1913+
);
1914+
});
1915+
});
1916+
};
1917+
18341918
return (
18351919
<div className={styles.tl} ref={panelRef}>
18361920
<div className={styles.tlToolbar}>
@@ -2174,6 +2258,8 @@ export function V4Timeline({
21742258
)}
21752259
</div>
21762260
<div className={styles.tlLane}>
2261+
{/* Its own provider, like the toolbar's: see the note there. */}
2262+
<TooltipProvider>{renderZoomTrails()}</TooltipProvider>
21772263
{renderPills(
21782264
zoomPills,
21792265
t("hints.pressZoom", { key: formatBinding(shortcuts.addZoom, isMac) }),

‎src/i18n/__tests__/tooltipCopy.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const TOOLTIP_KEYS: Record<string, string[]> = {
4242
"deviceSettings.title",
4343
"deviceSettings.close",
4444
],
45+
timeline: ["trails.zoomIn", "trails.zoomOut", "trails.zoomInCut", "trails.zoomOutCut"],
4546
};
4647

4748
function read(locale: string, namespace: string, key: string): string {

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
"cameraFullscreen": "كاميرا كاملة الشاشة",
3434
"cameraFullscreenItem": "كاميرا كاملة الشاشة {{index}}"
3535
},
36+
"trails": {
37+
"zoomIn": "التكبير التدريجي: تقترب الصورة حتى بداية التكبير",
38+
"zoomOut": "التصغير التدريجي: تبتعد الصورة مجددًا بعد نهاية التكبير",
39+
"zoomInCut": "يزيل قص {{seconds}} ث من التكبير التدريجي. يقفز التصدير عند القص.",
40+
"zoomOutCut": "يزيل قص {{seconds}} ث من التصغير التدريجي. يقفز التصدير عند القص."
41+
},
3642
"emptyState": {
3743
"noVideo": "لم يتم تحميل أي فيديو",
3844
"dragAndDrop": "اسحب وأفلت مقطع فيديو لبدء التعديل"

‎src/i18n/locales/cs/timeline.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
"cameraFullscreen": "Celá kamera",
3434
"cameraFullscreenItem": "Celá kamera {{index}}"
3535
},
36+
"trails": {
37+
"zoomIn": "Najetí: pohled se přibližuje až do začátku přiblížení",
38+
"zoomOut": "Oddálení: pohled se po konci přiblížení znovu oddaluje",
39+
"zoomInCut": "Oříznutí odstraní {{seconds}} s najetí. Export v místě střihu skočí.",
40+
"zoomOutCut": "Oříznutí odstraní {{seconds}} s oddálení. Export v místě střihu skočí."
41+
},
3642
"emptyState": {
3743
"noVideo": "Žádné video nenačteno",
3844
"dragAndDrop": "Přetáhněte video pro zahájení úprav"

‎src/i18n/locales/de/timeline.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
"cameraFullscreen": "Vollbild-Kamera",
3434
"cameraFullscreenItem": "Vollbild-Kamera {{index}}"
3535
},
36+
"trails": {
37+
"zoomIn": "Hineinzoomen: Die Ansicht zoomt bis zum Beginn des Zooms hinein",
38+
"zoomOut": "Herauszoomen: Die Ansicht zoomt nach dem Ende des Zooms wieder heraus",
39+
"zoomInCut": "Ein Trim entfernt {{seconds}} s des Hineinzoomens. Der Export springt am Schnitt.",
40+
"zoomOutCut": "Ein Trim entfernt {{seconds}} s des Herauszoomens. Der Export springt am Schnitt."
41+
},
3642
"emptyState": {
3743
"noVideo": "Kein Video geladen",
3844
"dragAndDrop": "Zieh ein Video hierher, um mit dem Bearbeiten zu beginnen"

‎src/i18n/locales/en/timeline.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
"cameraFullscreen": "Full Camera",
3434
"cameraFullscreenItem": "Full Camera {{index}}"
3535
},
36+
"trails": {
37+
"zoomIn": "Zoom-in: the view moves in until the zoom starts",
38+
"zoomOut": "Zoom-out: the view moves back out after the zoom ends",
39+
"zoomInCut": "A trim removes {{seconds}} s of the zoom-in. The export jumps at the cut.",
40+
"zoomOutCut": "A trim removes {{seconds}} s of the zoom-out. The export jumps at the cut."
41+
},
3642
"emptyState": {
3743
"noVideo": "No video loaded",
3844
"dragAndDrop": "Drag and drop a video to start editing"

‎src/i18n/locales/es/timeline.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
"cameraFullscreen": "Cámara a pantalla completa",
3434
"cameraFullscreenItem": "Cámara a pantalla completa {{index}}"
3535
},
36+
"trails": {
37+
"zoomIn": "Acercamiento: la vista se acerca hasta que empieza el zoom",
38+
"zoomOut": "Alejamiento: la vista se aleja de nuevo cuando termina el zoom",
39+
"zoomInCut": "Un recorte quita {{seconds}} s del acercamiento. La exportación salta en el corte.",
40+
"zoomOutCut": "Un recorte quita {{seconds}} s del alejamiento. La exportación salta en el corte."
41+
},
3642
"emptyState": {
3743
"noVideo": "No hay video cargado",
3844
"dragAndDrop": "Arrastra y suelta un video para comenzar a editar"

‎src/i18n/locales/fr/timeline.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
"cameraFullscreen": "Caméra plein écran",
3434
"cameraFullscreenItem": "Caméra plein écran {{index}}"
3535
},
36+
"trails": {
37+
"zoomIn": "Zoom avant : l'image se resserre jusqu'au début du zoom",
38+
"zoomOut": "Zoom arrière : l'image s'élargit à nouveau après la fin du zoom",
39+
"zoomInCut": "Une coupe retire {{seconds}} s du zoom avant. L'export saute à la coupe.",
40+
"zoomOutCut": "Une coupe retire {{seconds}} s du zoom arrière. L'export saute à la coupe."
41+
},
3642
"emptyState": {
3743
"noVideo": "Aucune vidéo chargée",
3844
"dragAndDrop": "Glissez-déposez une vidéo pour commencer à éditer"

0 commit comments

Comments
 (0)