@1agh/maude 0.57.0 → 0.58.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.
@@ -0,0 +1,115 @@
1
+ // feature-studio-file-preview — inline preview for a non-canvas tree row
2
+ // (markdown / text / image / video / audio / font). Reuses the existing
3
+ // static byte-serving route (no new server endpoint) and the chat panel's
4
+ // hand-rolled Markdown renderer — no new dependency.
5
+
6
+ import { useEffect, useState } from 'react';
7
+ import { Markdown } from './chat-markdown.jsx';
8
+
9
+ const TEXT_PREVIEW_MAX_BYTES = 2 * 1024 * 1024; // 2 MB — beyond this, just show a size note
10
+
11
+ function basename(p) {
12
+ return p.split('/').pop() || p;
13
+ }
14
+
15
+ // Bidi-override control chars (U+202A-E, U+2066-9) let a crafted filename
16
+ // visually disguise its own name/extension (the classic "invoice‮gnp.exe"
17
+ // trick) wherever we render a raw path/name as text — strip them before
18
+ // display. Doesn't affect matching (previewKind() regexes run on the
19
+ // unstripped name), only what a human/AT reads.
20
+ export function sanitizeDisplayText(s) {
21
+ return String(s).replace(/[\u202a-\u202e\u2066-\u2069]/g, '');
22
+ }
23
+
24
+ function useFetchedText(url, enabled) {
25
+ const [state, setState] = useState({ loading: true, error: null, text: '', tooLarge: false });
26
+ useEffect(() => {
27
+ if (!enabled) return;
28
+ let cancelled = false;
29
+ setState({ loading: true, error: null, text: '', tooLarge: false });
30
+ fetch(url)
31
+ .then(async (r) => {
32
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
33
+ const len = Number(r.headers.get('content-length') || 0);
34
+ if (len > TEXT_PREVIEW_MAX_BYTES) {
35
+ if (!cancelled) setState({ loading: false, error: null, text: '', tooLarge: len });
36
+ return;
37
+ }
38
+ const text = await r.text();
39
+ if (!cancelled) setState({ loading: false, error: null, text, tooLarge: false });
40
+ })
41
+ .catch((err) => {
42
+ if (!cancelled) setState({ loading: false, error: err.message || 'Failed to load', text: '', tooLarge: false });
43
+ });
44
+ return () => {
45
+ cancelled = true;
46
+ };
47
+ }, [url, enabled]);
48
+ return state;
49
+ }
50
+
51
+ function TextPreview({ url, name, as }) {
52
+ const { loading, error, text, tooLarge } = useFetchedText(url, true);
53
+ if (loading) return <div className="st-file-preview-status">Loading {name}…</div>;
54
+ if (error) return <div className="st-file-preview-status st-file-preview-error">Couldn't load {name}: {error}</div>;
55
+ if (tooLarge) {
56
+ return (
57
+ <div className="st-file-preview-status">
58
+ {name} is {Math.round(tooLarge / 1024)} KB — too large to preview inline.
59
+ </div>
60
+ );
61
+ }
62
+ if (as === 'markdown') return <div className="st-file-preview-markdown"><Markdown text={text} /></div>;
63
+ return <pre className="st-file-preview-text">{text}</pre>;
64
+ }
65
+
66
+ // CSS-string-literal escape for a value interpolated inside url("...") —
67
+ // filenames can contain arbitrary characters (only "/" and NUL are forbidden
68
+ // on disk), so an unescaped `"` or `\` could break out of the string and
69
+ // inject arbitrary rules into the live app-shell stylesheet (not just the
70
+ // sandboxed canvas iframe). family is already alnum/hyphen-only; this covers
71
+ // the url() side of the same interpolation.
72
+ function cssStringEscape(s) {
73
+ // CSS input-preprocessing (CSS Syntax Module Level 3 §3.3) normalizes CR,
74
+ // CRLF, AND FORM FEED (\f, U+000C) to a single LF before tokenizing, so an
75
+ // unescaped \f terminates a double-quoted string exactly like \r/\n does —
76
+ // strip all three, not just the two obvious ones.
77
+ return String(s).replace(/[\\"]/g, (c) => `\\${c}`).replace(/[\r\n\f]/g, '');
78
+ }
79
+
80
+ function FontPreview({ url, name }) {
81
+ const family = `st-preview-font-${name.replace(/[^a-z0-9]+/gi, '-')}`;
82
+ return (
83
+ <div className="st-file-preview-font">
84
+ <style>{`@font-face { font-family: "${family}"; src: url("${cssStringEscape(url)}"); }`}</style>
85
+ <div className="st-file-preview-font-specimen" style={{ fontFamily: family }}>
86
+ <div className="st-file-preview-font-lg">The quick brown fox</div>
87
+ <div className="st-file-preview-font-md">jumps over the lazy dog</div>
88
+ <div className="st-file-preview-font-sm">ABCDEFGHIJKLM abcdefghijklm 0123456789</div>
89
+ </div>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ export function FilePreview({ path, kind }) {
95
+ if (!path || !kind) return null;
96
+ const url = `/${path}`;
97
+ const name = sanitizeDisplayText(basename(path));
98
+ return (
99
+ <div className="st-file-preview" data-kind={kind}>
100
+ <h2 className="st-file-preview-header">{sanitizeDisplayText(path)}</h2>
101
+ <div className="st-file-preview-body">
102
+ {kind === 'markdown' && <TextPreview url={url} name={name} as="markdown" />}
103
+ {kind === 'text' && <TextPreview url={url} name={name} as="text" />}
104
+ {kind === 'image' && (
105
+ <div className="st-file-preview-image-wrap">
106
+ <img src={url} alt={`Preview: ${name}`} className="st-file-preview-image" />
107
+ </div>
108
+ )}
109
+ {kind === 'video' && <video key={url} src={url} controls className="st-file-preview-media" />}
110
+ {kind === 'audio' && <audio key={url} src={url} controls className="st-file-preview-audio" />}
111
+ {kind === 'font' && <FontPreview url={url} name={name} />}
112
+ </div>
113
+ </div>
114
+ );
115
+ }
@@ -80,7 +80,10 @@ export function stripBucket(widthPx) {
80
80
  return 7;
81
81
  }
82
82
 
83
- async function extractStrip(url, count) {
83
+ /** Hidden <video>, loaded and waited on through `loadedmetadata`/`canplay` so
84
+ * `.duration`/`.videoWidth`/`.videoHeight` are readable. Caller owns cleanup
85
+ * (`video.remove()`) once done with it. */
86
+ async function loadVideoMetadata(url, timeoutMs) {
84
87
  const video = document.createElement('video');
85
88
  video.muted = true;
86
89
  video.playsInline = true;
@@ -99,7 +102,7 @@ async function extractStrip(url, count) {
99
102
  }
100
103
  try {
101
104
  await new Promise((res, rej) => {
102
- const to = setTimeout(() => rej(new Error('metadata timeout')), 8000);
105
+ const to = setTimeout(() => rej(new Error('metadata timeout')), timeoutMs);
103
106
  const ok = () => {
104
107
  clearTimeout(to);
105
108
  res();
@@ -114,6 +117,39 @@ async function extractStrip(url, count) {
114
117
  });
115
118
  } catch (err) {
116
119
  video.remove();
120
+ throw err;
121
+ }
122
+ return video;
123
+ }
124
+
125
+ /** Real duration (seconds) of a video/audio src, probed via a hidden <video>
126
+ * element — never throws, resolves null on failure so callers (e.g. the
127
+ * timeline drop handler) can fall back to a default duration cleanly. */
128
+ export async function probeMediaDuration(url) {
129
+ let video;
130
+ try {
131
+ video = await loadVideoMetadata(url, 6000);
132
+ } catch {
133
+ return null;
134
+ }
135
+ const dur = video.duration;
136
+ video.remove();
137
+ return Number.isFinite(dur) && dur > 0 ? dur : null;
138
+ }
139
+
140
+ /** durationInFrames for a clip dropped on the timeline: the real probed
141
+ * duration when available, else a fallback (probe failure, or synthetic
142
+ * content with no inherent duration like a title/image). */
143
+ export function durationFramesForDrop(fps, probedSeconds, fallbackSeconds = 3) {
144
+ const sec = Number.isFinite(probedSeconds) && probedSeconds > 0 ? probedSeconds : fallbackSeconds;
145
+ return Math.max(1, Math.round(fps * sec));
146
+ }
147
+
148
+ async function extractStrip(url, count) {
149
+ let video;
150
+ try {
151
+ video = await loadVideoMetadata(url, 8000);
152
+ } catch (err) {
117
153
  console.warn('[timeline] filmstrip extraction failed for', url, err?.message || err);
118
154
  throw err;
119
155
  }
@@ -219,6 +219,10 @@
219
219
  .st-sb-changes.has-changes .st-sb-changes-dot { background: var(--status-warn); }
220
220
  .st-sb-changes.has-unpushed .st-sb-changes-dot { background: var(--accent); }
221
221
 
222
+ /* The running release. Ambient, not a state to act on — so it sits at the same
223
+ weight as a slot label rather than at .val's foreground. No new tokens. */
224
+ .st-sb-version .val { color: var(--fg-3); }
225
+
222
226
  /* ═══ Task 4 — body row · sidebar · tree · rail ═══════════════════════════════ */
223
227
 
224
228
  /* The body flex row replaces the legacy `.app` grid: rail | sidebar | main | rpanel. */
@@ -425,6 +429,47 @@
425
429
  incomplete; auto-hides once probeSetupReadiness() reports ready. */
426
430
  .st-empty-quick-setup { pointer-events: auto; margin-top: var(--space-2); }
427
431
 
432
+ /* ═══ feature-studio-file-preview — non-canvas file preview overlay ════════
433
+ * Layered above the (still-mounted) canvas iframe / empty state so switching
434
+ * back to a canvas is instant. Shell-token only, no hardcoded colors. */
435
+ .st-file-preview-overlay {
436
+ position: absolute; inset: 0; z-index: 5; display: flex; flex-direction: column;
437
+ background: var(--bg-1); overflow: auto;
438
+ }
439
+ .st-file-preview-overlay:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
440
+ .st-file-preview { display: flex; flex-direction: column; min-height: 100%; }
441
+ .st-file-preview-header {
442
+ flex: none; margin: 0; padding: var(--space-3) var(--space-4); font-family: var(--font-mono);
443
+ font-size: var(--type-xs); font-weight: 400; color: var(--fg-2); border-bottom: 1px solid var(--border-subtle);
444
+ background: var(--bg-0);
445
+ }
446
+ .st-file-preview-body { flex: 1; padding: var(--space-6); }
447
+ .st-file-preview-status { color: var(--fg-3); font-size: var(--type-sm); }
448
+ .st-file-preview-error { color: var(--status-error); }
449
+ .st-file-preview-text {
450
+ font-family: var(--font-mono); font-size: var(--type-xs); line-height: 1.6; color: var(--fg-1);
451
+ white-space: pre-wrap; word-break: break-word; background: var(--bg-0);
452
+ border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: var(--space-4);
453
+ }
454
+ .st-file-preview-markdown { max-width: 720px; color: var(--fg-1); }
455
+ .st-file-preview-image-wrap {
456
+ display: flex; align-items: center; justify-content: center; min-height: 200px;
457
+ background-image: linear-gradient(45deg, var(--bg-2) 25%, transparent 25%),
458
+ linear-gradient(-45deg, var(--bg-2) 25%, transparent 25%),
459
+ linear-gradient(45deg, transparent 75%, var(--bg-2) 75%),
460
+ linear-gradient(-45deg, transparent 75%, var(--bg-2) 75%);
461
+ background-size: 16px 16px; background-position: 0 0, 0 8px, 8px -8px, -8px 0;
462
+ border: 1px solid var(--border-subtle); border-radius: var(--radius-sm);
463
+ }
464
+ .st-file-preview-image { max-width: 100%; max-height: 70vh; object-fit: contain; }
465
+ .st-file-preview-media { max-width: 100%; max-height: 70vh; display: block; }
466
+ .st-file-preview-audio { width: 100%; }
467
+ .st-file-preview-font { display: flex; flex-direction: column; gap: var(--space-4); }
468
+ .st-file-preview-font-specimen { color: var(--fg-0); }
469
+ .st-file-preview-font-lg { font-size: 40px; line-height: 1.3; }
470
+ .st-file-preview-font-md { font-size: 24px; line-height: 1.4; }
471
+ .st-file-preview-font-sm { font-size: 14px; line-height: 1.6; color: var(--fg-2); word-break: break-all; }
472
+
428
473
  /* ═══ Task 6 — right panel (Inspector / Comments) + palette + toast ═══════════ */
429
474
  .st-rpanel {
430
475
  background: var(--bg-1); border-left: 1px solid var(--border-default);
@@ -619,6 +619,34 @@
619
619
  border-radius: var(--radius-sm);
620
620
  overflow-x: auto;
621
621
  }
622
+ .chat-md-table-wrap {
623
+ overflow-x: auto;
624
+ border: 1px solid var(--border-subtle);
625
+ border-radius: var(--radius-sm);
626
+ }
627
+ .chat-md-table {
628
+ width: 100%;
629
+ border-collapse: collapse;
630
+ font-size: var(--type-xs);
631
+ }
632
+ .chat-md-table th,
633
+ .chat-md-table td {
634
+ padding: var(--space-2) var(--space-3);
635
+ text-align: left;
636
+ border-bottom: 1px solid var(--border-subtle);
637
+ white-space: pre-wrap;
638
+ }
639
+ .chat-md-table th {
640
+ background: var(--bg-2);
641
+ font-weight: 600;
642
+ color: var(--fg-0);
643
+ }
644
+ .chat-md-table td {
645
+ color: var(--fg-1);
646
+ }
647
+ .chat-md-table tbody tr:last-child td {
648
+ border-bottom: none;
649
+ }
622
650
  .chat-code code {
623
651
  font-family: var(--font-mono);
624
652
  font-size: var(--type-xs);
@@ -1802,26 +1802,51 @@ export function applyMoveClipToOverlay(
1802
1802
  });
1803
1803
  }
1804
1804
  const mediaStart = directMediaTagStart(source, clip);
1805
- if (mediaStart == null || !clip.mediaSrc || clip.durationInFrames == null) {
1806
- throw new CanvasEditError('only a media clip with a direct source can move between layers', {
1807
- canvas: canvasAbsPath,
1808
- id: stableId,
1809
- });
1805
+ const isPlaceholder = clip.placeholder != null;
1806
+ if (!isPlaceholder && (mediaStart == null || !clip.mediaSrc || clip.durationInFrames == null)) {
1807
+ throw new CanvasEditError(
1808
+ 'only a media clip with a direct source, or an AI placeholder, can move between layers',
1809
+ { canvas: canvasAbsPath, id: stableId }
1810
+ );
1811
+ }
1812
+ if (isPlaceholder && clip.durationInFrames == null) {
1813
+ throw new CanvasEditError(
1814
+ 'only a media clip with a direct source, or an AI placeholder, can move between layers',
1815
+ { canvas: canvasAbsPath, id: stableId }
1816
+ );
1810
1817
  }
1811
1818
  const start = clipAbsoluteStart(source, canvasAbsPath, artboardId, stableId);
1812
- const trim =
1813
- numAttrText(source, mediaStart, 'trimBefore') ??
1814
- numAttrText(source, mediaStart, 'startFrom') ??
1815
- 0;
1816
- const rate = numAttrText(source, mediaStart, 'playbackRate') ?? 1;
1817
- const ins = applyInsertClipAt(canvasAbsPath, source, artboardId, {
1818
- lane: 'overlay',
1819
- from: start,
1820
- durationInFrames: clip.durationInFrames,
1821
- mediaTag: clip.mediaTag === 'Audio' ? 'Audio' : 'Video',
1822
- src: clip.mediaSrc,
1823
- });
1824
- let out = graftMediaAttrs(canvasAbsPath, ins.source, artboardId, ins.stableId, trim, rate);
1819
+ // A placeholder slate has no media element to graft trim/rate onto — it
1820
+ // rides over as-is (prompt + kind), never as media.
1821
+ const ins = isPlaceholder
1822
+ ? applyInsertClipAt(canvasAbsPath, source, artboardId, {
1823
+ lane: 'overlay',
1824
+ from: start,
1825
+ durationInFrames: clip.durationInFrames as number,
1826
+ placeholder: {
1827
+ prompt: clip.placeholder?.prompt ?? '',
1828
+ kind: clip.placeholder?.kind ?? 'veo',
1829
+ },
1830
+ })
1831
+ : applyInsertClipAt(canvasAbsPath, source, artboardId, {
1832
+ lane: 'overlay',
1833
+ from: start,
1834
+ durationInFrames: clip.durationInFrames as number,
1835
+ mediaTag: clip.mediaTag === 'Audio' ? 'Audio' : 'Video',
1836
+ src: clip.mediaSrc,
1837
+ });
1838
+ let out = isPlaceholder
1839
+ ? ins.source
1840
+ : graftMediaAttrs(
1841
+ canvasAbsPath,
1842
+ ins.source,
1843
+ artboardId,
1844
+ ins.stableId,
1845
+ numAttrText(source, mediaStart as number, 'trimBefore') ??
1846
+ numAttrText(source, mediaStart as number, 'startFrom') ??
1847
+ 0,
1848
+ numAttrText(source, mediaStart as number, 'playbackRate') ?? 1
1849
+ );
1825
1850
  const again = enumerateClips(canvasAbsPath, out, artboardId);
1826
1851
  const insertedHash = again.clips.find((c) => c.stableId === ins.stableId)?.contentHash ?? null;
1827
1852
  const original = again.clips.find(
@@ -1861,24 +1886,48 @@ export function applyMoveClipToStoryline(
1861
1886
  throw new CanvasEditError('already a storyline beat', { canvas: canvasAbsPath, id: stableId });
1862
1887
  }
1863
1888
  const mediaStart = directMediaTagStart(source, clip);
1864
- if (mediaStart == null || !clip.mediaSrc || clip.durationInFrames == null) {
1865
- throw new CanvasEditError('only a media clip with a direct source can move between layers', {
1866
- canvas: canvasAbsPath,
1867
- id: stableId,
1868
- });
1889
+ const isPlaceholder = clip.placeholder != null;
1890
+ if (!isPlaceholder && (mediaStart == null || !clip.mediaSrc || clip.durationInFrames == null)) {
1891
+ throw new CanvasEditError(
1892
+ 'only a media clip with a direct source, or an AI placeholder, can move between layers',
1893
+ { canvas: canvasAbsPath, id: stableId }
1894
+ );
1869
1895
  }
1870
- const trim =
1871
- numAttrText(source, mediaStart, 'trimBefore') ??
1872
- numAttrText(source, mediaStart, 'startFrom') ??
1873
- 0;
1874
- const rate = numAttrText(source, mediaStart, 'playbackRate') ?? 1;
1875
- const ins = applyInsertClipAt(canvasAbsPath, source, artboardId, {
1876
- lane: 'storyline',
1877
- durationInFrames: clip.durationInFrames,
1878
- mediaTag: clip.mediaTag === 'Audio' ? 'Audio' : 'Video',
1879
- src: clip.mediaSrc,
1880
- });
1881
- let out = graftMediaAttrs(canvasAbsPath, ins.source, artboardId, ins.stableId, trim, rate);
1896
+ if (isPlaceholder && clip.durationInFrames == null) {
1897
+ throw new CanvasEditError(
1898
+ 'only a media clip with a direct source, or an AI placeholder, can move between layers',
1899
+ { canvas: canvasAbsPath, id: stableId }
1900
+ );
1901
+ }
1902
+ // A placeholder slate has no media element to graft trim/rate onto — it
1903
+ // rides over as-is (prompt + kind), never as media.
1904
+ const ins = isPlaceholder
1905
+ ? applyInsertClipAt(canvasAbsPath, source, artboardId, {
1906
+ lane: 'storyline',
1907
+ durationInFrames: clip.durationInFrames as number,
1908
+ placeholder: {
1909
+ prompt: clip.placeholder?.prompt ?? '',
1910
+ kind: clip.placeholder?.kind ?? 'veo',
1911
+ },
1912
+ })
1913
+ : applyInsertClipAt(canvasAbsPath, source, artboardId, {
1914
+ lane: 'storyline',
1915
+ durationInFrames: clip.durationInFrames as number,
1916
+ mediaTag: clip.mediaTag === 'Audio' ? 'Audio' : 'Video',
1917
+ src: clip.mediaSrc,
1918
+ });
1919
+ let out = isPlaceholder
1920
+ ? ins.source
1921
+ : graftMediaAttrs(
1922
+ canvasAbsPath,
1923
+ ins.source,
1924
+ artboardId,
1925
+ ins.stableId,
1926
+ numAttrText(source, mediaStart as number, 'trimBefore') ??
1927
+ numAttrText(source, mediaStart as number, 'startFrom') ??
1928
+ 0,
1929
+ numAttrText(source, mediaStart as number, 'playbackRate') ?? 1
1930
+ );
1882
1931
  const again = enumerateClips(canvasAbsPath, out, artboardId);
1883
1932
  const insertedHash = again.clips.find((c) => c.stableId === ins.stableId)?.contentHash ?? null;
1884
1933
  const original = again.clips.find(