@1agh/maude 0.51.0 → 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 +849 -121
- package/apps/studio/client/github.js +2 -0
- 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 +590 -0
- 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 +1 -0
- 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/git/service.ts +1 -1
- package/apps/studio/http.ts +113 -6
- package/apps/studio/ripple.ts +335 -0
- package/apps/studio/server.ts +7 -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 +28 -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/gitignore-block.mjs +1 -0
- 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;
|