@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.
- package/apps/studio/api.ts +389 -14
- package/apps/studio/bin/_video-playwright.mjs +15 -6
- package/apps/studio/canvas-edit.ts +141 -20
- package/apps/studio/canvas-lib.tsx +61 -0
- package/apps/studio/client/app.jsx +824 -121
- package/apps/studio/client/panels/ClipInspector.jsx +409 -0
- package/apps/studio/client/panels/CloudBar.jsx +12 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
- package/apps/studio/client/panels/timeline-media-cache.js +274 -0
- package/apps/studio/client/panels/timeline-parse.js +46 -1
- package/apps/studio/client/panels/timeline-scale.js +64 -0
- package/apps/studio/client/report-bug.jsx +15 -15
- package/apps/studio/client/styles/3-shell-maude.css +446 -3
- package/apps/studio/client/styles/4-components-maude.css +55 -0
- package/apps/studio/client/styles/4-components.css +0 -83
- package/apps/studio/clip-ops.ts +1980 -0
- package/apps/studio/cloud/endpoints.ts +88 -17
- package/apps/studio/credential-grammar.ts +100 -0
- package/apps/studio/debug-bundle.ts +12 -0
- package/apps/studio/dist/client.bundle.js +2048 -2048
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video.ts +49 -10
- package/apps/studio/http.ts +113 -6
- package/apps/studio/ripple.ts +335 -0
- package/apps/studio/sync/hub-link.ts +3 -3
- package/apps/studio/sync/hubs-config.ts +32 -0
- package/apps/studio/sync/workspace-signin.ts +8 -4
- package/apps/studio/test/_helpers.ts +5 -3
- package/apps/studio/test/canvas-create-api.test.ts +8 -2
- package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
- package/apps/studio/test/clip-addressing.test.ts +7 -6
- package/apps/studio/test/clip-ops.test.ts +836 -0
- package/apps/studio/test/cloud-endpoints.test.ts +37 -2
- package/apps/studio/test/comments-api.test.ts +49 -0
- package/apps/studio/test/credential-grammar.test.ts +107 -0
- package/apps/studio/test/read-only-role.test.ts +95 -0
- package/apps/studio/test/ripple.test.ts +197 -0
- package/apps/studio/test/timeline-scale.test.ts +65 -0
- package/apps/studio/test/workspace-signin.test.ts +4 -1
- package/apps/studio/whats-new.json +35 -0
- package/cli/commands/share.mjs +8 -4
- package/cli/lib/ddr-to-kgai.mjs +6 -1
- package/cli/lib/ddr-to-kgai.test.mjs +1 -2
- package/cli/lib/hubs-config.mjs +15 -1
- package/cli/lib/hubs-config.test.mjs +19 -0
- package/cli/lib/share-plan.mjs +5 -1
- package/cli/lib/share-plan.test.mjs +11 -2
- 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]
|
|
2417
|
-
kinds[kinds.length - 1]
|
|
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
|
|
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.
|
|
4428
|
-
|
|
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
|
-
` <
|
|
4431
|
-
` <
|
|
4432
|
-
` </
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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, '"').replace(/[<>]/g, (c) => (c === '<' ? '<' : '>'));
|
|
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
|
|