@1agh/maude 0.51.1 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/api.ts +389 -14
- package/apps/studio/bin/_video-playwright.mjs +15 -6
- package/apps/studio/canvas-edit.ts +141 -20
- package/apps/studio/canvas-lib.tsx +61 -0
- package/apps/studio/client/app.jsx +824 -121
- package/apps/studio/client/panels/ClipInspector.jsx +409 -0
- package/apps/studio/client/panels/CloudBar.jsx +12 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
- package/apps/studio/client/panels/timeline-media-cache.js +274 -0
- package/apps/studio/client/panels/timeline-parse.js +46 -1
- package/apps/studio/client/panels/timeline-scale.js +64 -0
- package/apps/studio/client/report-bug.jsx +15 -15
- package/apps/studio/client/styles/3-shell-maude.css +446 -3
- package/apps/studio/client/styles/4-components-maude.css +55 -0
- package/apps/studio/client/styles/4-components.css +0 -83
- package/apps/studio/clip-ops.ts +1980 -0
- package/apps/studio/cloud/endpoints.ts +88 -17
- package/apps/studio/credential-grammar.ts +100 -0
- package/apps/studio/debug-bundle.ts +12 -0
- package/apps/studio/dist/client.bundle.js +2048 -2048
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video.ts +49 -10
- package/apps/studio/http.ts +113 -6
- package/apps/studio/ripple.ts +335 -0
- package/apps/studio/sync/hub-link.ts +3 -3
- package/apps/studio/sync/hubs-config.ts +32 -0
- package/apps/studio/sync/workspace-signin.ts +8 -4
- package/apps/studio/test/_helpers.ts +5 -3
- package/apps/studio/test/canvas-create-api.test.ts +8 -2
- package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
- package/apps/studio/test/clip-addressing.test.ts +7 -6
- package/apps/studio/test/clip-ops.test.ts +836 -0
- package/apps/studio/test/cloud-endpoints.test.ts +37 -2
- package/apps/studio/test/comments-api.test.ts +49 -0
- package/apps/studio/test/credential-grammar.test.ts +107 -0
- package/apps/studio/test/read-only-role.test.ts +95 -0
- package/apps/studio/test/ripple.test.ts +197 -0
- package/apps/studio/test/timeline-scale.test.ts +65 -0
- package/apps/studio/test/workspace-signin.test.ts +4 -1
- package/apps/studio/whats-new.json +35 -0
- package/cli/commands/share.mjs +8 -4
- package/cli/lib/ddr-to-kgai.mjs +6 -1
- package/cli/lib/ddr-to-kgai.test.mjs +1 -2
- package/cli/lib/hubs-config.mjs +15 -1
- package/cli/lib/hubs-config.test.mjs +19 -0
- package/cli/lib/share-plan.mjs +5 -1
- package/cli/lib/share-plan.test.mjs +11 -2
- package/package.json +8 -8
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// timeline-media-cache.js — feature-enhanced-video-editing (Task 7).
|
|
2
|
+
// Client-side extraction of REAL clip visuals for the Timeline: filmstrip
|
|
3
|
+
// thumbnails (a hidden <video> seeked N times → canvas → JPEG dataURLs) and
|
|
4
|
+
// audio waveform peaks (fetch → WebAudio decodeAudioData → max-abs bins).
|
|
5
|
+
//
|
|
6
|
+
// Contract:
|
|
7
|
+
// • async + cancellable-by-neglect: callers pass an onReady callback; a
|
|
8
|
+
// stale generation simply ignores the result. Today's plain blocks are the
|
|
9
|
+
// instant fallback while extraction runs.
|
|
10
|
+
// • never blocks the scrub: extraction is queued (max 2 concurrent decodes —
|
|
11
|
+
// WKWebView memory) and kicked from idle callbacks.
|
|
12
|
+
// • cached in memory keyed `<src>|<bucket>` AND persisted server-side under
|
|
13
|
+
// `<designRoot>/_canvas-state/timeline-media/` (per-machine runtime state,
|
|
14
|
+
// DDR-115) via /_api/timeline-media.
|
|
15
|
+
|
|
16
|
+
const memory = new Map(); // key → { strip?: string[], peaks?: number[] }
|
|
17
|
+
const pending = new Map(); // key → Promise
|
|
18
|
+
let running = 0;
|
|
19
|
+
const queue = [];
|
|
20
|
+
const MAX_CONCURRENT = 2;
|
|
21
|
+
const THUMB_W = 160;
|
|
22
|
+
|
|
23
|
+
function pump() {
|
|
24
|
+
while (running < MAX_CONCURRENT && queue.length) {
|
|
25
|
+
const job = queue.shift();
|
|
26
|
+
running += 1;
|
|
27
|
+
const idle = window.requestIdleCallback || ((fn) => setTimeout(fn, 40));
|
|
28
|
+
idle(() => {
|
|
29
|
+
job()
|
|
30
|
+
.catch(() => {})
|
|
31
|
+
.finally(() => {
|
|
32
|
+
running -= 1;
|
|
33
|
+
pump();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function enqueue(fn) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
queue.push(() => fn().then(resolve, () => resolve(null)));
|
|
42
|
+
pump();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Server-persisted cache key — the content-addressed sha8 in the asset path
|
|
47
|
+
* when present (stable across machines/paths), else a djb2 of the src. */
|
|
48
|
+
export function mediaCacheKey(src, bucket) {
|
|
49
|
+
const sha = String(src).match(/([a-f0-9]{8,})\.[a-z0-9]+$/i)?.[1]?.slice(0, 12);
|
|
50
|
+
if (sha) return `${sha}-${bucket}`;
|
|
51
|
+
let h = 5381;
|
|
52
|
+
for (let i = 0; i < src.length; i += 1) h = ((h << 5) + h + src.charCodeAt(i)) >>> 0;
|
|
53
|
+
return `h${h.toString(16)}-${bucket}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function serverGet(key) {
|
|
57
|
+
try {
|
|
58
|
+
const r = await fetch(`/_api/timeline-media?key=${encodeURIComponent(key)}`);
|
|
59
|
+
if (!r.ok) return null;
|
|
60
|
+
const j = await r.json();
|
|
61
|
+
return j?.ok && j.data && typeof j.data === 'object' ? j.data : null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function serverPut(key, data) {
|
|
68
|
+
fetch(`/_api/timeline-media?key=${encodeURIComponent(key)}`, {
|
|
69
|
+
method: 'PUT',
|
|
70
|
+
headers: { 'content-type': 'application/json' },
|
|
71
|
+
body: JSON.stringify(data),
|
|
72
|
+
}).catch(() => {});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Thumbnail bucket for a block width in px: 1 / 3 / 5 / 7 frames by zoom. */
|
|
76
|
+
export function stripBucket(widthPx) {
|
|
77
|
+
if (widthPx < 90) return 1;
|
|
78
|
+
if (widthPx < 200) return 3;
|
|
79
|
+
if (widthPx < 340) return 5;
|
|
80
|
+
return 7;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function extractStrip(url, count) {
|
|
84
|
+
const video = document.createElement('video');
|
|
85
|
+
video.muted = true;
|
|
86
|
+
video.playsInline = true;
|
|
87
|
+
video.preload = 'auto';
|
|
88
|
+
video.crossOrigin = 'anonymous';
|
|
89
|
+
// WKWebView refuses to decode a detached <video> in some codec paths (the
|
|
90
|
+
// desktop "no filmstrips" bug) — keep it in the document, invisible, for the
|
|
91
|
+
// whole extraction, and kick the load explicitly.
|
|
92
|
+
video.style.cssText = 'position:fixed;left:-9999px;top:-9999px;width:2px;height:2px;opacity:0;pointer-events:none;';
|
|
93
|
+
document.body.appendChild(video);
|
|
94
|
+
video.src = url;
|
|
95
|
+
try {
|
|
96
|
+
video.load();
|
|
97
|
+
} catch {
|
|
98
|
+
/* load() is best-effort */
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
await new Promise((res, rej) => {
|
|
102
|
+
const to = setTimeout(() => rej(new Error('metadata timeout')), 8000);
|
|
103
|
+
const ok = () => {
|
|
104
|
+
clearTimeout(to);
|
|
105
|
+
res();
|
|
106
|
+
};
|
|
107
|
+
video.addEventListener('loadedmetadata', ok, { once: true });
|
|
108
|
+
video.addEventListener('canplay', ok, { once: true });
|
|
109
|
+
video.addEventListener(
|
|
110
|
+
'error',
|
|
111
|
+
() => rej(new Error(`video error (${video.error?.code ?? '?'})`)),
|
|
112
|
+
{ once: true }
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
} catch (err) {
|
|
116
|
+
video.remove();
|
|
117
|
+
console.warn('[timeline] filmstrip extraction failed for', url, err?.message || err);
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
const dur = video.duration;
|
|
121
|
+
if (!Number.isFinite(dur) || dur <= 0) throw new Error('no duration');
|
|
122
|
+
const ratio = (video.videoHeight || 9) / (video.videoWidth || 16);
|
|
123
|
+
const canvas = document.createElement('canvas');
|
|
124
|
+
canvas.width = THUMB_W;
|
|
125
|
+
canvas.height = Math.max(2, Math.round(THUMB_W * ratio));
|
|
126
|
+
const g = canvas.getContext('2d');
|
|
127
|
+
const strip = [];
|
|
128
|
+
try {
|
|
129
|
+
for (let k = 0; k < count; k += 1) {
|
|
130
|
+
const t = ((k + 0.5) / count) * dur;
|
|
131
|
+
// eslint-disable-next-line no-await-in-loop
|
|
132
|
+
await new Promise((res, rej) => {
|
|
133
|
+
const to = setTimeout(() => rej(new Error('seek timeout')), 6000);
|
|
134
|
+
video.addEventListener(
|
|
135
|
+
'seeked',
|
|
136
|
+
() => {
|
|
137
|
+
clearTimeout(to);
|
|
138
|
+
res();
|
|
139
|
+
},
|
|
140
|
+
{ once: true }
|
|
141
|
+
);
|
|
142
|
+
video.currentTime = Math.min(dur - 0.01, Math.max(0, t));
|
|
143
|
+
});
|
|
144
|
+
g.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
145
|
+
strip.push(canvas.toDataURL('image/jpeg', 0.55));
|
|
146
|
+
}
|
|
147
|
+
} catch (err) {
|
|
148
|
+
// WKWebView quirk — some codecs never fire `seeked` when paused. A partial
|
|
149
|
+
// strip (or a single poster frame) still beats an anonymous slab.
|
|
150
|
+
if (strip.length === 0) {
|
|
151
|
+
try {
|
|
152
|
+
await new Promise((res, rej) => {
|
|
153
|
+
if (video.readyState >= 2) {
|
|
154
|
+
res();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const to = setTimeout(() => rej(new Error('data timeout')), 4000);
|
|
158
|
+
video.addEventListener(
|
|
159
|
+
'loadeddata',
|
|
160
|
+
() => {
|
|
161
|
+
clearTimeout(to);
|
|
162
|
+
res();
|
|
163
|
+
},
|
|
164
|
+
{ once: true }
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
g.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
168
|
+
strip.push(canvas.toDataURL('image/jpeg', 0.55));
|
|
169
|
+
} catch {
|
|
170
|
+
video.remove();
|
|
171
|
+
console.warn('[timeline] filmstrip seek+poster both failed for', url, err?.message || err);
|
|
172
|
+
throw err; // truly nothing extractable
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
while (strip.length < count) strip.push(strip[strip.length - 1]);
|
|
176
|
+
}
|
|
177
|
+
video.removeAttribute('src');
|
|
178
|
+
try {
|
|
179
|
+
video.load();
|
|
180
|
+
} catch {
|
|
181
|
+
/* teardown */
|
|
182
|
+
}
|
|
183
|
+
video.remove();
|
|
184
|
+
return strip;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function extractPeaks(url, bins = 400) {
|
|
188
|
+
const res = await fetch(url);
|
|
189
|
+
if (!res.ok) throw new Error(`fetch ${res.status}`);
|
|
190
|
+
const buf = await res.arrayBuffer();
|
|
191
|
+
const Ctor = window.AudioContext || window.webkitAudioContext;
|
|
192
|
+
if (!Ctor) throw new Error('no AudioContext');
|
|
193
|
+
const ctx = new Ctor();
|
|
194
|
+
try {
|
|
195
|
+
const decoded = await ctx.decodeAudioData(buf);
|
|
196
|
+
const data = decoded.getChannelData(0);
|
|
197
|
+
const step = Math.max(1, Math.floor(data.length / bins));
|
|
198
|
+
const peaks = [];
|
|
199
|
+
for (let b = 0; b < bins; b += 1) {
|
|
200
|
+
let max = 0;
|
|
201
|
+
const start = b * step;
|
|
202
|
+
const end = Math.min(data.length, start + step);
|
|
203
|
+
// Sample sparsely inside the bin — full max over 10M samples is wasted work.
|
|
204
|
+
const inner = Math.max(1, Math.floor((end - start) / 64));
|
|
205
|
+
for (let i = start; i < end; i += inner) {
|
|
206
|
+
const v = Math.abs(data[i]);
|
|
207
|
+
if (v > max) max = v;
|
|
208
|
+
}
|
|
209
|
+
peaks.push(Math.round(max * 100) / 100);
|
|
210
|
+
}
|
|
211
|
+
return peaks;
|
|
212
|
+
} finally {
|
|
213
|
+
ctx.close?.().catch?.(() => {});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Filmstrip for a video src (resolved URL). Resolves to string[] | null. */
|
|
218
|
+
export function getFilmstrip(url, bucket) {
|
|
219
|
+
const key = mediaCacheKey(url, `s${bucket}`);
|
|
220
|
+
const hit = memory.get(key);
|
|
221
|
+
if (hit?.strip) return Promise.resolve(hit.strip);
|
|
222
|
+
if (pending.has(key)) return pending.get(key);
|
|
223
|
+
const p = (async () => {
|
|
224
|
+
const cached = await serverGet(key);
|
|
225
|
+
if (Array.isArray(cached?.strip)) {
|
|
226
|
+
memory.set(key, { strip: cached.strip });
|
|
227
|
+
return cached.strip;
|
|
228
|
+
}
|
|
229
|
+
const strip = await enqueue(() => extractStrip(url, bucket));
|
|
230
|
+
if (!strip) return null;
|
|
231
|
+
memory.set(key, { strip });
|
|
232
|
+
serverPut(key, { strip });
|
|
233
|
+
return strip;
|
|
234
|
+
})().finally(() => pending.delete(key));
|
|
235
|
+
pending.set(key, p);
|
|
236
|
+
return p;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Waveform peaks for an audio src (resolved URL). Resolves to number[] | null. */
|
|
240
|
+
export function getPeaks(url) {
|
|
241
|
+
const key = mediaCacheKey(url, 'peaks');
|
|
242
|
+
const hit = memory.get(key);
|
|
243
|
+
if (hit?.peaks) return Promise.resolve(hit.peaks);
|
|
244
|
+
if (pending.has(key)) return pending.get(key);
|
|
245
|
+
const p = (async () => {
|
|
246
|
+
const cached = await serverGet(key);
|
|
247
|
+
if (Array.isArray(cached?.peaks)) {
|
|
248
|
+
memory.set(key, { peaks: cached.peaks });
|
|
249
|
+
return cached.peaks;
|
|
250
|
+
}
|
|
251
|
+
const peaks = await enqueue(() => extractPeaks(url));
|
|
252
|
+
if (!peaks) return null;
|
|
253
|
+
memory.set(key, { peaks });
|
|
254
|
+
serverPut(key, { peaks });
|
|
255
|
+
return peaks;
|
|
256
|
+
})().finally(() => pending.delete(key));
|
|
257
|
+
pending.set(key, p);
|
|
258
|
+
return p;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Build the SVG path for a peak array in a 100×12 viewBox (mirrored wave). */
|
|
262
|
+
export function peaksToPath(peaks) {
|
|
263
|
+
if (!peaks?.length) return '';
|
|
264
|
+
const n = peaks.length;
|
|
265
|
+
const top = [];
|
|
266
|
+
const bottom = [];
|
|
267
|
+
for (let i = 0; i < n; i += 1) {
|
|
268
|
+
const px = (i / (n - 1)) * 100;
|
|
269
|
+
const a = Math.max(0.04, Math.min(1, peaks[i])) * 5;
|
|
270
|
+
top.push(`${px.toFixed(2)} ${(6 - a).toFixed(2)}`);
|
|
271
|
+
bottom.unshift(`${px.toFixed(2)} ${(6 + a).toFixed(2)}`);
|
|
272
|
+
}
|
|
273
|
+
return `M${top.join(' L')} L${bottom.join(' L')} Z`;
|
|
274
|
+
}
|
|
@@ -133,6 +133,10 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
const items = [];
|
|
136
|
+
// Spans of clip bodies — used below to spot LOOSE media (a bare
|
|
137
|
+
// <OffthreadVideo>/<Img> sibling outside every Sequence), which previously
|
|
138
|
+
// never showed on the timeline at all (dogfood 2026-07-30).
|
|
139
|
+
const clipSpans = [];
|
|
136
140
|
const tagRe =
|
|
137
141
|
/<(TransitionSeries\.Sequence|TransitionSeries\.Transition|Series\.Sequence|Sequence)\b([^>]*)>/g;
|
|
138
142
|
let m;
|
|
@@ -152,6 +156,7 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
|
|
|
152
156
|
// a phantom audio). FALLBACK badge only; the enumerator (comp-clips)
|
|
153
157
|
// overlays authoritative media by index (incl. wrapper-component media).
|
|
154
158
|
const closeIdx = scope.indexOf(`</${kind}>`, openEnd);
|
|
159
|
+
clipSpans.push([m.index, closeIdx >= 0 ? closeIdx : openEnd + 240]);
|
|
155
160
|
const bound = closeIdx >= 0 ? Math.min(closeIdx, openEnd + 400) : openEnd + 240;
|
|
156
161
|
const after = scope.slice(openEnd, bound);
|
|
157
162
|
const lm = after.match(/<([A-Z][A-Za-z0-9]*)\b/);
|
|
@@ -178,13 +183,23 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
|
|
|
178
183
|
|
|
179
184
|
let cursor = 0;
|
|
180
185
|
const sequences = [];
|
|
186
|
+
// Task 5 (enhanced-video-editing) — seam transitions between series beats,
|
|
187
|
+
// so the storyline lane can draw its ⧓ chips: { afterIndex, dur } where
|
|
188
|
+
// afterIndex is the sequence-row index the transition FOLLOWS.
|
|
189
|
+
const transitions = [];
|
|
190
|
+
let pendingTransition = null;
|
|
181
191
|
for (const it of items) {
|
|
182
192
|
if (it.type === 'transition') {
|
|
183
193
|
cursor -= it.dur || 0;
|
|
194
|
+
if (sequences.length > 0) pendingTransition = { afterIndex: sequences.length - 1, dur: it.dur || 0 };
|
|
184
195
|
continue;
|
|
185
196
|
}
|
|
186
197
|
const from = it.from != null ? it.from : cursor;
|
|
187
198
|
const duration = it.dur != null ? it.dur : 30;
|
|
199
|
+
if (pendingTransition) {
|
|
200
|
+
transitions.push(pendingTransition);
|
|
201
|
+
pendingTransition = null;
|
|
202
|
+
}
|
|
188
203
|
sequences.push({
|
|
189
204
|
label: it.compName ?? `Seq ${sequences.length + 1}`,
|
|
190
205
|
series: !!it.series,
|
|
@@ -216,14 +231,44 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
|
|
|
216
231
|
audio.push({
|
|
217
232
|
kind: 'audio',
|
|
218
233
|
label: name,
|
|
234
|
+
src: srcM ? srcM[1] : null,
|
|
219
235
|
from: Math.max(0, from),
|
|
220
236
|
duration: dur != null ? Math.max(1, dur) : Math.max(1, total - from),
|
|
221
237
|
});
|
|
222
238
|
}
|
|
223
239
|
|
|
240
|
+
// Loose VISUAL media — a bare <OffthreadVideo>/<Video>/<Img> sibling outside
|
|
241
|
+
// every clip span. Remotion renders it from frame 0 for the whole comp, so
|
|
242
|
+
// show it as a read-only overlay row (`loose: true` — no clip ops; it has no
|
|
243
|
+
// enumerator identity). Dogfood 2026-07-30: these were invisible before.
|
|
244
|
+
for (const lm of scope.matchAll(/<(OffthreadVideo|Video|Img)\b([^>]*?)\/?>/g)) {
|
|
245
|
+
const at = lm.index ?? 0;
|
|
246
|
+
if (clipSpans.some(([s2, e2]) => at >= s2 && at <= e2)) continue;
|
|
247
|
+
const srcM = lm[2].match(/src=["']([^"']+)["']/);
|
|
248
|
+
if (!srcM) continue;
|
|
249
|
+
const name = String(srcM[1]).split(/[\\/]/).pop();
|
|
250
|
+
sequences.push({
|
|
251
|
+
label: name || lm[1],
|
|
252
|
+
series: false,
|
|
253
|
+
loose: true,
|
|
254
|
+
mediaTag: lm[1],
|
|
255
|
+
mediaSrc: srcM[1],
|
|
256
|
+
from: 0,
|
|
257
|
+
duration: Math.max(1, total),
|
|
258
|
+
keyframes: [],
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
224
262
|
// `artboardId` is the DCArtboard the timeline scoped to — the shell passes it
|
|
225
263
|
// to /_api/comp-clips + every clip op so the enumerator targets the SAME comp
|
|
226
264
|
// (on a multi-comp canvas its own fallback picks a different one — the
|
|
227
265
|
// showreel badge/replace mis-scoped to the wrong comp without this).
|
|
228
|
-
return {
|
|
266
|
+
return {
|
|
267
|
+
total,
|
|
268
|
+
fps: scopedFps || undefined,
|
|
269
|
+
sequences,
|
|
270
|
+
audio,
|
|
271
|
+
transitions,
|
|
272
|
+
artboardId: scopedArtboardId,
|
|
273
|
+
};
|
|
229
274
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// timeline-scale.js — feature-enhanced-video-editing (Task 4). Pure zoom/scale
|
|
2
|
+
// helpers for the Timeline: all block/handle/caret/playhead math goes through
|
|
3
|
+
// ONE px-per-frame scale (replacing the old %-of-total positioning), so drags
|
|
4
|
+
// land on the same frames at every zoom level. Pure + DOM-free → unit-tested.
|
|
5
|
+
|
|
6
|
+
/** Fit-to-width scale: the whole comp spans the viewport exactly. */
|
|
7
|
+
export function fitPxPerFrame(viewportPx, totalFrames) {
|
|
8
|
+
const w = Math.max(1, viewportPx || 1);
|
|
9
|
+
const t = Math.max(1, totalFrames || 1);
|
|
10
|
+
return w / t;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Hard zoom bounds: never below fit (no dead space right of the comp), never
|
|
14
|
+
* above MAX_PX_PER_FRAME (a frame wider than ~40px stops being useful). */
|
|
15
|
+
export const MAX_PX_PER_FRAME = 40;
|
|
16
|
+
|
|
17
|
+
export function clampPxPerFrame(pxPerFrame, viewportPx, totalFrames) {
|
|
18
|
+
const fit = fitPxPerFrame(viewportPx, totalFrames);
|
|
19
|
+
const v = Number(pxPerFrame);
|
|
20
|
+
if (!Number.isFinite(v) || v <= 0) return fit;
|
|
21
|
+
return Math.min(MAX_PX_PER_FRAME, Math.max(fit, v));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function frameToPx(frame, pxPerFrame) {
|
|
25
|
+
return frame * pxPerFrame;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function pxToFrame(px, pxPerFrame) {
|
|
29
|
+
return pxPerFrame > 0 ? px / pxPerFrame : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Zoom keeping `anchorFrame` stationary on screen: given the current scroll
|
|
34
|
+
* offset and the anchor's viewport position, return the scrollLeft that puts
|
|
35
|
+
* the anchor back at the same viewport x under the new scale.
|
|
36
|
+
*/
|
|
37
|
+
export function zoomAroundScroll(prevPxPerFrame, nextPxPerFrame, anchorFrame, prevScrollLeft) {
|
|
38
|
+
const anchorViewportX = frameToPx(anchorFrame, prevPxPerFrame) - prevScrollLeft;
|
|
39
|
+
return Math.max(0, frameToPx(anchorFrame, nextPxPerFrame) - anchorViewportX);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Adaptive ruler tick step (in frames): the densest step from the ladder whose
|
|
44
|
+
* ticks stay ≥ `minPx` apart. Ladder walks 1 f → 5 f → 10 f → 1 s → 5 s → 10 s
|
|
45
|
+
* → 30 s → 60 s, so zooming in refines seconds into frames.
|
|
46
|
+
*/
|
|
47
|
+
export function tickStepFrames(pxPerFrame, fps, minPx = 56) {
|
|
48
|
+
const f = Math.max(1, Math.round(fps || 30));
|
|
49
|
+
const ladder = [1, 5, 10, f, 5 * f, 10 * f, 30 * f, 60 * f];
|
|
50
|
+
for (const step of ladder) {
|
|
51
|
+
if (step * pxPerFrame >= minPx) return step;
|
|
52
|
+
}
|
|
53
|
+
return ladder[ladder.length - 1];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Ruler tick labels: whole seconds label as `Ns`, sub-second ticks as `Nf`. */
|
|
57
|
+
export function tickLabel(frame, fps) {
|
|
58
|
+
const f = Math.max(1, Math.round(fps || 30));
|
|
59
|
+
if (frame % f === 0) return `${frame / f}s`;
|
|
60
|
+
return `${frame}f`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Multiplicative zoom step (⌘+/⌘− and the slider use the same factor). */
|
|
64
|
+
export const ZOOM_STEP = 1.5;
|
|
@@ -327,24 +327,24 @@ export function ReportBugDialog({ open, onClose }) {
|
|
|
327
327
|
const canPreview = description.trim().length > 0;
|
|
328
328
|
|
|
329
329
|
return (
|
|
330
|
-
<div className="
|
|
330
|
+
<div className="st-scrim" onClick={close} data-testid="report-bug-dialog">
|
|
331
331
|
<div
|
|
332
|
-
className="
|
|
332
|
+
className="st-dialog rb-dialog"
|
|
333
333
|
role="dialog"
|
|
334
334
|
aria-modal="true"
|
|
335
335
|
aria-labelledby="rb-title"
|
|
336
336
|
onClick={(e) => e.stopPropagation()}
|
|
337
337
|
>
|
|
338
|
-
<
|
|
339
|
-
<span className="title" id="rb-title">
|
|
338
|
+
<div className="st-dialog-hd">
|
|
339
|
+
<span className="st-dialog-title" id="rb-title">
|
|
340
340
|
Report a bug
|
|
341
341
|
</span>
|
|
342
|
-
<button type="button" className="
|
|
342
|
+
<button type="button" className="st-iconbtn" onClick={close} aria-label="Close">
|
|
343
343
|
×
|
|
344
344
|
</button>
|
|
345
|
-
</
|
|
345
|
+
</div>
|
|
346
346
|
|
|
347
|
-
<div className="
|
|
347
|
+
<div className="st-dialog-bd rb-body">
|
|
348
348
|
{step === 'describe' && (
|
|
349
349
|
<>
|
|
350
350
|
<label className="rb-label" htmlFor="rb-desc">
|
|
@@ -352,7 +352,7 @@ export function ReportBugDialog({ open, onClose }) {
|
|
|
352
352
|
</label>
|
|
353
353
|
<textarea
|
|
354
354
|
id="rb-desc"
|
|
355
|
-
className="
|
|
355
|
+
className="textarea"
|
|
356
356
|
data-testid="report-bug-description"
|
|
357
357
|
rows={5}
|
|
358
358
|
autoFocus
|
|
@@ -364,12 +364,12 @@ export function ReportBugDialog({ open, onClose }) {
|
|
|
364
364
|
Next you'll see exactly what gets attached — nothing is sent until you approve it.
|
|
365
365
|
</p>
|
|
366
366
|
<footer className="rb-actions">
|
|
367
|
-
<button type="button" className="
|
|
367
|
+
<button type="button" className="st-btn" onClick={close}>
|
|
368
368
|
Cancel
|
|
369
369
|
</button>
|
|
370
370
|
<button
|
|
371
371
|
type="button"
|
|
372
|
-
className="
|
|
372
|
+
className="st-btn rb-primary"
|
|
373
373
|
data-testid="report-bug-preview"
|
|
374
374
|
disabled={!canPreview}
|
|
375
375
|
onClick={() => setStep('preview')}
|
|
@@ -501,12 +501,12 @@ export function ReportBugDialog({ open, onClose }) {
|
|
|
501
501
|
repository only maintainers can open.
|
|
502
502
|
</p>
|
|
503
503
|
<footer className="rb-actions">
|
|
504
|
-
<button type="button" className="
|
|
504
|
+
<button type="button" className="st-btn" onClick={() => setStep('describe')}>
|
|
505
505
|
Back
|
|
506
506
|
</button>
|
|
507
507
|
<button
|
|
508
508
|
type="button"
|
|
509
|
-
className="
|
|
509
|
+
className="st-btn rb-primary"
|
|
510
510
|
data-testid="report-bug-send"
|
|
511
511
|
onClick={send}
|
|
512
512
|
>
|
|
@@ -556,7 +556,7 @@ export function ReportBugDialog({ open, onClose }) {
|
|
|
556
556
|
</p>
|
|
557
557
|
)}
|
|
558
558
|
<footer className="rb-actions">
|
|
559
|
-
<button type="button" className="
|
|
559
|
+
<button type="button" className="st-btn rb-primary" onClick={close}>
|
|
560
560
|
Done
|
|
561
561
|
</button>
|
|
562
562
|
</footer>
|
|
@@ -574,10 +574,10 @@ export function ReportBugDialog({ open, onClose }) {
|
|
|
574
574
|
attach them by hand.
|
|
575
575
|
</p>
|
|
576
576
|
<footer className="rb-actions">
|
|
577
|
-
<button type="button" className="
|
|
577
|
+
<button type="button" className="st-btn" onClick={() => setStep('preview')}>
|
|
578
578
|
Back
|
|
579
579
|
</button>
|
|
580
|
-
<button type="button" className="
|
|
580
|
+
<button type="button" className="st-btn rb-primary" onClick={saveFallback}>
|
|
581
581
|
Save locally & open GitHub
|
|
582
582
|
</button>
|
|
583
583
|
</footer>
|