@1agh/maude 0.58.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,30 @@
1
+ /**
2
+ * @file artboard-hug-commit.ts
3
+ * @purpose Pure decision for what the Artboard Inspector's Height field
4
+ * should do when a value is committed (issue #79 — typing a height
5
+ * while the artboard hugs its content silently did nothing; Width
6
+ * worked because it has no such mode). Kept framework-free + in a
7
+ * .ts file so it's unit-testable (app.jsx is bundled JSX, and its
8
+ * module-scope `createRoot(...).render(...)` makes it unsafe to
9
+ * import directly in a test) — same shape as sizing-mode.ts.
10
+ */
11
+
12
+ export type HeightCommitAction =
13
+ | { kind: 'resize'; height: number }
14
+ | { kind: 'promote-to-fixed'; height: number }
15
+ | { kind: 'noop' };
16
+
17
+ /**
18
+ * Fixed mode: a typed height is an ordinary resize, same lane as Width.
19
+ * Hug mode (DDR-027 Design Decision 1 — height is a content-driven floor,
20
+ * not an exact size): a typed height now PROMOTES the artboard to Fixed at
21
+ * that height — the same "freeze current height" write the Hug/Fixed
22
+ * segmented toggle already performs, just seeded from the typed value
23
+ * instead of the last measured one — rather than being silently dropped.
24
+ */
25
+ export function resolveHeightCommit(fixed: boolean, typedHeight: number): HeightCommitAction {
26
+ const height =
27
+ Number.isFinite(typedHeight) && typedHeight > 0 ? Math.round(typedHeight) : undefined;
28
+ if (height == null) return { kind: 'noop' };
29
+ return fixed ? { kind: 'resize', height } : { kind: 'promote-to-fixed', height };
30
+ }
@@ -20,6 +20,7 @@ import { GRID_KEYWORD_UNITS, parseTrackList, serializeTrackList } from '../grid-
20
20
  // Inspector's paper-preset picker, same "pull only pure math/data" shape as
21
21
  // canvas-cursors.ts above.
22
22
  import { PAPER_PRESETS, resolvePrintArtboard } from '../print/units.ts';
23
+ import { resolveHeightCommit } from '../artboard-hug-commit.ts';
23
24
  import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
24
25
  // The single "what is the hub link doing" rule, shared with the cloud rail's
25
26
  // connect note so the two surfaces can never disagree. Pure data + strings —
@@ -42,6 +43,7 @@ import { FilePreview, sanitizeDisplayText } from './panels/file-preview.jsx';
42
43
  import SetupChecklistDialog, { useSetupReadiness } from './panels/SetupChecklist.jsx';
43
44
  import TimelinePanel from './panels/TimelinePanel.jsx';
44
45
  import { parseCompTimeline } from './panels/timeline-parse.js';
46
+ import { durationFramesForDrop, probeMediaDuration } from './panels/timeline-media-cache.js';
45
47
  import GenerateDialog from './generate-dialog.jsx';
46
48
  import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
47
49
  import SettingsPanel from './panels/SettingsPanel.jsx';
@@ -8262,11 +8264,17 @@ function ArtboardKnobs({
8262
8264
  onResizeArtboard?.(artboardId, resolved.widthPx, resolved.heightPx);
8263
8265
  onSetArtboardPrint?.(artboardId, next);
8264
8266
  };
8265
- const setHug = (nextFixed) => {
8267
+ const setHug = (nextFixed, explicitHeight) => {
8266
8268
  if (!artboardId) return;
8267
- const freezeHeight = nextFixed && Number.isFinite(h) ? Math.round(h) : undefined;
8269
+ const src = Number.isFinite(explicitHeight) ? explicitHeight : h;
8270
+ const freezeHeight = nextFixed && Number.isFinite(src) ? Math.round(src) : undefined;
8268
8271
  onSetArtboardHug?.(artboardId, nextFixed, freezeHeight);
8269
8272
  };
8273
+ const commitHeight = (n) => {
8274
+ const action = resolveHeightCommit(fixed, n);
8275
+ if (action.kind === 'resize') commitSize(null, action.height);
8276
+ else if (action.kind === 'promote-to-fixed') setHug(true, action.height);
8277
+ };
8270
8278
  const commitStyle = (patch) => {
8271
8279
  if (!artboardId) return;
8272
8280
  onSetArtboardStyle?.(artboardId, patch);
@@ -8292,11 +8300,7 @@ function ArtboardKnobs({
8292
8300
  lead="H"
8293
8301
  steppers={false}
8294
8302
  scrub={fixed}
8295
- onCommit={(n) => {
8296
- // Hug — height is content-driven; ignore a typed value rather
8297
- // than silently switching modes.
8298
- if (fixed) commitSize(null, n);
8299
- }}
8303
+ onCommit={commitHeight}
8300
8304
  />
8301
8305
  </div>
8302
8306
  <div style={{ padding: '0 12px 8px' }}>
@@ -14919,10 +14923,11 @@ function App() {
14919
14923
  // fresh "New Cut" canvas only when no artboard opted in.
14920
14924
  let upgraded = false;
14921
14925
  for (const c of clips) {
14926
+ const probedSec = await probeMediaDuration(c.src).catch(() => null);
14922
14927
  const body = {
14923
14928
  canvas,
14924
14929
  lane: c.mediaKind === 'audio' && upgraded ? 'audio' : 'storyline',
14925
- durationInFrames: Math.round(fps * 3),
14930
+ durationInFrames: durationFramesForDrop(fps, probedSec),
14926
14931
  mediaTag: c.mediaKind === 'audio' ? 'Audio' : 'Video',
14927
14932
  src: c.src,
14928
14933
  };
@@ -14993,6 +14998,12 @@ function App() {
14993
14998
  continue;
14994
14999
  }
14995
15000
  const isAudio = mediaTag === 'Audio';
15001
+ // Video clips carry a real duration — probe it so the drop
15002
+ // lands at the clip's own length instead of a fixed 3s that
15003
+ // the user then has to drag out by hand. Images have no
15004
+ // inherent duration, so they keep the fallback.
15005
+ const probedSec =
15006
+ mediaTag === 'Video' ? await probeMediaDuration(up.path).catch(() => null) : null;
14996
15007
  const body = { canvas, artboardId, mediaTag, src: up.path };
14997
15008
  if (pos && (isAudio || pos.lane === 'audio')) {
14998
15009
  body.lane = 'audio';
@@ -15005,7 +15016,7 @@ function App() {
15005
15016
  body.index = slot;
15006
15017
  slot += 1; // multiple files insert in order
15007
15018
  }
15008
- body.durationInFrames = Math.round(fps * 3);
15019
+ body.durationInFrames = durationFramesForDrop(fps, probedSec);
15009
15020
  } else if (pos?.lane === 'overlay' && !isAudio) {
15010
15021
  if ((timelineSequences || []).length === 0) {
15011
15022
  // First clip of a greenfield comp = the BASE layer →
@@ -15015,14 +15026,14 @@ function App() {
15015
15026
  body.lane = 'overlay';
15016
15027
  body.from = Math.max(0, pos.frame ?? 0);
15017
15028
  }
15018
- body.durationInFrames = Math.round(fps * 3);
15029
+ body.durationInFrames = durationFramesForDrop(fps, probedSec);
15019
15030
  } else if (!isAudio) {
15020
15031
  // Default container rule: a video/image drop ANYWHERE on the
15021
15032
  // timeline lands in the storyline (append = hard cut at the
15022
15033
  // end; the caret gives it an index). The old lane-less
15023
15034
  // append refused hard-cut series ("no transition to clone").
15024
15035
  body.lane = 'storyline';
15025
- body.durationInFrames = Math.round(fps * 3);
15036
+ body.durationInFrames = durationFramesForDrop(fps, probedSec);
15026
15037
  } else {
15027
15038
  // Audio without a caret → audio band, from the start.
15028
15039
  body.lane = 'audio';
@@ -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
  }
@@ -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(