@1agh/maude 0.51.1 → 0.52.1

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
@@ -228,7 +228,7 @@ export async function acquireFileLock(filePath: string): Promise<() => Promise<v
228
228
  }
229
229
 
230
230
  const locks = new Map<string, Promise<void>>();
231
- function withLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
231
+ export function withLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
232
232
  const prev = locks.get(filePath) ?? Promise.resolve();
233
233
  let release!: () => void;
234
234
  const gate = new Promise<void>((res) => {
@@ -920,7 +920,7 @@ function getStringAttr(opening: AnyNode, name: string): string | null {
920
920
 
921
921
  /** Info about the line a byte offset sits on: the whitespace indentation, where
922
922
  * it begins, and whether a newline immediately precedes it. */
923
- function lineStartInfo(
923
+ export function lineStartInfo(
924
924
  source: string,
925
925
  pos: number
926
926
  ): { indent: string; indentStart: number; newlineBefore: boolean } {
@@ -2114,12 +2114,29 @@ export interface ClipInfo {
2114
2114
  mediaShared: boolean;
2115
2115
  /** Positional cd-id of the clip's OWN `<Sequence>` node (for `moveElement` z-order reorder). */
2116
2116
  clipCdId: string | null;
2117
+ /** feature-enhanced-video-editing — the media element's parametric props, so
2118
+ * the clip inspector shows authoritative current values (two-tokenizer rule:
2119
+ * the enumerator is the display source of truth). Null when the clip has no
2120
+ * direct media element. */
2121
+ mediaProps: {
2122
+ playbackRate: number | null;
2123
+ volume: number | null;
2124
+ muted: boolean;
2125
+ trimBefore: number | null;
2126
+ /** The style `filter` string (grade), when a literal. */
2127
+ filter: string | null;
2128
+ /** Crop/reposition wrapper marker (`data-mframe="scale,x,y"`). */
2129
+ framing: { scale: number; x: number; y: number } | null;
2130
+ } | null;
2117
2131
  /** The clip's stacked layers (mp4 background + title/lower-third + …) for the
2118
2132
  * expandable timeline rows. Empty for a pure inline card. */
2119
2133
  layers: ClipLayer[];
2120
2134
  /** True when the clip's body is gated behind `{false && (…)}` (hidden — renders
2121
2135
  * nothing but keeps its time slot + the TransitionSeries alternation). */
2122
2136
  hidden: boolean;
2137
+ /** enhanced-video-editing (Task 21) — a prompt-carrying `<AIPlaceholder>`
2138
+ * slate clip, awaiting generation. Null for ordinary clips. */
2139
+ placeholder: { prompt: string | null; kind: string } | null;
2123
2140
  /** Fingerprint of the clip's source span — refuse an op if it no longer matches. */
2124
2141
  contentHash: string;
2125
2142
  start: number;
@@ -2299,6 +2316,40 @@ export function enumerateClips(
2299
2316
  // Resolve media even through a wrapper component (the showreel pattern) so
2300
2317
  // the Timeline can badge the clip video/image/audio AND replace its source.
2301
2318
  const m = resolveNestedMedia(c.node, componentBodies, arrays, cdIdOf);
2319
+ // Parametric media props (enhanced-video-editing) — only for a DIRECT media
2320
+ // element (wrapper media is shared; the verbs refuse it anyway).
2321
+ let mediaProps: ClipInfo['mediaProps'] = null;
2322
+ const direct = firstMediaDescendant(c.node);
2323
+ if (direct) {
2324
+ const mo = direct.openingElement;
2325
+ let filter: string | null = null;
2326
+ const st = findAttribute(mo, 'style');
2327
+ if (
2328
+ st?.value?.type === 'JSXExpressionContainer' &&
2329
+ st.value.expression?.type === 'ObjectExpression'
2330
+ ) {
2331
+ for (const p of st.value.expression.properties ?? []) {
2332
+ const key = p?.key?.name ?? p?.key?.value;
2333
+ if (
2334
+ key === 'filter' &&
2335
+ (p.value?.type === 'Literal' || p.value?.type === 'StringLiteral')
2336
+ ) {
2337
+ filter = String(p.value.value);
2338
+ }
2339
+ }
2340
+ }
2341
+ let framing: { scale: number; x: number; y: number } | null = null;
2342
+ const fm = source.slice(c.start, c.end).match(/data-mframe="([-\d.]+),([-\d.]+),([-\d.]+)"/);
2343
+ if (fm) framing = { scale: Number(fm[1]), x: Number(fm[2]), y: Number(fm[3]) };
2344
+ mediaProps = {
2345
+ playbackRate: numAttr(mo, 'playbackRate', consts),
2346
+ volume: numAttr(mo, 'volume', consts),
2347
+ muted: !!findAttribute(mo, 'muted'),
2348
+ trimBefore: numAttr(mo, 'trimBefore', consts) ?? numAttr(mo, 'startFrom', consts),
2349
+ filter,
2350
+ framing,
2351
+ };
2352
+ }
2302
2353
  return {
2303
2354
  stableId,
2304
2355
  kind: c.kind,
@@ -2311,6 +2362,50 @@ export function enumerateClips(
2311
2362
  mediaArrayRef: m ? m.arrayRef : null,
2312
2363
  mediaShared: m ? m.shared : false,
2313
2364
  clipCdId: cdIdOf.get(c.node) ?? null,
2365
+ mediaProps,
2366
+ placeholder: (() => {
2367
+ // Direct <AIPlaceholder> child → a slate clip (Task 21). The prompt is
2368
+ // authored as `prompt={"…"}` (JSON.stringify — DDR-150 P1), so read
2369
+ // both the expression-container and plain-string spellings.
2370
+ for (const child of (c.node.children ?? []) as AnyNode[]) {
2371
+ if (child?.type !== 'JSXElement' || jsxTagName(child) !== 'AIPlaceholder') continue;
2372
+ const po = child.openingElement;
2373
+ const pAttr = findAttribute(po, 'prompt');
2374
+ let prompt: string | null = null;
2375
+ let pv = pAttr?.value;
2376
+ if (pv?.type === 'JSXExpressionContainer') pv = pv.expression;
2377
+ if (pv && (pv.type === 'Literal' || pv.type === 'StringLiteral')) {
2378
+ prompt = String(pv.value);
2379
+ }
2380
+ if (prompt == null) {
2381
+ // Paired-tag form: the prompt is a `{"…"}` literal inside a child
2382
+ // element (the inline-editable <span>). First string-literal
2383
+ // expression anywhere under the placeholder wins.
2384
+ const scan = (n: AnyNode | null | undefined): string | null => {
2385
+ if (!n || typeof n !== 'object') return null;
2386
+ if (n.type === 'JSXExpressionContainer') {
2387
+ const e = (n as AnyNode).expression;
2388
+ if (
2389
+ e &&
2390
+ (e.type === 'Literal' || e.type === 'StringLiteral') &&
2391
+ typeof e.value === 'string'
2392
+ ) {
2393
+ return String(e.value);
2394
+ }
2395
+ return null;
2396
+ }
2397
+ for (const kid of ((n as AnyNode).children ?? []) as AnyNode[]) {
2398
+ const hit = scan(kid);
2399
+ if (hit != null) return hit;
2400
+ }
2401
+ return null;
2402
+ };
2403
+ prompt = scan(child);
2404
+ }
2405
+ return { prompt, kind: getStringAttr(po, 'kind') ?? 'veo' };
2406
+ }
2407
+ return null;
2408
+ })(),
2314
2409
  layers: collectClipLayers(c.node, componentBodies, arrays, cdIdOf),
2315
2410
  hidden: clipChildrenHidden(c.node, source),
2316
2411
  contentHash: hashSpan(source, c.start, c.end),
@@ -2412,13 +2507,19 @@ export function assertCompSemantics(canvasAbsPath: string, source: string): { ok
2412
2507
  else if (tag && CLIP_TAGS.has(tag)) kinds.push('sequence');
2413
2508
  }
2414
2509
  if (kinds.length === 0) continue;
2510
+ // Adjacent SEQUENCES are valid Remotion (a hard cut — no transition between
2511
+ // beats); real shipped canvases use them, so the gate refuses only what
2512
+ // actually breaks the renderer: a transition at the head/tail, or two
2513
+ // transitions back-to-back (enhanced-video-editing relaxation of the
2514
+ // original strict-alternation check, which refused every op on a
2515
+ // hard-cut comp).
2415
2516
  const bad =
2416
- kinds[0] !== 'sequence' ||
2417
- kinds[kinds.length - 1] !== 'sequence' ||
2418
- kinds.some((k, i) => i > 0 && k === kinds[i - 1]);
2517
+ kinds[0] === 'transition' ||
2518
+ kinds[kinds.length - 1] === 'transition' ||
2519
+ kinds.some((k, i) => i > 0 && k === 'transition' && kinds[i - 1] === 'transition');
2419
2520
  if (bad) {
2420
2521
  throw new CanvasEditError(
2421
- 'TransitionSeries must alternate Sequence/Transition and begin + end with a Sequence (a leading, trailing, or doubled Transition is invalid Remotion)',
2522
+ 'TransitionSeries must begin + end with a Sequence and never stack two Transitions (a leading, trailing, or doubled Transition is invalid Remotion)',
2422
2523
  { canvas: canvasAbsPath, id: '' }
2423
2524
  );
2424
2525
  }
@@ -2428,7 +2529,7 @@ export function assertCompSemantics(canvasAbsPath: string, source: string): { ok
2428
2529
 
2429
2530
  /** Source span of an element extended back over its leading indent + newline
2430
2531
  * (so removing it doesn't leave a blank line) — mirrors moveElement's removeStart. */
2431
- function spanWithFraming(source: string, start: number, end: number): [number, number] {
2532
+ export function spanWithFraming(source: string, start: number, end: number): [number, number] {
2432
2533
  const line = lineStartInfo(source, start);
2433
2534
  const rs = line.newlineBefore ? line.indentStart - 1 : line.indentStart;
2434
2535
  return [rs, end];
@@ -4406,14 +4507,14 @@ function assertContainedAssetSrc(src: string, canvasAbsPath: string): void {
4406
4507
  export function assembleCompSource(
4407
4508
  componentName: string,
4408
4509
  clips: AssembleClip[],
4409
- opts: { fps?: number; width?: number; height?: number } = {}
4510
+ opts: { fps?: number; width?: number; height?: number; allowEmpty?: boolean } = {}
4410
4511
  ): string {
4411
4512
  const fps = Math.max(1, Math.round(opts.fps ?? 30));
4412
4513
  const width = Math.max(1, Math.round(opts.width ?? 1280));
4413
4514
  const height = Math.max(1, Math.round(opts.height ?? 720));
4414
4515
  const videos = clips.filter((c) => c.mediaKind === 'video');
4415
4516
  const audios = clips.filter((c) => c.mediaKind === 'audio');
4416
- if (videos.length === 0 && audios.length === 0) {
4517
+ if (videos.length === 0 && audios.length === 0 && !opts.allowEmpty) {
4417
4518
  throw new CanvasEditError('assemble needs at least one clip', {
4418
4519
  canvas: componentName,
4419
4520
  id: '',
@@ -4421,31 +4522,51 @@ export function assembleCompSource(
4421
4522
  }
4422
4523
  for (const c of clips) assertContainedAssetSrc(c.src, componentName);
4423
4524
 
4525
+ // enhanced-video-editing (Task 20, Blueprint default-container rule): video
4526
+ // beats land in a <TransitionSeries> STORYLINE (hard cuts — series membership
4527
+ // is what makes clips butt magnetically + accept seam transitions later).
4424
4528
  const defDur = fps * 3;
4425
4529
  let cursor = 0;
4426
4530
  const seqLines: string[] = [];
4427
- videos.forEach((c, i) => {
4428
- const dur = Math.max(1, Math.round(c.durationInFrames ?? defDur));
4531
+ if (videos.length) {
4532
+ seqLines.push(' <TransitionSeries>');
4533
+ videos.forEach((c, i) => {
4534
+ const dur = Math.max(1, Math.round(c.durationInFrames ?? defDur));
4535
+ seqLines.push(
4536
+ ` <TransitionSeries.Sequence name="clip-${i + 1}" durationInFrames={${dur}}>`,
4537
+ ` <OffthreadVideo src="${escapeAttr(c.src)}" />`,
4538
+ ` </TransitionSeries.Sequence>`
4539
+ );
4540
+ cursor += dur;
4541
+ });
4542
+ seqLines.push(' </TransitionSeries>');
4543
+ } else if (audios.length === 0) {
4544
+ // Greenfield empty comp — the drop-first hint (the first storyline drop
4545
+ // authors the <TransitionSeries> around it).
4429
4546
  seqLines.push(
4430
- ` <Sequence name="clip-${i + 1}" from={${cursor}} durationInFrames={${dur}}>`,
4431
- ` <OffthreadVideo src="${escapeAttr(c.src)}" />`,
4432
- ` </Sequence>`
4547
+ ` <AbsoluteFill style={{ display: 'grid', placeItems: 'center' }}>`,
4548
+ ` <div style={{ color: 'var(--fg-2, #888)', fontFamily: 'system-ui', fontSize: 24 }}>Drop clips on the timeline to start the cut</div>`,
4549
+ ` </AbsoluteFill>`
4433
4550
  );
4434
- cursor += dur;
4435
- });
4551
+ }
4436
4552
  // Audio beds run under the whole reel (no seek — a bed, not a clip).
4437
4553
  audios.forEach((c) => {
4438
4554
  seqLines.push(` <Audio src="${escapeAttr(c.src)}" />`);
4439
4555
  });
4440
- const total = Math.max(cursor, defDur);
4556
+ // Empty (greenfield) comps get a 5 s canvas to scrub; a real reel's total is
4557
+ // the cursor (with the historical defDur floor for degenerate inputs).
4558
+ const total = videos.length || audios.length ? Math.max(cursor, defDur) : fps * 5;
4441
4559
 
4442
- const remotionImports = ['AbsoluteFill', 'Sequence'];
4560
+ const remotionImports = ['AbsoluteFill'];
4443
4561
  if (videos.length) remotionImports.push('OffthreadVideo');
4444
4562
  if (audios.length) remotionImports.push('Audio');
4563
+ const transitionsImport = videos.length
4564
+ ? `import { TransitionSeries } from '@remotion/transitions';\n`
4565
+ : '';
4445
4566
 
4446
4567
  return [
4447
4568
  `import { DesignCanvas, DCSection, DCArtboard, VideoComp } from '@maude/canvas-lib';`,
4448
- `import { ${remotionImports.join(', ')} } from 'remotion';`,
4569
+ `${transitionsImport}import { ${remotionImports.join(', ')} } from 'remotion';`,
4449
4570
  ``,
4450
4571
  `const Comp = () => (`,
4451
4572
  ` <AbsoluteFill style={{ background: 'var(--bg-0)' }}>`,
@@ -4542,7 +4663,7 @@ function editStringAttr(
4542
4663
  s.appendLeft(insertAt, ` ${name}="${escapeAttr(value)}"`);
4543
4664
  }
4544
4665
 
4545
- function escapeAttr(value: string): string {
4666
+ export function escapeAttr(value: string): string {
4546
4667
  return value.replace(/"/g, '&quot;').replace(/[<>]/g, (c) => (c === '<' ? '&lt;' : '&gt;'));
4547
4668
  }
4548
4669
 
@@ -149,6 +149,67 @@ import { VideoComp } from './video-comp.tsx';
149
149
  export type { CompSnapshot, VideoCompMeta, VideoCompProps } from './video-comp.tsx';
150
150
  export { VideoComp };
151
151
 
152
+ // ─────────────────────────────────────────────────────────────────────────────
153
+ // AIPlaceholder — feature-enhanced-video-editing (Task 21). A prompt-carrying
154
+ // slate clip: occupies real timeline space inside a video-comp until the
155
+ // DDR-164 generation spine resolves it into media (replace-media swaps the
156
+ // slate in place; the clip's stableId survives). Deterministic, DS-tokened,
157
+ // export-safe (renders identically in the Player and both export paths).
158
+ // Plain absolutely-filled divs — deliberately no remotion import here.
159
+
160
+ export interface AIPlaceholderProps {
161
+ /** The generation prompt (user text — rendered as TEXT, never executed).
162
+ * Prefer passing it as CHILDREN (`<AIPlaceholder>{"…"}</AIPlaceholder>`) —
163
+ * a stamped child element is inline-editable in the artboard. */
164
+ prompt?: string;
165
+ /** Target generator: veo (video) · motion (motion-graphics video) · image. */
166
+ kind?: 'veo' | 'motion' | 'image';
167
+ /** Optional display hint (the clip's own durationInFrames rules playback). */
168
+ durationInFrames?: number;
169
+ /** The prompt slot (wins over `prompt` when present). */
170
+ children?: React.ReactNode;
171
+ }
172
+
173
+ export function AIPlaceholder({
174
+ prompt,
175
+ kind = 'veo',
176
+ durationInFrames,
177
+ children,
178
+ }: AIPlaceholderProps) {
179
+ return (
180
+ <div
181
+ data-ai-placeholder={kind}
182
+ style={{
183
+ position: 'absolute',
184
+ inset: 0,
185
+ display: 'grid',
186
+ placeItems: 'center',
187
+ background:
188
+ 'repeating-linear-gradient(45deg, var(--bg-0, #101014), var(--bg-0, #101014) 14px, var(--bg-1, #16161c) 14px, var(--bg-1, #16161c) 28px)',
189
+ border: '2px dashed var(--accent, #7c8cf8)',
190
+ boxSizing: 'border-box',
191
+ color: 'var(--fg-0, #fff)',
192
+ fontFamily: 'system-ui, sans-serif',
193
+ textAlign: 'center',
194
+ padding: '6%',
195
+ }}
196
+ >
197
+ <div style={{ display: 'grid', gap: 10, maxWidth: '80%' }}>
198
+ <div style={{ fontSize: 22, lineHeight: 1.4, color: 'var(--fg-0, #fff)' }}>
199
+ {children ?? prompt}
200
+ </div>
201
+ {durationInFrames ? (
202
+ <div
203
+ style={{ fontSize: 12, color: 'var(--fg-2, #9aa)', fontVariantNumeric: 'tabular-nums' }}
204
+ >
205
+ {durationInFrames}f
206
+ </div>
207
+ ) : null}
208
+ </div>
209
+ </div>
210
+ );
211
+ }
212
+
152
213
  // ─────────────────────────────────────────────────────────────────────────────
153
214
  // Module constants
154
215