@1agh/maude 0.58.2 → 0.58.3
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/annotations-bindings.ts +83 -4
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +44 -1
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1261 -1261
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +306 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +276 -26
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -80,7 +80,7 @@ function StatusIcon({ present }) {
|
|
|
80
80
|
);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export function SetupChecklist({ report, loading, refresh, onStartTour, onBringBrand }) {
|
|
83
|
+
export function SetupChecklist({ report, loading, refresh, onStartTour, onBringBrand, onImportFigma }) {
|
|
84
84
|
return (
|
|
85
85
|
<div className="setup-cl">
|
|
86
86
|
<ul className="setup-cl-list">
|
|
@@ -131,6 +131,16 @@ export function SetupChecklist({ report, loading, refresh, onStartTour, onBringB
|
|
|
131
131
|
Bring my existing brand
|
|
132
132
|
</a>
|
|
133
133
|
)}
|
|
134
|
+
{onImportFigma && (
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
data-testid="onboarding-import-figma"
|
|
138
|
+
className="btn btn--ghost btn--sm"
|
|
139
|
+
onClick={onImportFigma}
|
|
140
|
+
>
|
|
141
|
+
Import from Figma
|
|
142
|
+
</button>
|
|
143
|
+
)}
|
|
134
144
|
{refresh && (
|
|
135
145
|
<button
|
|
136
146
|
type="button"
|
|
@@ -148,7 +158,13 @@ export function SetupChecklist({ report, loading, refresh, onStartTour, onBringB
|
|
|
148
158
|
|
|
149
159
|
// Standalone modal, reachable from Help ▸ Quick setup… — reuses the shared
|
|
150
160
|
// help-modal chrome (backdrop + header + body), same shape as ReadinessDialog.
|
|
151
|
-
export default function SetupChecklistDialog({
|
|
161
|
+
export default function SetupChecklistDialog({
|
|
162
|
+
open,
|
|
163
|
+
onClose,
|
|
164
|
+
onStartTour,
|
|
165
|
+
onBringBrand,
|
|
166
|
+
onImportFigma,
|
|
167
|
+
}) {
|
|
152
168
|
const { report, loading, refresh } = useSetupReadiness(open);
|
|
153
169
|
useEffect(() => {
|
|
154
170
|
if (!open) return undefined;
|
|
@@ -210,6 +226,14 @@ export default function SetupChecklistDialog({ open, onClose, onStartTour, onBri
|
|
|
210
226
|
}
|
|
211
227
|
: undefined
|
|
212
228
|
}
|
|
229
|
+
onImportFigma={
|
|
230
|
+
onImportFigma
|
|
231
|
+
? () => {
|
|
232
|
+
onClose();
|
|
233
|
+
onImportFigma();
|
|
234
|
+
}
|
|
235
|
+
: undefined
|
|
236
|
+
}
|
|
213
237
|
/>
|
|
214
238
|
) : (
|
|
215
239
|
<p className="setup-cl-note">
|
|
@@ -44,7 +44,7 @@ import { ContextMenuView } from '../../context-menu.tsx';
|
|
|
44
44
|
function rowKind(seq) {
|
|
45
45
|
if (seq.placeholder) return 'placeholder';
|
|
46
46
|
const t = seq.mediaTag;
|
|
47
|
-
if (t === 'Video' || t === 'OffthreadVideo') return 'video';
|
|
47
|
+
if (t === 'Video' || t === 'OffthreadVideo' || t === 'MaudeVideo') return 'video';
|
|
48
48
|
if (t === 'Img') return 'image';
|
|
49
49
|
if (t === 'Audio') return 'audio';
|
|
50
50
|
return 'jsx';
|
|
@@ -981,7 +981,7 @@ export default function TimelinePanel({
|
|
|
981
981
|
// bare tag name as the clip's identity — "Clip N" beats "bca08b1e.mov";
|
|
982
982
|
// the real src stays in the tooltip.
|
|
983
983
|
const rawName =
|
|
984
|
-
['Video', 'OffthreadVideo', 'Audio', 'Img'].includes(seq.label) && seq.mediaSrc
|
|
984
|
+
['Video', 'OffthreadVideo', 'Audio', 'Img', 'MaudeVideo', 'MaudeAudio', 'MaudeImg'].includes(seq.label) && seq.mediaSrc
|
|
985
985
|
? String(seq.mediaSrc).split('/').pop()
|
|
986
986
|
: seq.label;
|
|
987
987
|
const isHashName = /^[a-f0-9]{6,16}\.\w+$/i.test(rawName || '');
|
|
@@ -123,7 +123,7 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
|
|
|
123
123
|
if (usages.length) {
|
|
124
124
|
const target =
|
|
125
125
|
(selectedArtboardId && usages.find((u) => u.artboardId === selectedArtboardId)) ||
|
|
126
|
-
usages.find((u) => /<(?:Audio|OffthreadVideo|Video)\b/.test(u.body)) ||
|
|
126
|
+
usages.find((u) => /<(?:Maude)?(?:Audio|OffthreadVideo|Video)\b/.test(u.body)) ||
|
|
127
127
|
usages.find((u) => u.duration != null && u.duration === totalFrames) ||
|
|
128
128
|
usages[0];
|
|
129
129
|
scope = target.body;
|
|
@@ -162,7 +162,7 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
|
|
|
162
162
|
const lm = after.match(/<([A-Z][A-Za-z0-9]*)\b/);
|
|
163
163
|
// Media child sniff (badge + replace affordance): the first
|
|
164
164
|
// <Video|OffthreadVideo|Audio|Img src="…"> DIRECTLY inside the clip body.
|
|
165
|
-
const mm = after.match(/<(Video|OffthreadVideo|Audio|Img)\b[^>]*src=["']([^"']+)["']/);
|
|
165
|
+
const mm = after.match(/<(?:Maude)?(Video|OffthreadVideo|Audio|Img)\b[^>]*src=["']([^"']+)["']/);
|
|
166
166
|
const nameM = attrs.match(/name=["']([^"']+)["']/);
|
|
167
167
|
items.push({
|
|
168
168
|
type: 'seq',
|
|
@@ -241,7 +241,7 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
|
|
|
241
241
|
// every clip span. Remotion renders it from frame 0 for the whole comp, so
|
|
242
242
|
// show it as a read-only overlay row (`loose: true` — no clip ops; it has no
|
|
243
243
|
// enumerator identity). Dogfood 2026-07-30: these were invisible before.
|
|
244
|
-
for (const lm of scope.matchAll(/<(OffthreadVideo|Video|Img)\b([^>]*?)\/?>/g)) {
|
|
244
|
+
for (const lm of scope.matchAll(/<(?:Maude)?(OffthreadVideo|Video|Img)\b([^>]*?)\/?>/g)) {
|
|
245
245
|
const at = lm.index ?? 0;
|
|
246
246
|
if (clipSpans.some(([s2, e2]) => at >= s2 && at <= e2)) continue;
|
|
247
247
|
const srcM = lm[2].match(/src=["']([^"']+)["']/);
|
|
@@ -2120,6 +2120,9 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2120
2120
|
.gp-save { flex: 1; justify-content: center; }
|
|
2121
2121
|
.gp-save:disabled, .gp-save[aria-disabled="true"], .gp-publish:disabled, .gp-publish[aria-disabled="true"] { background: var(--bg-3); color: var(--fg-3); border-color: var(--border-default); cursor: not-allowed; }
|
|
2122
2122
|
.gp-hint { font-size: var(--type-xs); color: var(--fg-2); }
|
|
2123
|
+
/* cloud-managed posture (DDR-218) — the one save mechanism, named */
|
|
2124
|
+
.gp-cloud-note { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-1) 0; font-size: var(--type-xs); color: var(--fg-2); line-height: var(--lh-base); }
|
|
2125
|
+
.gp-cloud-note svg { color: var(--accent); flex: 0 0 auto; }
|
|
2123
2126
|
|
|
2124
2127
|
/* publish / get-latest bar */
|
|
2125
2128
|
.gp-publishbar { flex: none; border-top: 1px solid var(--border-default); padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); background: var(--bg-1); }
|
|
@@ -2318,6 +2321,10 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2318
2321
|
.gi-menu-item:hover { background: var(--bg-2); color: var(--fg-0); }
|
|
2319
2322
|
.gi-menu-item:hover svg { color: var(--fg-1); }
|
|
2320
2323
|
.gi-menu-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
2324
|
+
/* the already-linked project's row — a state, not an action (fix 7, sync RCA) */
|
|
2325
|
+
.gi-menu-item.is-connected { cursor: default; }
|
|
2326
|
+
.gi-menu-item.is-connected:hover { background: transparent; color: var(--fg-1); }
|
|
2327
|
+
.gi-menu-item.is-connected:hover svg { color: var(--fg-2); }
|
|
2321
2328
|
.gi-menu-item--danger, .gi-menu-item--danger svg { color: var(--status-error); }
|
|
2322
2329
|
.gi-menu-item--danger:hover { background: color-mix(in oklab, var(--status-error) 14%, transparent); color: var(--status-error); }
|
|
2323
2330
|
.gi-menu-sep { height: 1px; background: var(--border-subtle); margin: var(--space-1) 0; }
|
|
@@ -1399,6 +1399,27 @@
|
|
|
1399
1399
|
background: color-mix(in oklab, var(--u-status-error) 18%, transparent);
|
|
1400
1400
|
color: var(--u-status-error);
|
|
1401
1401
|
}
|
|
1402
|
+
/* A degraded export is `done` but not clean — it must never read as success.
|
|
1403
|
+
Overrides --done, so declaration order matters. */
|
|
1404
|
+
.st-export-pill--degraded {
|
|
1405
|
+
background: color-mix(in oklab, var(--u-status-warning, var(--u-accent)) 18%, transparent);
|
|
1406
|
+
color: var(--u-status-warning, var(--u-accent));
|
|
1407
|
+
}
|
|
1408
|
+
.st-export-degraded {
|
|
1409
|
+
font-size: 12px;
|
|
1410
|
+
line-height: 1.45;
|
|
1411
|
+
color: var(--u-status-warning, var(--u-accent));
|
|
1412
|
+
padding: 6px 8px;
|
|
1413
|
+
border-radius: var(--radius-sm, 6px);
|
|
1414
|
+
background: color-mix(in oklab, var(--u-status-warning, var(--u-accent)) 10%, transparent);
|
|
1415
|
+
}
|
|
1416
|
+
.st-export-degraded__fix {
|
|
1417
|
+
margin-top: 4px;
|
|
1418
|
+
color: var(--u-fg-2);
|
|
1419
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
1420
|
+
font-size: 11px;
|
|
1421
|
+
overflow-wrap: anywhere;
|
|
1422
|
+
}
|
|
1402
1423
|
.st-export-progress {
|
|
1403
1424
|
position: relative;
|
|
1404
1425
|
height: 4px;
|
|
@@ -2023,3 +2044,116 @@
|
|
|
2023
2044
|
color: var(--u-fg-0);
|
|
2024
2045
|
background: var(--u-bg-2);
|
|
2025
2046
|
}
|
|
2047
|
+
|
|
2048
|
+
/* ── Figma import panel (DDR-216 / T11) ─────────────────────────────────────
|
|
2049
|
+
* Rides the shared .st-scrim / .st-dialog shell (3-shell-maude.css). Tokens
|
|
2050
|
+
* only — no hardcoded colour, and no inline `style=` in the JSX either, because
|
|
2051
|
+
* the canvas CSP (`style-src 'self'`) drops inline styles SILENTLY. */
|
|
2052
|
+
.st-figma-dialog {
|
|
2053
|
+
width: min(560px, 92vw);
|
|
2054
|
+
padding-bottom: var(--space-4);
|
|
2055
|
+
}
|
|
2056
|
+
.st-figma-label {
|
|
2057
|
+
display: block;
|
|
2058
|
+
padding: var(--space-3) var(--space-5) 4px;
|
|
2059
|
+
font-size: var(--type-xs, 12px);
|
|
2060
|
+
color: var(--fg-2);
|
|
2061
|
+
}
|
|
2062
|
+
.st-figma-input {
|
|
2063
|
+
display: block;
|
|
2064
|
+
width: calc(100% - (2 * var(--space-5)));
|
|
2065
|
+
margin: 0 var(--space-5);
|
|
2066
|
+
padding: 8px 10px;
|
|
2067
|
+
border: 1px solid var(--border-default);
|
|
2068
|
+
border-radius: var(--radius-sm);
|
|
2069
|
+
background: var(--bg-1);
|
|
2070
|
+
color: var(--fg-0);
|
|
2071
|
+
font-family: var(--font-mono);
|
|
2072
|
+
font-size: var(--type-sm, 13px);
|
|
2073
|
+
}
|
|
2074
|
+
.st-figma-modes {
|
|
2075
|
+
border: 0;
|
|
2076
|
+
margin: 0;
|
|
2077
|
+
padding: 0 var(--space-5);
|
|
2078
|
+
}
|
|
2079
|
+
.st-figma-mode {
|
|
2080
|
+
display: flex;
|
|
2081
|
+
align-items: baseline;
|
|
2082
|
+
gap: 8px;
|
|
2083
|
+
padding: 4px 0;
|
|
2084
|
+
font-size: var(--type-sm, 13px);
|
|
2085
|
+
color: var(--fg-1);
|
|
2086
|
+
}
|
|
2087
|
+
.st-figma-mode-hint {
|
|
2088
|
+
color: var(--fg-3);
|
|
2089
|
+
font-size: var(--type-xs, 12px);
|
|
2090
|
+
}
|
|
2091
|
+
.st-figma-actions {
|
|
2092
|
+
display: flex;
|
|
2093
|
+
justify-content: flex-end;
|
|
2094
|
+
gap: 8px;
|
|
2095
|
+
padding: var(--space-4) var(--space-5) 0;
|
|
2096
|
+
}
|
|
2097
|
+
.st-figma-notice {
|
|
2098
|
+
margin: var(--space-3) var(--space-5) 0;
|
|
2099
|
+
padding: 8px 10px;
|
|
2100
|
+
border: 1px solid var(--border-subtle);
|
|
2101
|
+
border-radius: var(--radius-sm);
|
|
2102
|
+
background: var(--bg-2);
|
|
2103
|
+
color: var(--fg-2);
|
|
2104
|
+
font-size: var(--type-xs, 12px);
|
|
2105
|
+
}
|
|
2106
|
+
/* The third-party-content banner. Deliberately reads as a caution, not as a
|
|
2107
|
+
* provenance badge — an "imported from Figma" stamp otherwise makes the most
|
|
2108
|
+
* attacker-influenced artifact in the tree look like verified design work. */
|
|
2109
|
+
.st-figma-notice-warn {
|
|
2110
|
+
border-color: var(--status-warn, var(--border-default));
|
|
2111
|
+
color: var(--fg-1);
|
|
2112
|
+
}
|
|
2113
|
+
.st-figma-error {
|
|
2114
|
+
margin: var(--space-3) var(--space-5) 0;
|
|
2115
|
+
color: var(--status-danger, var(--fg-0));
|
|
2116
|
+
font-size: var(--type-xs, 12px);
|
|
2117
|
+
}
|
|
2118
|
+
.st-figma-summary {
|
|
2119
|
+
margin: var(--space-3) var(--space-5) 0;
|
|
2120
|
+
padding: 10px;
|
|
2121
|
+
border: 1px solid var(--border-subtle);
|
|
2122
|
+
border-radius: var(--radius-sm);
|
|
2123
|
+
background: var(--bg-2);
|
|
2124
|
+
font-size: var(--type-xs, 12px);
|
|
2125
|
+
color: var(--fg-2);
|
|
2126
|
+
}
|
|
2127
|
+
.st-figma-summary-hd {
|
|
2128
|
+
color: var(--fg-1);
|
|
2129
|
+
font-weight: 600;
|
|
2130
|
+
margin-bottom: 6px;
|
|
2131
|
+
}
|
|
2132
|
+
.st-figma-summary-row {
|
|
2133
|
+
margin: var(--space-2) var(--space-5) 0;
|
|
2134
|
+
font-size: var(--type-xs, 12px);
|
|
2135
|
+
color: var(--fg-2);
|
|
2136
|
+
}
|
|
2137
|
+
.st-figma-summary .st-figma-summary-row {
|
|
2138
|
+
margin: 4px 0 0;
|
|
2139
|
+
}
|
|
2140
|
+
.st-figma-summary-list {
|
|
2141
|
+
list-style: none;
|
|
2142
|
+
margin: 0;
|
|
2143
|
+
padding: 0;
|
|
2144
|
+
display: grid;
|
|
2145
|
+
gap: 4px;
|
|
2146
|
+
}
|
|
2147
|
+
.st-figma-summary-kind {
|
|
2148
|
+
color: var(--fg-1);
|
|
2149
|
+
}
|
|
2150
|
+
.st-figma-summary-count {
|
|
2151
|
+
font-family: var(--font-mono);
|
|
2152
|
+
color: var(--fg-2);
|
|
2153
|
+
}
|
|
2154
|
+
.st-figma-summary-ids {
|
|
2155
|
+
display: block;
|
|
2156
|
+
font-family: var(--font-mono);
|
|
2157
|
+
color: var(--fg-3);
|
|
2158
|
+
overflow-wrap: anywhere;
|
|
2159
|
+
}
|
package/apps/studio/clip-ops.ts
CHANGED
|
@@ -341,16 +341,24 @@ export function applyInsertClipAt(
|
|
|
341
341
|
}
|
|
342
342
|
const dur = Math.max(1, Math.round(opts.durationInFrames));
|
|
343
343
|
const srcTrim = (opts.src ?? '').trim();
|
|
344
|
-
const media =
|
|
344
|
+
const media: { tag: string; src: string; importName: string; specifier: string } | null =
|
|
345
345
|
opts.mediaTag && INSERTABLE_MEDIA.has(opts.mediaTag) && srcTrim
|
|
346
|
-
? { tag: opts.mediaTag, src: srcTrim }
|
|
346
|
+
? { tag: opts.mediaTag, src: srcTrim, importName: opts.mediaTag, specifier: 'remotion' }
|
|
347
347
|
: null;
|
|
348
348
|
if (media) {
|
|
349
349
|
assertContainedSrc(canvasAbsPath, media.src, artboardId ?? '');
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
350
|
+
// This used to rewrite <Video> → <OffthreadVideo> as "house spelling", to
|
|
351
|
+
// dodge a collision with a canvas component named `Video`. That trade was
|
|
352
|
+
// wrong in one direction nobody noticed: <OffthreadVideo> is rejected by
|
|
353
|
+
// @remotion/web-renderer, so the editor was silently converting the
|
|
354
|
+
// audio-capable element into the audio-killing one behind the author's
|
|
355
|
+
// back — every inserted clip exported muted (RCA
|
|
356
|
+
// issue-mp4-audio-export-html5audio-silent-degrade). The collision is real,
|
|
357
|
+
// so it is now solved where it belongs: in the import (see resolveMediaTag).
|
|
358
|
+
const resolved = resolveMediaTag(source, media.tag);
|
|
359
|
+
media.tag = resolved.emitTag;
|
|
360
|
+
media.importName = resolved.importName;
|
|
361
|
+
media.specifier = resolved.specifier;
|
|
354
362
|
}
|
|
355
363
|
|
|
356
364
|
const seqs = cc.clips.filter((c) => c.kind === 'sequence');
|
|
@@ -403,7 +411,7 @@ export function applyInsertClipAt(
|
|
|
403
411
|
);
|
|
404
412
|
ensureImportPublic(s2, source, 'TransitionSeries', '@remotion/transitions');
|
|
405
413
|
if (opts.placeholder) ensureNamedImport(s2, source, 'AIPlaceholder', '@maude/canvas-lib');
|
|
406
|
-
if (media) ensureNamedImport(s2, source, media.
|
|
414
|
+
if (media) ensureNamedImport(s2, source, media.importName, media.specifier);
|
|
407
415
|
const out2 = s2.toString();
|
|
408
416
|
assertParses(canvasAbsPath, out2, artboardId ?? '');
|
|
409
417
|
assertCompSemantics(canvasAbsPath, out2);
|
|
@@ -426,7 +434,7 @@ export function applyInsertClipAt(
|
|
|
426
434
|
? `\n${indent} <${media.tag} src="${escapeAttr(media.src)}" />\n${indent}`
|
|
427
435
|
: '';
|
|
428
436
|
if (opts.placeholder) ensureNamedImport(s, source, 'AIPlaceholder', '@maude/canvas-lib');
|
|
429
|
-
if (media) ensureNamedImport(s, source, media.
|
|
437
|
+
if (media) ensureNamedImport(s, source, media.importName, media.specifier);
|
|
430
438
|
const beatText = `<${seriesPrefix}.Sequence durationInFrames={${dur}}>${mediaText}</${seriesPrefix}.Sequence>`;
|
|
431
439
|
if (index === 0) {
|
|
432
440
|
// Head insert: B (T) before the first beat.
|
|
@@ -514,7 +522,7 @@ export function applyInsertClipAt(
|
|
|
514
522
|
? `\n${indent} <${media.tag} src="${escapeAttr(media.src)}" />\n${indent}`
|
|
515
523
|
: '';
|
|
516
524
|
if (opts.placeholder) ensureNamedImport(s, source, 'AIPlaceholder', '@maude/canvas-lib');
|
|
517
|
-
if (media) ensureNamedImport(s, source, media.
|
|
525
|
+
if (media) ensureNamedImport(s, source, media.importName, media.specifier);
|
|
518
526
|
if (titleText) ensureNamedImport(s, source, 'AbsoluteFill', 'remotion');
|
|
519
527
|
ensureNamedImport(s, source, 'Sequence', 'remotion');
|
|
520
528
|
const clipText = `\n${indent}<Sequence from={${from}} durationInFrames={${dur}}>${mediaText}</Sequence>`;
|
|
@@ -549,7 +557,7 @@ export function applyInsertClipAt(
|
|
|
549
557
|
s2.appendLeft(openStart, '<>');
|
|
550
558
|
s2.appendRight(insertAt, `${clipText}\n${indent}</>`);
|
|
551
559
|
if (opts.placeholder) ensureNamedImport(s2, source, 'AIPlaceholder', '@maude/canvas-lib');
|
|
552
|
-
if (media) ensureNamedImport(s2, source, media.
|
|
560
|
+
if (media) ensureNamedImport(s2, source, media.importName, media.specifier);
|
|
553
561
|
if (titleText) ensureNamedImport(s2, source, 'AbsoluteFill', 'remotion');
|
|
554
562
|
ensureNamedImport(s2, source, 'Sequence', 'remotion');
|
|
555
563
|
out = s2.toString();
|
|
@@ -675,7 +683,7 @@ function setTagAttr(
|
|
|
675
683
|
function directMediaTagStart(
|
|
676
684
|
source: string,
|
|
677
685
|
clip: ClipInfo,
|
|
678
|
-
tags = ['Video', 'OffthreadVideo', 'Audio', 'Img']
|
|
686
|
+
tags = ['Video', 'OffthreadVideo', 'Audio', 'Img', 'MaudeVideo', 'MaudeAudio', 'MaudeImg']
|
|
679
687
|
): number | null {
|
|
680
688
|
const span = source.slice(clip.start, clip.end);
|
|
681
689
|
const m = span.match(new RegExp(`<(${tags.join('|')})\\b`));
|
|
@@ -737,7 +745,13 @@ export function applySetPlaybackRate(
|
|
|
737
745
|
}
|
|
738
746
|
const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
|
|
739
747
|
const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
|
|
740
|
-
const mediaStart = directMediaTagStart(source, clip, [
|
|
748
|
+
const mediaStart = directMediaTagStart(source, clip, [
|
|
749
|
+
'Video',
|
|
750
|
+
'OffthreadVideo',
|
|
751
|
+
'Audio',
|
|
752
|
+
'MaudeVideo',
|
|
753
|
+
'MaudeAudio',
|
|
754
|
+
]);
|
|
741
755
|
if (mediaStart == null) {
|
|
742
756
|
throw new CanvasEditError(
|
|
743
757
|
`clip "${stableId}" has no direct media element — speed applies to <Video>/<Audio> clips (shared/wrapper media would change every user)`,
|
|
@@ -780,7 +794,13 @@ export function applyTrimIn(
|
|
|
780
794
|
const delta = Math.round(deltaFrames);
|
|
781
795
|
const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
|
|
782
796
|
const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
|
|
783
|
-
const mediaStart = directMediaTagStart(source, clip, [
|
|
797
|
+
const mediaStart = directMediaTagStart(source, clip, [
|
|
798
|
+
'Video',
|
|
799
|
+
'OffthreadVideo',
|
|
800
|
+
'Audio',
|
|
801
|
+
'MaudeVideo',
|
|
802
|
+
'MaudeAudio',
|
|
803
|
+
]);
|
|
784
804
|
if (mediaStart == null) {
|
|
785
805
|
throw new CanvasEditError(
|
|
786
806
|
`clip "${stableId}" has no direct media element — in-point trim applies to media clips`,
|
|
@@ -843,7 +863,13 @@ export function applyClipAudio(
|
|
|
843
863
|
): { source: string } {
|
|
844
864
|
const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
|
|
845
865
|
const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
|
|
846
|
-
const mediaStart = directMediaTagStart(source, clip, [
|
|
866
|
+
const mediaStart = directMediaTagStart(source, clip, [
|
|
867
|
+
'Video',
|
|
868
|
+
'OffthreadVideo',
|
|
869
|
+
'Audio',
|
|
870
|
+
'MaudeVideo',
|
|
871
|
+
'MaudeAudio',
|
|
872
|
+
]);
|
|
847
873
|
if (mediaStart == null) {
|
|
848
874
|
throw new CanvasEditError(`clip "${stableId}" has no direct media element to set audio on`, {
|
|
849
875
|
canvas: canvasAbsPath,
|
|
@@ -882,7 +908,7 @@ export function applyDetachAudio(
|
|
|
882
908
|
): { source: string; audioStableId: string | null } {
|
|
883
909
|
const cc = enumerateClips(canvasAbsPath, source, artboardId);
|
|
884
910
|
const { clip } = mustResolve(canvasAbsPath, cc.clips, stableId, expectedHash);
|
|
885
|
-
const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo']);
|
|
911
|
+
const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo', 'MaudeVideo']);
|
|
886
912
|
if (mediaStart == null || !clip.mediaSrc) {
|
|
887
913
|
throw new CanvasEditError(
|
|
888
914
|
`clip "${stableId}" has no direct video media with a literal src to detach`,
|
|
@@ -1038,7 +1064,13 @@ export function applyClipGrade(
|
|
|
1038
1064
|
): { source: string; filter: string } {
|
|
1039
1065
|
const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
|
|
1040
1066
|
const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
|
|
1041
|
-
const mediaStart = directMediaTagStart(source, clip, [
|
|
1067
|
+
const mediaStart = directMediaTagStart(source, clip, [
|
|
1068
|
+
'Video',
|
|
1069
|
+
'OffthreadVideo',
|
|
1070
|
+
'Img',
|
|
1071
|
+
'MaudeVideo',
|
|
1072
|
+
'MaudeImg',
|
|
1073
|
+
]);
|
|
1042
1074
|
if (mediaStart == null) {
|
|
1043
1075
|
throw new CanvasEditError(`clip "${stableId}" has no direct media element to grade`, {
|
|
1044
1076
|
canvas: canvasAbsPath,
|
|
@@ -1096,7 +1128,13 @@ export function applyClipFraming(
|
|
|
1096
1128
|
): { source: string } {
|
|
1097
1129
|
const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
|
|
1098
1130
|
const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
|
|
1099
|
-
const mediaStart = directMediaTagStart(source, clip, [
|
|
1131
|
+
const mediaStart = directMediaTagStart(source, clip, [
|
|
1132
|
+
'Video',
|
|
1133
|
+
'OffthreadVideo',
|
|
1134
|
+
'Img',
|
|
1135
|
+
'MaudeVideo',
|
|
1136
|
+
'MaudeImg',
|
|
1137
|
+
]);
|
|
1100
1138
|
if (mediaStart == null) {
|
|
1101
1139
|
throw new CanvasEditError(`clip "${stableId}" has no direct media element to frame`, {
|
|
1102
1140
|
canvas: canvasAbsPath,
|
|
@@ -1410,6 +1448,44 @@ export function applySplitClip(
|
|
|
1410
1448
|
|
|
1411
1449
|
/** Ensure `name` is among the named imports from `specifier` (extends an
|
|
1412
1450
|
* existing import line, else adds one after the last import). */
|
|
1451
|
+
/**
|
|
1452
|
+
* Where an inserted media element must come from.
|
|
1453
|
+
*
|
|
1454
|
+
* `Video` and `Audio` MUST resolve to `@remotion/media` — those are the only
|
|
1455
|
+
* media elements `@remotion/web-renderer` can render, and importing them from
|
|
1456
|
+
* `remotion` (where `Audio` IS `Html5Audio`) makes the export drop its audio
|
|
1457
|
+
* track and fall back to a ~40× slower capture. RCA
|
|
1458
|
+
* `issue-mp4-audio-export-html5audio-silent-degrade`.
|
|
1459
|
+
*/
|
|
1460
|
+
const MEDIA_SPECIFIER: Record<string, string> = {
|
|
1461
|
+
Video: '@remotion/media',
|
|
1462
|
+
Audio: '@remotion/media',
|
|
1463
|
+
Img: 'remotion',
|
|
1464
|
+
OffthreadVideo: 'remotion',
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Import an inserted media element from the right package, aliasing it when the
|
|
1469
|
+
* canvas already declares a component of that name.
|
|
1470
|
+
*
|
|
1471
|
+
* The collision is real — a canvas with its own `const Video = …` would have the
|
|
1472
|
+
* emitted `<Video>` resolve to itself. The old fix was to emit
|
|
1473
|
+
* `<OffthreadVideo>` instead, which silently cost the audio track. Aliasing
|
|
1474
|
+
* keeps the collision solved AND the audio intact. Returns the tag to emit.
|
|
1475
|
+
*/
|
|
1476
|
+
function resolveMediaTag(
|
|
1477
|
+
source: string,
|
|
1478
|
+
tag: string
|
|
1479
|
+
): { emitTag: string; importName: string; specifier: string } {
|
|
1480
|
+
const specifier = MEDIA_SPECIFIER[tag] ?? 'remotion';
|
|
1481
|
+
const declaresLocally = new RegExp(
|
|
1482
|
+
`(?:const|let|var|function|class)\\s+${tag}\\b|\\b${tag}\\s*[:=]\\s*\\(`
|
|
1483
|
+
).test(source);
|
|
1484
|
+
if (!declaresLocally) return { emitTag: tag, importName: tag, specifier };
|
|
1485
|
+
const alias = `Maude${tag}`;
|
|
1486
|
+
return { emitTag: alias, importName: `${tag} as ${alias}`, specifier };
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1413
1489
|
function ensureNamedImport(s: MagicString, source: string, name: string, specifier: string): void {
|
|
1414
1490
|
const esc = specifier.replace(/[/]/g, '\\/');
|
|
1415
1491
|
const line = source.match(new RegExp(`import \\{([^}]*)\\} from ['"]${esc}['"]`));
|
|
@@ -15,11 +15,19 @@
|
|
|
15
15
|
// user token → saveHubCredential() + linkedHub in config.json —
|
|
16
16
|
// the exact state `maude design link` would have written by hand.
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
chmodSync,
|
|
20
|
+
existsSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
unlinkSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
} from 'node:fs';
|
|
19
27
|
import { homedir } from 'node:os';
|
|
20
28
|
import { basename, dirname, join } from 'node:path';
|
|
21
29
|
|
|
22
|
-
import { saveHubCredential } from '../sync/hub-link.ts';
|
|
30
|
+
import { deleteHubCredential, saveHubCredential } from '../sync/hub-link.ts';
|
|
23
31
|
import { getHubToken, normalizeUrl } from '../sync/hubs-config.ts';
|
|
24
32
|
|
|
25
33
|
/**
|
|
@@ -116,11 +124,14 @@ interface Ctx {
|
|
|
116
124
|
* syncing after the next start.
|
|
117
125
|
*/
|
|
118
126
|
syncControl?: {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
/** `null` = unlink: clear the runtime's in-memory link, cycle to solo. */
|
|
128
|
+
restart(
|
|
129
|
+
linkedHub?: {
|
|
130
|
+
url: string;
|
|
131
|
+
linkedAt: number;
|
|
132
|
+
syncTsx?: boolean;
|
|
133
|
+
} | null
|
|
134
|
+
): Promise<{ syncing: boolean; canvases: number; reason?: string; detail?: string }>;
|
|
124
135
|
};
|
|
125
136
|
}
|
|
126
137
|
|
|
@@ -319,6 +330,50 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
319
330
|
project: exchanged.body.project,
|
|
320
331
|
});
|
|
321
332
|
},
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Detach THIS project from its workspace — the in-app `maude design
|
|
336
|
+
* unlink` (fix 7's Disconnect, sync RCA 2026-08-10). Drops the committed
|
|
337
|
+
* `linkedHub` AND the stored hub credential for that address (the CLI
|
|
338
|
+
* default), then cycles the runtime back to solo so syncing stops NOW
|
|
339
|
+
* rather than at the next restart. Idempotent: disconnecting an unlinked
|
|
340
|
+
* project answers ok, not an error.
|
|
341
|
+
*/
|
|
342
|
+
async detach(): Promise<CloudEndpointResult> {
|
|
343
|
+
const cfgPath = join(ctx.paths.designRoot, 'config.json');
|
|
344
|
+
let cfg: Record<string, unknown> = {};
|
|
345
|
+
try {
|
|
346
|
+
cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
347
|
+
} catch {
|
|
348
|
+
/* absent/malformed → nothing linked */
|
|
349
|
+
}
|
|
350
|
+
const linked = (cfg as { linkedHub?: { url?: unknown } }).linkedHub;
|
|
351
|
+
const url = typeof linked?.url === 'string' ? linked.url : null;
|
|
352
|
+
if (linked) {
|
|
353
|
+
delete cfg.linkedHub;
|
|
354
|
+
// Temp + rename (security review 2026-08-10, F5) — config.json is the
|
|
355
|
+
// SERVED project config (tokensCssRel, canvasGroups); a crash mid-write
|
|
356
|
+
// would corrupt it. Same atomic posture as `hub-link.ts`, but WITHOUT
|
|
357
|
+
// the credential store's 0o600 (this file is committed + world-readable
|
|
358
|
+
// by design), so it inherits the umask like the plain write it replaces.
|
|
359
|
+
const tmp = `${cfgPath}.${process.pid}.tmp`;
|
|
360
|
+
writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
|
|
361
|
+
renameSync(tmp, cfgPath);
|
|
362
|
+
}
|
|
363
|
+
if (url) {
|
|
364
|
+
try {
|
|
365
|
+
deleteHubCredential(normalizeUrl(url));
|
|
366
|
+
} catch {
|
|
367
|
+
/* an unparseable stored URL has no credential entry to drop */
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
await ctx.syncControl?.restart(null);
|
|
372
|
+
} catch {
|
|
373
|
+
/* best-effort — the link is gone on disk either way */
|
|
374
|
+
}
|
|
375
|
+
return { status: 200, json: { ok: true, detached: !!linked } };
|
|
376
|
+
},
|
|
322
377
|
};
|
|
323
378
|
|
|
324
379
|
/**
|
|
@@ -363,14 +418,27 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
363
418
|
}): Promise<CloudEndpointResult> {
|
|
364
419
|
// The cell is a different host than the control plane — one direct call.
|
|
365
420
|
let hubToken: string | null = null;
|
|
421
|
+
let hubTokenExpiresAt: number | undefined;
|
|
422
|
+
let vouchedRole: string | undefined;
|
|
366
423
|
try {
|
|
367
424
|
const res = await fetch(`${workspaceUrl}/auth/login`, {
|
|
368
425
|
method: 'POST',
|
|
369
426
|
headers: { 'content-type': 'application/json' },
|
|
370
427
|
body: JSON.stringify({ token: projectToken }),
|
|
371
428
|
});
|
|
372
|
-
const body = (await res.json().catch(() => ({}))) as CloudBody
|
|
373
|
-
|
|
429
|
+
const body = (await res.json().catch(() => ({}))) as CloudBody & {
|
|
430
|
+
expiresAt?: unknown;
|
|
431
|
+
user?: { role?: unknown };
|
|
432
|
+
};
|
|
433
|
+
if (res.ok && body?.token) {
|
|
434
|
+
hubToken = body.token;
|
|
435
|
+
// The cell reports when this session dies (≤ the 12 h project-token
|
|
436
|
+
// cap). Persisted so the sync runtime can renew BEFORE the deadline —
|
|
437
|
+
// discarding it here is what made every link silently expire.
|
|
438
|
+
if (typeof body.expiresAt === 'number' && Number.isFinite(body.expiresAt))
|
|
439
|
+
hubTokenExpiresAt = body.expiresAt;
|
|
440
|
+
if (typeof body.user?.role === 'string' && body.user.role) vouchedRole = body.user.role;
|
|
441
|
+
}
|
|
374
442
|
} catch {
|
|
375
443
|
/* handled below */
|
|
376
444
|
}
|
|
@@ -385,7 +453,7 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
385
453
|
}
|
|
386
454
|
|
|
387
455
|
const norm = normalizeUrl(workspaceUrl);
|
|
388
|
-
saveHubCredential(norm, hubToken);
|
|
456
|
+
saveHubCredential(norm, hubToken, vouchedRole, hubTokenExpiresAt);
|
|
389
457
|
|
|
390
458
|
// Project side — linkedHub in .design/config.json (committed; no token).
|
|
391
459
|
const cfgPath = join(ctx.paths.designRoot, 'config.json');
|