@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.
- package/apps/studio/artboard-hug-commit.ts +30 -0
- package/apps/studio/client/app.jsx +22 -11
- package/apps/studio/client/panels/timeline-media-cache.js +38 -2
- package/apps/studio/clip-ops.ts +84 -35
- package/apps/studio/dist/client.bundle.js +936 -936
- package/apps/studio/test/artboard-hug-commit.test.ts +36 -0
- package/apps/studio/test/clip-ops.test.ts +84 -2
- package/apps/studio/test/timeline-media-cache.test.ts +41 -0
- package/package.json +8 -8
|
@@ -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
|
|
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={
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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')),
|
|
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
|
}
|
package/apps/studio/clip-ops.ts
CHANGED
|
@@ -1802,26 +1802,51 @@ export function applyMoveClipToOverlay(
|
|
|
1802
1802
|
});
|
|
1803
1803
|
}
|
|
1804
1804
|
const mediaStart = directMediaTagStart(source, clip);
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
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
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
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
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
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(
|