@1agh/maude 0.58.0 → 0.58.2

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.
@@ -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
  }
@@ -1235,6 +1235,18 @@
1235
1235
  .chat-qa {
1236
1236
  font-family: var(--font-mono);
1237
1237
  }
1238
+ /* Issue #74 — the "Implement N comments" action. Its siblings are literal
1239
+ slash commands (hence mono); this one is prose ABOUT the canvas's current
1240
+ state and only appears when there is something to act on, so it reads as
1241
+ the row's contextual action: proportional type + a light accent tint. */
1242
+ .chat-qa--comments {
1243
+ font-family: inherit;
1244
+ border-color: var(--accent-muted);
1245
+ color: var(--accent);
1246
+ }
1247
+ .chat-qa--comments:hover {
1248
+ background: var(--accent-tint);
1249
+ }
1238
1250
 
1239
1251
  /* Composer */
1240
1252
  .chat-composer {
@@ -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(