@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.
Files changed (48) hide show
  1. package/apps/studio/api.ts +389 -14
  2. package/apps/studio/bin/_video-playwright.mjs +15 -6
  3. package/apps/studio/canvas-edit.ts +141 -20
  4. package/apps/studio/canvas-lib.tsx +61 -0
  5. package/apps/studio/client/app.jsx +824 -121
  6. package/apps/studio/client/panels/ClipInspector.jsx +409 -0
  7. package/apps/studio/client/panels/CloudBar.jsx +12 -2
  8. package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
  9. package/apps/studio/client/panels/timeline-media-cache.js +274 -0
  10. package/apps/studio/client/panels/timeline-parse.js +46 -1
  11. package/apps/studio/client/panels/timeline-scale.js +64 -0
  12. package/apps/studio/client/report-bug.jsx +15 -15
  13. package/apps/studio/client/styles/3-shell-maude.css +446 -3
  14. package/apps/studio/client/styles/4-components-maude.css +55 -0
  15. package/apps/studio/client/styles/4-components.css +0 -83
  16. package/apps/studio/clip-ops.ts +1980 -0
  17. package/apps/studio/cloud/endpoints.ts +88 -17
  18. package/apps/studio/credential-grammar.ts +100 -0
  19. package/apps/studio/debug-bundle.ts +12 -0
  20. package/apps/studio/dist/client.bundle.js +2048 -2048
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/exporters/video.ts +49 -10
  23. package/apps/studio/http.ts +113 -6
  24. package/apps/studio/ripple.ts +335 -0
  25. package/apps/studio/sync/hub-link.ts +3 -3
  26. package/apps/studio/sync/hubs-config.ts +32 -0
  27. package/apps/studio/sync/workspace-signin.ts +8 -4
  28. package/apps/studio/test/_helpers.ts +5 -3
  29. package/apps/studio/test/canvas-create-api.test.ts +8 -2
  30. package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
  31. package/apps/studio/test/clip-addressing.test.ts +7 -6
  32. package/apps/studio/test/clip-ops.test.ts +836 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +37 -2
  34. package/apps/studio/test/comments-api.test.ts +49 -0
  35. package/apps/studio/test/credential-grammar.test.ts +107 -0
  36. package/apps/studio/test/read-only-role.test.ts +95 -0
  37. package/apps/studio/test/ripple.test.ts +197 -0
  38. package/apps/studio/test/timeline-scale.test.ts +65 -0
  39. package/apps/studio/test/workspace-signin.test.ts +4 -1
  40. package/apps/studio/whats-new.json +35 -0
  41. package/cli/commands/share.mjs +8 -4
  42. package/cli/lib/ddr-to-kgai.mjs +6 -1
  43. package/cli/lib/ddr-to-kgai.test.mjs +1 -2
  44. package/cli/lib/hubs-config.mjs +15 -1
  45. package/cli/lib/hubs-config.test.mjs +19 -0
  46. package/cli/lib/share-plan.mjs +5 -1
  47. package/cli/lib/share-plan.test.mjs +11 -2
  48. package/package.json +8 -8
@@ -0,0 +1,409 @@
1
+ // ClipInspector — feature-enhanced-video-editing (Task 9). The per-clip
2
+ // parametric editing popover, scoped to the timeline selection: tabs
3
+ // Speed · Audio · Crop · Grade (media clips) and Transition (seam chips).
4
+ // Draggable, Esc closes, one instance (owned by TimelinePanel). Values come
5
+ // from the enumerator's authoritative `mediaProps`; every commit goes through
6
+ // `onVerb(verb, params)` → POST /_api/clip-edit (stableId + contentHash).
7
+
8
+ import { useCallback, useEffect, useRef, useState } from 'react';
9
+
10
+ import { NumberField, SliderField, Toggle } from '../inspector-controls.jsx';
11
+
12
+ const SPEED_PRESETS = [0.25, 0.5, 1, 1.5, 2, 4];
13
+ const PRESENTATIONS = ['fade', 'slide', 'wipe', 'flip', 'clock-wipe', 'none'];
14
+ // Named grade "looks" — parameter bundles, still ONE flat filter string on disk.
15
+ const GRADE_PRESETS = {
16
+ None: null,
17
+ Mono: { saturation: 0, contrast: 1.05 },
18
+ Warm: { hue: -12, saturation: 1.15, brightness: 1.03 },
19
+ Cool: { hue: 12, saturation: 1.05, brightness: 0.98 },
20
+ Punch: { contrast: 1.18, saturation: 1.25 },
21
+ };
22
+
23
+ /** Parse a CSS filter chain back to grade params (mirror of the server's
24
+ * filterToGrade). Null = unrecognized function → read-only badge. */
25
+ export function filterToGradeParams(filter) {
26
+ if (!filter) return {};
27
+ const out = {};
28
+ let rest = String(filter).trim();
29
+ const fnRe = /^([a-z-]+)\(([^)]*)\)\s*/;
30
+ const keys = {
31
+ brightness: 'brightness',
32
+ contrast: 'contrast',
33
+ saturate: 'saturation',
34
+ 'hue-rotate': 'hue',
35
+ sepia: 'sepia',
36
+ grayscale: 'grayscale',
37
+ invert: 'invert',
38
+ };
39
+ while (rest.length) {
40
+ const m = rest.match(fnRe);
41
+ if (!m || !keys[m[1]]) return null;
42
+ const v = Number.parseFloat(m[2]);
43
+ if (!Number.isFinite(v)) return null;
44
+ out[keys[m[1]]] = v;
45
+ rest = rest.slice(m[0].length);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function TabBar({ tabs, active, onPick }) {
51
+ return (
52
+ <div className="tlci-tabs" role="tablist">
53
+ {tabs.map((t) => (
54
+ <button
55
+ key={t}
56
+ type="button"
57
+ role="tab"
58
+ aria-selected={active === t}
59
+ className={`tlci-tab${active === t ? ' is-on' : ''}`}
60
+ data-testid={`timeline-inspector-tab-${t.toLowerCase()}`}
61
+ onClick={() => onPick(t)}
62
+ >
63
+ {t}
64
+ </button>
65
+ ))}
66
+ </div>
67
+ );
68
+ }
69
+
70
+ export default function ClipInspector({
71
+ mode = 'clip', // 'clip' | 'transition'
72
+ clipLabel = '',
73
+ mediaProps = null,
74
+ kind = 'video', // rowKind of the clip
75
+ transition = null, // { dur } when mode === 'transition'
76
+ // Non-null = the clip carries editable text (Title literal / AI prompt).
77
+ textValue = null,
78
+ x = 200,
79
+ y = 200,
80
+ onVerb, // (verb, params) => void
81
+ onClose,
82
+ }) {
83
+ const tabs =
84
+ mode === 'transition'
85
+ ? ['Transition']
86
+ : textValue != null && kind !== 'video' && kind !== 'audio'
87
+ ? ['Text', 'Speed']
88
+ : kind === 'audio'
89
+ ? ['Speed', 'Audio']
90
+ : textValue != null
91
+ ? ['Speed', 'Audio', 'Crop', 'Grade', 'Text']
92
+ : ['Speed', 'Audio', 'Crop', 'Grade'];
93
+ const [tab, setTab] = useState(tabs[0]);
94
+ const [pos, setPos] = useState({ x, y });
95
+ const rootRef = useRef(null);
96
+
97
+ useEffect(() => {
98
+ const onKey = (e) => {
99
+ if (e.key === 'Escape') {
100
+ e.stopPropagation();
101
+ onClose?.();
102
+ }
103
+ };
104
+ window.addEventListener('keydown', onKey, true);
105
+ return () => window.removeEventListener('keydown', onKey, true);
106
+ }, [onClose]);
107
+
108
+ const beginDrag = useCallback(
109
+ (e) => {
110
+ if (e.target.closest('button, input, select')) return;
111
+ e.preventDefault();
112
+ const start = { x: e.clientX, y: e.clientY, px: pos.x, py: pos.y };
113
+ const move = (ev) =>
114
+ setPos({ x: start.px + ev.clientX - start.x, y: start.py + ev.clientY - start.y });
115
+ const up = () => {
116
+ window.removeEventListener('pointermove', move);
117
+ window.removeEventListener('pointerup', up);
118
+ };
119
+ window.addEventListener('pointermove', move);
120
+ window.addEventListener('pointerup', up);
121
+ },
122
+ [pos]
123
+ );
124
+
125
+ // Local knob state seeded from the authoritative props; commit on release.
126
+ const rate = mediaProps?.playbackRate ?? 1;
127
+ const volume = mediaProps?.muted ? 0 : (mediaProps?.volume ?? 1);
128
+ const framing = mediaProps?.framing ?? null;
129
+ const gradeParams = filterToGradeParams(mediaProps?.filter);
130
+ const gradeReadOnly = gradeParams == null;
131
+ const [grade, setGrade] = useState(gradeParams ?? {});
132
+ const [frame, setFrame] = useState(framing ?? { scale: 1, x: 0, y: 0 });
133
+ const [transDur, setTransDur] = useState(transition?.dur ?? 15);
134
+ useEffect(() => {
135
+ setGrade(filterToGradeParams(mediaProps?.filter) ?? {});
136
+ setFrame(mediaProps?.framing ?? { scale: 1, x: 0, y: 0 });
137
+ }, [mediaProps]);
138
+ useEffect(() => {
139
+ setTransDur(transition?.dur ?? 15);
140
+ }, [transition]);
141
+
142
+ const commitGrade = (next) => {
143
+ setGrade(next);
144
+ const meaningful = Object.entries(next).some(
145
+ ([k, v]) => (k === 'brightness' || k === 'contrast' || k === 'saturation' ? v !== 1 : v !== 0)
146
+ );
147
+ onVerb?.('grade', { grade: meaningful ? next : null });
148
+ };
149
+ const commitFrame = (next) => {
150
+ setFrame(next);
151
+ onVerb?.('framing', {
152
+ framing: next.scale === 1 && next.x === 0 && next.y === 0 ? null : next,
153
+ });
154
+ };
155
+
156
+ const gradeSlider = (label, key, min, max, step, neutral) => (
157
+ <label className="tlci-row" key={key}>
158
+ <span className="tlci-lbl">{label}</span>
159
+ <SliderField
160
+ value={grade[key] ?? neutral}
161
+ min={min}
162
+ max={max}
163
+ step={step}
164
+ onCommit={(v) => commitGrade({ ...grade, [key]: v })}
165
+ ariaLabel={label}
166
+ />
167
+ </label>
168
+ );
169
+
170
+ return (
171
+ <div
172
+ ref={rootRef}
173
+ className="tlci"
174
+ style={{ left: pos.x, top: pos.y }}
175
+ role="dialog"
176
+ aria-label={`Adjust ${clipLabel || 'clip'}`}
177
+ data-testid="timeline-inspector"
178
+ onPointerDown={(e) => e.stopPropagation()}
179
+ >
180
+ <div className="tlci-head" onPointerDown={beginDrag}>
181
+ <span className="tlci-title">{clipLabel || (mode === 'transition' ? 'Transition' : 'Clip')}</span>
182
+ <button type="button" className="tlci-x" aria-label="Close" onClick={onClose}>
183
+ ×
184
+ </button>
185
+ </div>
186
+ <TabBar tabs={tabs} active={tab} onPick={setTab} />
187
+
188
+ {tab === 'Speed' && (
189
+ <div className="tlci-body" role="tabpanel">
190
+ <div className="tlci-presets">
191
+ {SPEED_PRESETS.map((p) => (
192
+ <button
193
+ key={p}
194
+ type="button"
195
+ className={`tlci-chip${rate === p ? ' is-on' : ''}`}
196
+ data-testid={`timeline-speed-${String(p).replace('.', '_')}`}
197
+ onClick={() => onVerb?.('speed', { rate: p })}
198
+ >
199
+ {p}×
200
+ </button>
201
+ ))}
202
+ </div>
203
+ <label className="tlci-row">
204
+ <span className="tlci-lbl">Custom</span>
205
+ <NumberField
206
+ value={rate}
207
+ min={0.1}
208
+ max={16}
209
+ step={0.05}
210
+ onCommit={(v) => onVerb?.('speed', { rate: v })}
211
+ ariaLabel="Playback rate"
212
+ />
213
+ </label>
214
+ <p className="tlci-note">Duration recomputes; the cut ripples to stay gapless.</p>
215
+ </div>
216
+ )}
217
+
218
+ {tab === 'Audio' && (
219
+ <div className="tlci-body" role="tabpanel">
220
+ <label className="tlci-row">
221
+ <span className="tlci-lbl">Muted</span>
222
+ <Toggle
223
+ checked={!!mediaProps?.muted}
224
+ onChange={(v) => onVerb?.('audio', { muted: v })}
225
+ ariaLabel="Mute clip"
226
+ />
227
+ </label>
228
+ <label className="tlci-row">
229
+ <span className="tlci-lbl">Volume</span>
230
+ <SliderField
231
+ value={volume}
232
+ min={0}
233
+ max={1}
234
+ step={0.05}
235
+ onCommit={(v) => onVerb?.('audio', { volume: v })}
236
+ ariaLabel="Clip volume"
237
+ />
238
+ </label>
239
+ {kind === 'video' ? (
240
+ <button
241
+ type="button"
242
+ className="tlci-btn"
243
+ data-testid="timeline-detach-audio"
244
+ onClick={() => onVerb?.('detach-audio', {})}
245
+ >
246
+ Detach audio
247
+ </button>
248
+ ) : null}
249
+ </div>
250
+ )}
251
+
252
+ {tab === 'Crop' && (
253
+ <div className="tlci-body" role="tabpanel">
254
+ <label className="tlci-row">
255
+ <span className="tlci-lbl">Scale</span>
256
+ <NumberField
257
+ value={frame.scale}
258
+ min={1}
259
+ max={8}
260
+ step={0.05}
261
+ onCommit={(v) => commitFrame({ ...frame, scale: v })}
262
+ ariaLabel="Crop scale"
263
+ />
264
+ </label>
265
+ <label className="tlci-row">
266
+ <span className="tlci-lbl">X %</span>
267
+ <NumberField
268
+ value={frame.x}
269
+ min={-100}
270
+ max={100}
271
+ step={1}
272
+ onCommit={(v) => commitFrame({ ...frame, x: v })}
273
+ ariaLabel="Crop x offset"
274
+ />
275
+ </label>
276
+ <label className="tlci-row">
277
+ <span className="tlci-lbl">Y %</span>
278
+ <NumberField
279
+ value={frame.y}
280
+ min={-100}
281
+ max={100}
282
+ step={1}
283
+ onCommit={(v) => commitFrame({ ...frame, y: v })}
284
+ ariaLabel="Crop y offset"
285
+ />
286
+ </label>
287
+ <div className="tlci-presets">
288
+ <button type="button" className="tlci-chip" onClick={() => commitFrame({ scale: 1, x: 0, y: 0 })}>
289
+ Reset
290
+ </button>
291
+ <button type="button" className="tlci-chip" onClick={() => commitFrame({ ...frame, scale: 1.2 })}>
292
+ 1.2×
293
+ </button>
294
+ <button type="button" className="tlci-chip" onClick={() => commitFrame({ ...frame, scale: 1.5 })}>
295
+ 1.5×
296
+ </button>
297
+ </div>
298
+ </div>
299
+ )}
300
+
301
+ {tab === 'Grade' && (
302
+ <div className="tlci-body" role="tabpanel">
303
+ {gradeReadOnly ? (
304
+ <p className="tlci-note" data-testid="timeline-grade-readonly">
305
+ This clip carries a hand-written filter — edit it in code.
306
+ </p>
307
+ ) : (
308
+ <>
309
+ <div className="tlci-presets">
310
+ {Object.entries(GRADE_PRESETS).map(([name, params]) => (
311
+ <button
312
+ key={name}
313
+ type="button"
314
+ className="tlci-chip"
315
+ data-testid={`timeline-grade-${name.toLowerCase()}`}
316
+ onClick={() => commitGrade(params ? { ...params } : {})}
317
+ >
318
+ {name}
319
+ </button>
320
+ ))}
321
+ </div>
322
+ {gradeSlider('Brightness', 'brightness', 0.2, 2, 0.01, 1)}
323
+ {gradeSlider('Contrast', 'contrast', 0.2, 2, 0.01, 1)}
324
+ {gradeSlider('Saturation', 'saturation', 0, 2, 0.01, 1)}
325
+ {gradeSlider('Hue', 'hue', -180, 180, 1, 0)}
326
+ {gradeSlider('Sepia', 'sepia', 0, 1, 0.01, 0)}
327
+ {gradeSlider('Grayscale', 'grayscale', 0, 1, 0.01, 0)}
328
+ <p className="tlci-note">
329
+ Preview applies on the paused frame (WKWebView filter ceiling); exports are exact.
330
+ </p>
331
+ </>
332
+ )}
333
+ </div>
334
+ )}
335
+
336
+ {tab === 'Text' && (
337
+ <div className="tlci-body" role="tabpanel">
338
+ <textarea
339
+ className="tlci-textarea"
340
+ data-testid="timeline-inspector-text"
341
+ defaultValue={textValue || ''}
342
+ rows={4}
343
+ placeholder={kind === 'placeholder' ? 'Generation prompt…' : 'Title text…'}
344
+ onKeyDown={(e) => e.stopPropagation()}
345
+ />
346
+ <button
347
+ type="button"
348
+ className="tlci-btn"
349
+ data-testid="timeline-inspector-text-apply"
350
+ onClick={(e) => {
351
+ const v = e.currentTarget.closest('.tlci-body')?.querySelector('textarea')?.value;
352
+ if (v && v.trim()) onVerb?.('set-text', { text: v });
353
+ }}
354
+ >
355
+ Apply text
356
+ </button>
357
+ <p className="tlci-note">
358
+ {kind === 'placeholder'
359
+ ? 'Updates the generation prompt on the slate.'
360
+ : 'Rewrites the title text in the composition.'}
361
+ </p>
362
+ </div>
363
+ )}
364
+
365
+ {tab === 'Transition' && (
366
+ <div className="tlci-body" role="tabpanel">
367
+ <div className="tlci-presets" data-testid="timeline-transition-grid">
368
+ {PRESENTATIONS.map((p) => (
369
+ <button
370
+ key={p}
371
+ type="button"
372
+ className="tlci-chip"
373
+ data-testid={`timeline-transition-${p}`}
374
+ onClick={() => onVerb?.('transition', { presentation: p })}
375
+ >
376
+ {p}
377
+ </button>
378
+ ))}
379
+ </div>
380
+ <label className="tlci-row">
381
+ <span className="tlci-lbl">Frames</span>
382
+ <NumberField
383
+ value={transDur}
384
+ min={1}
385
+ max={120}
386
+ step={1}
387
+ onCommit={(v) => {
388
+ setTransDur(v);
389
+ onVerb?.('transition', { durationInFrames: v });
390
+ }}
391
+ ariaLabel="Transition duration"
392
+ />
393
+ </label>
394
+ <button
395
+ type="button"
396
+ className="tlci-btn"
397
+ data-testid="timeline-transition-remove"
398
+ onClick={() => {
399
+ onVerb?.('remove-transition', {});
400
+ onClose?.();
401
+ }}
402
+ >
403
+ Remove transition (hard cut)
404
+ </button>
405
+ </div>
406
+ )}
407
+ </div>
408
+ );
409
+ }
@@ -123,7 +123,13 @@ export default function CloudBar() {
123
123
  if (!isNativeApp()) return undefined;
124
124
  const consume = (url) => {
125
125
  const parsed = parseDeepLink(url);
126
- if (parsed) setPending(parsed);
126
+ if (!parsed) return;
127
+ // NEVER replace a link the person is already looking at. The strip is a
128
+ // single slot, so an overwrite would swap the code out from under a
129
+ // click that was aimed at the previous one — the person confirms the
130
+ // name they read and connects the link that arrived last (validate
131
+ // 2026-07-30, attacker A1). A second link waits its turn instead.
132
+ setPending((current) => current ?? parsed);
127
133
  };
128
134
  invoke('take_pending_deep_link')
129
135
  .then((url) => url && consume(url))
@@ -142,7 +148,11 @@ export default function CloudBar() {
142
148
  const r = await api('/_api/cloud/attach/code', {
143
149
  method: 'POST',
144
150
  headers: { 'content-type': 'application/json' },
145
- body: JSON.stringify({ code: pending.code }),
151
+ // Send the project the LINK claimed — the one this person just read in
152
+ // the confirm strip. The server compares it against what the exchange
153
+ // actually opens and refuses a mismatch, so a familiar name wrapped
154
+ // around somebody else's code cannot borrow that consent.
155
+ body: JSON.stringify({ code: pending.code, project: pending.project }),
146
156
  });
147
157
  setBusy('');
148
158
  setPending(null);