@1agh/maude 0.42.0 → 0.43.0
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/.ai/cache/_stats.json +7 -0
- package/apps/studio/acp/bootstrap-brief.ts +12 -2
- package/apps/studio/annotations-layer.tsx +1055 -278
- package/apps/studio/annotations-model.ts +58 -0
- package/apps/studio/api.ts +173 -22
- package/apps/studio/bin/_ingest-footage.mjs +386 -0
- package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
- package/apps/studio/bin/_video-playwright.mjs +141 -71
- package/apps/studio/bin/ingest-footage.sh +27 -0
- package/apps/studio/bin/photo-adjust.sh +163 -0
- package/apps/studio/bin/photo-bg-remove.sh +160 -0
- package/apps/studio/bin/probe-footage.sh +28 -0
- package/apps/studio/bin/read-annotations.mjs +125 -2
- package/apps/studio/canvas-edit.ts +459 -10
- package/apps/studio/canvas-lib.tsx +523 -5
- package/apps/studio/canvas-pipeline.ts +65 -7
- package/apps/studio/canvas-shell.tsx +156 -16
- package/apps/studio/client/app.jsx +451 -34
- package/apps/studio/client/export-center.jsx +35 -1
- package/apps/studio/client/github.js +4 -0
- package/apps/studio/client/panels/GitPanel.jsx +8 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
- package/apps/studio/client/panels/StickerPicker.jsx +160 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
- package/apps/studio/client/photo-knobs.jsx +437 -0
- package/apps/studio/client/styles/3-shell-maude.css +42 -0
- package/apps/studio/commands/edit-source-command.ts +31 -4
- package/apps/studio/context-menu.tsx +31 -6
- package/apps/studio/dist/client.bundle.js +5561 -19
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
- package/apps/studio/dist/runtime/pixi-js.js +519 -519
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +25 -0
- package/apps/studio/exporters/jobs.ts +47 -5
- package/apps/studio/exporters/video.ts +24 -4
- package/apps/studio/footage/schema.test.ts +195 -0
- package/apps/studio/footage/schema.ts +484 -0
- package/apps/studio/footage-store.ts +235 -0
- package/apps/studio/fs-watch.ts +10 -0
- package/apps/studio/git/endpoints.ts +68 -7
- package/apps/studio/git/service.ts +196 -20
- package/apps/studio/github/service.ts +67 -0
- package/apps/studio/handoff.ts +3 -2
- package/apps/studio/http.ts +173 -14
- package/apps/studio/input-router.tsx +10 -0
- package/apps/studio/inspect.ts +53 -1
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/photo/filters.ts +285 -0
- package/apps/studio/photo/pipeline.ts +607 -0
- package/apps/studio/photo/schema.ts +367 -0
- package/apps/studio/photo-store.ts +154 -0
- package/apps/studio/runtime-bundle.ts +29 -6
- package/apps/studio/server.ts +5 -1
- package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
- package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
- package/apps/studio/stickers/life-style/best.png +0 -0
- package/apps/studio/stickers/life-style/come-on.png +0 -0
- package/apps/studio/stickers/life-style/cut.png +0 -0
- package/apps/studio/stickers/life-style/detox.png +0 -0
- package/apps/studio/stickers/life-style/just.png +0 -0
- package/apps/studio/stickers/life-style/manifest.json +68 -0
- package/apps/studio/stickers/life-style/mirror.png +0 -0
- package/apps/studio/stickers/life-style/nice.png +0 -0
- package/apps/studio/stickers/life-style/objects.png +0 -0
- package/apps/studio/stickers/life-style/ok.png +0 -0
- package/apps/studio/stickers/life-style/queen.png +0 -0
- package/apps/studio/stickers/life-style/star.png +0 -0
- package/apps/studio/stickers/life-style/sun.png +0 -0
- package/apps/studio/stickers/life-style/water.png +0 -0
- package/apps/studio/stickers/life-style/yeah.png +0 -0
- package/apps/studio/stickers/life-style/you-can.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
- package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
- package/apps/studio/stickers/project-status/group-100.png +0 -0
- package/apps/studio/stickers/project-status/group-101.png +0 -0
- package/apps/studio/stickers/project-status/group-102.png +0 -0
- package/apps/studio/stickers/project-status/group-103.png +0 -0
- package/apps/studio/stickers/project-status/group-104.png +0 -0
- package/apps/studio/stickers/project-status/group-105.png +0 -0
- package/apps/studio/stickers/project-status/group-106.png +0 -0
- package/apps/studio/stickers/project-status/group-107.png +0 -0
- package/apps/studio/stickers/project-status/group-108.png +0 -0
- package/apps/studio/stickers/project-status/group-109.png +0 -0
- package/apps/studio/stickers/project-status/group-110.png +0 -0
- package/apps/studio/stickers/project-status/group-111.png +0 -0
- package/apps/studio/stickers/project-status/group-112.png +0 -0
- package/apps/studio/stickers/project-status/group-113.png +0 -0
- package/apps/studio/stickers/project-status/group-114.png +0 -0
- package/apps/studio/stickers/project-status/group-115.png +0 -0
- package/apps/studio/stickers/project-status/group-117.png +0 -0
- package/apps/studio/stickers/project-status/group-118.png +0 -0
- package/apps/studio/stickers/project-status/group-119.png +0 -0
- package/apps/studio/stickers/project-status/group-120.png +0 -0
- package/apps/studio/stickers/project-status/group-121.png +0 -0
- package/apps/studio/stickers/project-status/group-122.png +0 -0
- package/apps/studio/stickers/project-status/group-41.png +0 -0
- package/apps/studio/stickers/project-status/group-42.png +0 -0
- package/apps/studio/stickers/project-status/group-43.png +0 -0
- package/apps/studio/stickers/project-status/group-44.png +0 -0
- package/apps/studio/stickers/project-status/group-45.png +0 -0
- package/apps/studio/stickers/project-status/group-46.png +0 -0
- package/apps/studio/stickers/project-status/group-47.png +0 -0
- package/apps/studio/stickers/project-status/group-48.png +0 -0
- package/apps/studio/stickers/project-status/group-49.png +0 -0
- package/apps/studio/stickers/project-status/group-50.png +0 -0
- package/apps/studio/stickers/project-status/group-51.png +0 -0
- package/apps/studio/stickers/project-status/group-52.png +0 -0
- package/apps/studio/stickers/project-status/group-53.png +0 -0
- package/apps/studio/stickers/project-status/group-54.png +0 -0
- package/apps/studio/stickers/project-status/group-55.png +0 -0
- package/apps/studio/stickers/project-status/group-56.png +0 -0
- package/apps/studio/stickers/project-status/group-57.png +0 -0
- package/apps/studio/stickers/project-status/group-58.png +0 -0
- package/apps/studio/stickers/project-status/group-59.png +0 -0
- package/apps/studio/stickers/project-status/group-60.png +0 -0
- package/apps/studio/stickers/project-status/group-61.png +0 -0
- package/apps/studio/stickers/project-status/group-62.png +0 -0
- package/apps/studio/stickers/project-status/group-63.png +0 -0
- package/apps/studio/stickers/project-status/group-64.png +0 -0
- package/apps/studio/stickers/project-status/group-65.png +0 -0
- package/apps/studio/stickers/project-status/group-66.png +0 -0
- package/apps/studio/stickers/project-status/group-67.png +0 -0
- package/apps/studio/stickers/project-status/group-68.png +0 -0
- package/apps/studio/stickers/project-status/group-69.png +0 -0
- package/apps/studio/stickers/project-status/group-70.png +0 -0
- package/apps/studio/stickers/project-status/group-71.png +0 -0
- package/apps/studio/stickers/project-status/group-72.png +0 -0
- package/apps/studio/stickers/project-status/group-73.png +0 -0
- package/apps/studio/stickers/project-status/group-74.png +0 -0
- package/apps/studio/stickers/project-status/group-75.png +0 -0
- package/apps/studio/stickers/project-status/group-76.png +0 -0
- package/apps/studio/stickers/project-status/group-77.png +0 -0
- package/apps/studio/stickers/project-status/group-78.png +0 -0
- package/apps/studio/stickers/project-status/group-79.png +0 -0
- package/apps/studio/stickers/project-status/group-80.png +0 -0
- package/apps/studio/stickers/project-status/group-81.png +0 -0
- package/apps/studio/stickers/project-status/group-82.png +0 -0
- package/apps/studio/stickers/project-status/group-83.png +0 -0
- package/apps/studio/stickers/project-status/group-84.png +0 -0
- package/apps/studio/stickers/project-status/group-85.png +0 -0
- package/apps/studio/stickers/project-status/group-86.png +0 -0
- package/apps/studio/stickers/project-status/group-87.png +0 -0
- package/apps/studio/stickers/project-status/group-88.png +0 -0
- package/apps/studio/stickers/project-status/group-89.png +0 -0
- package/apps/studio/stickers/project-status/group-90.png +0 -0
- package/apps/studio/stickers/project-status/group-91.png +0 -0
- package/apps/studio/stickers/project-status/group-92.png +0 -0
- package/apps/studio/stickers/project-status/group-93.png +0 -0
- package/apps/studio/stickers/project-status/group-94.png +0 -0
- package/apps/studio/stickers/project-status/group-96.png +0 -0
- package/apps/studio/stickers/project-status/group-97.png +0 -0
- package/apps/studio/stickers/project-status/group-98.png +0 -0
- package/apps/studio/stickers/project-status/group-99.png +0 -0
- package/apps/studio/stickers/project-status/manifest.json +328 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
- package/apps/studio/test/acp-commands.test.ts +2 -1
- package/apps/studio/test/asset-api.test.ts +3 -3
- package/apps/studio/test/canvas-edit.test.ts +17 -6
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
- package/apps/studio/test/edit-source-command.test.ts +23 -0
- package/apps/studio/test/element-structural-edit.test.ts +46 -0
- package/apps/studio/test/footage-store.test.ts +100 -0
- package/apps/studio/test/git-branches.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +56 -1
- package/apps/studio/test/input-router.test.ts +33 -0
- package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
- package/apps/studio/test/photo-edit-api.test.ts +201 -0
- package/apps/studio/test/photo-filters.test.ts +149 -0
- package/apps/studio/test/photo-pipeline.test.ts +106 -0
- package/apps/studio/test/photo-taxonomy.test.ts +96 -0
- package/apps/studio/test/read-annotations.test.ts +164 -0
- package/apps/studio/test/shell-importmap.test.ts +49 -0
- package/apps/studio/test/text-editability-stamp.test.ts +131 -0
- package/apps/studio/test/timeline-parse.test.ts +57 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
- package/apps/studio/test/undo-stack.test.ts +33 -0
- package/apps/studio/test/video-asset.test.ts +5 -5
- package/apps/studio/test/video-render-bridge.test.ts +23 -1
- package/apps/studio/text-caret.ts +239 -0
- package/apps/studio/tool-palette.tsx +43 -20
- package/apps/studio/undo-stack.ts +94 -2
- package/apps/studio/use-annotation-resize.tsx +4 -4
- package/apps/studio/use-canvas-media-drop.tsx +38 -1
- package/apps/studio/use-selection-set.tsx +21 -0
- package/apps/studio/video-comp.tsx +48 -7
- package/apps/studio/whats-new.json +53 -0
- package/cli/commands/design.mjs +34 -3
- package/cli/lib/gitignore-block.mjs +1 -0
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +1 -0
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
createContext,
|
|
88
88
|
Fragment,
|
|
89
89
|
isValidElement,
|
|
90
|
+
type MouseEvent as ReactMouseEvent,
|
|
90
91
|
type ReactNode,
|
|
91
92
|
type PointerEvent as ReactPointerEvent,
|
|
92
93
|
type RefObject,
|
|
@@ -106,6 +107,11 @@ import {
|
|
|
106
107
|
diffLayoutPositions,
|
|
107
108
|
} from './commands/move-artboards-command.ts';
|
|
108
109
|
import { scopedCdSelector, selectorIndex, shortText } from './dom-selection.ts';
|
|
110
|
+
// Photo editor (feature-photo-editor) — schema.ts is DEPENDENCY-FREE (no pixi),
|
|
111
|
+
// so this static import is safe and adds zero bundle cost. The WebGL compositor
|
|
112
|
+
// (photo/pipeline.ts, which imports pixi.js) is loaded via a DYNAMIC import
|
|
113
|
+
// inside <PhotoLayer> only when an edited photo actually mounts — see there.
|
|
114
|
+
import { isDefaultEdit, type PhotoEdit } from './photo/schema.ts';
|
|
109
115
|
import { AgentPresenceProvider, useAgentPresence } from './use-agent-presence.tsx';
|
|
110
116
|
import { type DragState, useArtboardDrag } from './use-artboard-drag.tsx';
|
|
111
117
|
import {
|
|
@@ -118,13 +124,14 @@ import { CollabProvider, canvasSlugFromPath } from './use-collab.tsx';
|
|
|
118
124
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
119
125
|
import { MaybeToolProvider, useToolModeOptional } from './use-tool-mode.tsx';
|
|
120
126
|
import { UndoStackProvider, useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
127
|
+
// DDR-148 — video-comp canvas kind. Imported (not just re-exported below) so
|
|
128
|
+
// DCArtboard can identity-match <VideoComp> for the header badge. Its
|
|
129
|
+
// `remotion`/`@remotion/player` imports resolve through the canvas importmap
|
|
130
|
+
// (RUNTIME_PACKAGES), same as react/motion.
|
|
131
|
+
import { VideoComp } from './video-comp.tsx';
|
|
121
132
|
|
|
122
133
|
export type { CompSnapshot, VideoCompMeta, VideoCompProps } from './video-comp.tsx';
|
|
123
|
-
|
|
124
|
-
// <VideoComp> (Remotion composition mounted in <Player> + the deterministic
|
|
125
|
-
// seek bridge). Its `remotion`/`@remotion/player` imports resolve through the
|
|
126
|
-
// canvas importmap (RUNTIME_PACKAGES), same as react/motion.
|
|
127
|
-
export { VideoComp } from './video-comp.tsx';
|
|
134
|
+
export { VideoComp };
|
|
128
135
|
|
|
129
136
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
130
137
|
// Module constants
|
|
@@ -320,6 +327,28 @@ button.dc-artboard-label {
|
|
|
320
327
|
width: 100%;
|
|
321
328
|
}
|
|
322
329
|
button.dc-artboard-label:focus-visible { outline: 2px solid var(--maude-hud-accent, #d63b1f); outline-offset: -2px; }
|
|
330
|
+
/* DDR-148 — video-artboard badge, overlaid top-right of the header. Opens
|
|
331
|
+
the timeline panel (same postMessage the context-menu entry sends). */
|
|
332
|
+
.dc-canvas .dc-artboard-video-badge {
|
|
333
|
+
position: absolute;
|
|
334
|
+
top: 4px;
|
|
335
|
+
right: 6px;
|
|
336
|
+
z-index: 2;
|
|
337
|
+
display: inline-flex;
|
|
338
|
+
align-items: center;
|
|
339
|
+
justify-content: center;
|
|
340
|
+
width: 18px;
|
|
341
|
+
height: 18px;
|
|
342
|
+
padding: 0;
|
|
343
|
+
appearance: none;
|
|
344
|
+
border: none;
|
|
345
|
+
border-radius: 3px;
|
|
346
|
+
background: var(--bg-2, #e8e3d8);
|
|
347
|
+
color: var(--fg-1, #4a3f30);
|
|
348
|
+
cursor: pointer;
|
|
349
|
+
}
|
|
350
|
+
.dc-canvas .dc-artboard-video-badge:hover { background: color-mix(in oklab, var(--fg-0, #2a2520) 12%, var(--bg-2, #e8e3d8)); }
|
|
351
|
+
.dc-canvas .dc-artboard-video-badge:focus-visible { outline: 2px solid var(--maude-hud-accent, #d63b1f); outline-offset: 1px; }
|
|
323
352
|
/* Active-artboard ring is in canvas-shell HALO_CSS (subtle 1 px tint). */
|
|
324
353
|
/* Phase 4.2 — drag chrome. */
|
|
325
354
|
.dc-canvas[data-active-tool="move"] .dc-artboard-label { cursor: grab; }
|
|
@@ -451,6 +480,29 @@ function harvestArtboards(children: ReactNode): ArtboardSeed[] {
|
|
|
451
480
|
return out;
|
|
452
481
|
}
|
|
453
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Does this artboard's subtree contain a <VideoComp>? Same identity-match +
|
|
485
|
+
* displayName-fallback walk as harvestArtboards, short-circuiting on the
|
|
486
|
+
* first hit — used to badge video artboards in DCArtboard's header.
|
|
487
|
+
*/
|
|
488
|
+
function subtreeHasVideoComp(children: ReactNode): boolean {
|
|
489
|
+
function visit(node: ReactNode): boolean {
|
|
490
|
+
if (node == null || typeof node === 'boolean') return false;
|
|
491
|
+
if (Array.isArray(node)) return node.some(visit);
|
|
492
|
+
if (!isValidElement(node)) return false;
|
|
493
|
+
const type = node.type;
|
|
494
|
+
if (
|
|
495
|
+
type === VideoComp ||
|
|
496
|
+
(typeof type === 'function' && (type as { displayName?: string }).displayName === 'VideoComp')
|
|
497
|
+
) {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
const childProp = (node.props as { children?: ReactNode } | null | undefined)?.children;
|
|
501
|
+
return childProp != null ? visit(childProp) : false;
|
|
502
|
+
}
|
|
503
|
+
return visit(children);
|
|
504
|
+
}
|
|
505
|
+
|
|
454
506
|
function synthDefaultGrid(seeds: ArtboardSeed[]): ArtboardRect[] {
|
|
455
507
|
// Render order (the order DCArtboards appear in JSX), not alphabetical —
|
|
456
508
|
// authors label artboards DS-01 / DS-02 / CV-01 etc. and expect that
|
|
@@ -1761,6 +1813,21 @@ export function DCArtboard({
|
|
|
1761
1813
|
// its presence color + show its funny name instead of the generic file label.
|
|
1762
1814
|
const agent = useAgentPresence();
|
|
1763
1815
|
const rect = ctx ? ctx.rectFor(id) : null;
|
|
1816
|
+
// DDR-148 — badge video artboards in the header; clicking the badge opens
|
|
1817
|
+
// the timeline panel via the same postMessage the context-menu "Open
|
|
1818
|
+
// Timeline" entry already sends (canvas-shell.tsx artboardHasVideo/handler).
|
|
1819
|
+
const hasVideo = useMemo(() => subtreeHasVideoComp(children), [children]);
|
|
1820
|
+
const openTimeline = useCallback(
|
|
1821
|
+
(e: ReactMouseEvent<HTMLButtonElement>) => {
|
|
1822
|
+
e.stopPropagation();
|
|
1823
|
+
try {
|
|
1824
|
+
window.parent.postMessage({ dgn: 'open-timeline-request', artboardId: id }, '*');
|
|
1825
|
+
} catch {
|
|
1826
|
+
/* detached / cross-origin */
|
|
1827
|
+
}
|
|
1828
|
+
},
|
|
1829
|
+
[id]
|
|
1830
|
+
);
|
|
1764
1831
|
|
|
1765
1832
|
// Drag hook — always called (hook rules). Inert outside DesignCanvas
|
|
1766
1833
|
// (allRects empty, enabled=false), so specimens / legacy uses get a plain
|
|
@@ -1872,6 +1939,33 @@ export function DCArtboard({
|
|
|
1872
1939
|
<button type="button" className="dc-artboard-label sku" aria-label={`Artboard ${label}`}>
|
|
1873
1940
|
{label}
|
|
1874
1941
|
</button>
|
|
1942
|
+
{hasVideo ? (
|
|
1943
|
+
<button
|
|
1944
|
+
type="button"
|
|
1945
|
+
className="dc-artboard-video-badge"
|
|
1946
|
+
aria-label={`Open timeline — ${label}`}
|
|
1947
|
+
title="Video artboard — open timeline"
|
|
1948
|
+
onClick={openTimeline}
|
|
1949
|
+
>
|
|
1950
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">
|
|
1951
|
+
<rect
|
|
1952
|
+
x="1.5"
|
|
1953
|
+
y="4.5"
|
|
1954
|
+
width="8"
|
|
1955
|
+
height="7"
|
|
1956
|
+
rx="1.2"
|
|
1957
|
+
stroke="currentColor"
|
|
1958
|
+
strokeWidth="1.3"
|
|
1959
|
+
/>
|
|
1960
|
+
<path
|
|
1961
|
+
d="M9.5 7l4-2.3v6.6l-4-2.3"
|
|
1962
|
+
stroke="currentColor"
|
|
1963
|
+
strokeWidth="1.3"
|
|
1964
|
+
strokeLinejoin="round"
|
|
1965
|
+
/>
|
|
1966
|
+
</svg>
|
|
1967
|
+
</button>
|
|
1968
|
+
) : null}
|
|
1875
1969
|
<div className="dc-artboard-body">{children}</div>
|
|
1876
1970
|
</article>
|
|
1877
1971
|
{showActivity ? (
|
|
@@ -2136,6 +2230,430 @@ export function DrawProof({
|
|
|
2136
2230
|
}
|
|
2137
2231
|
DrawProof.displayName = 'DrawProof';
|
|
2138
2232
|
|
|
2233
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2234
|
+
// PhotoLayer (feature-photo-editor, Task 6) — the non-destructive WebGL photo
|
|
2235
|
+
// compositor surface. Renders a source photo (artboard `<img>` or annotation
|
|
2236
|
+
// `ImageStroke`) with a live `PhotoEdit` applied through pixi.js.
|
|
2237
|
+
//
|
|
2238
|
+
// LAZY-BUNDLE GUARANTEE (the plan's load-bearing acceptance criterion + BUILDER's
|
|
2239
|
+
// flagged top risk): an UNEDITED photo (`isDefaultEdit(edit)`) renders as the
|
|
2240
|
+
// plain `<img>` and NEVER touches pixi. The compositor module (photo/pipeline.ts)
|
|
2241
|
+
// is only reached through a DYNAMIC `import()` inside the effect, so a canvas
|
|
2242
|
+
// with zero edited photos pays zero pixi.js/bg-removal cost. Verified empirically
|
|
2243
|
+
// against `buildCanvasModule` (no eager `pixi.js` import in the default-edit
|
|
2244
|
+
// bundle) — see test/photo-canvas-bundle.test.ts.
|
|
2245
|
+
//
|
|
2246
|
+
// A11y: the pixi output is a `<canvas>` (a black box to AT), so it carries
|
|
2247
|
+
// `role="img"` + `aria-label` from `alt` (validation step 7 requirement).
|
|
2248
|
+
// Reduced-motion: the compositor renders statically (autoStart:false, no ticker).
|
|
2249
|
+
|
|
2250
|
+
export interface PhotoLayerProps {
|
|
2251
|
+
/** Relative `assets/<sha8>.<ext>` source (validated upstream). */
|
|
2252
|
+
source: string;
|
|
2253
|
+
/** Live non-destructive edit. Absent / neutral ⇒ plain `<img>`, no pixi. */
|
|
2254
|
+
edit?: PhotoEdit | null;
|
|
2255
|
+
width: number;
|
|
2256
|
+
height: number;
|
|
2257
|
+
alt?: string;
|
|
2258
|
+
className?: string;
|
|
2259
|
+
style?: CSSProperties;
|
|
2260
|
+
/** Resolve a relative asset path to a fetchable URL (defaults to identity —
|
|
2261
|
+
* relative `assets/…` already resolves against the canvas iframe origin). */
|
|
2262
|
+
resolveUrl?: (rel: string) => string;
|
|
2263
|
+
/** Fired once the pixi compositor has mounted + drawn its first frame. The
|
|
2264
|
+
* preview bridge hides the original element only AFTER this, so a background
|
|
2265
|
+
* cutout never flashes the untouched original underneath (and no flicker). */
|
|
2266
|
+
onReady?: () => void;
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
export function PhotoLayer({
|
|
2270
|
+
source,
|
|
2271
|
+
edit,
|
|
2272
|
+
width,
|
|
2273
|
+
height,
|
|
2274
|
+
alt = '',
|
|
2275
|
+
className,
|
|
2276
|
+
style,
|
|
2277
|
+
resolveUrl,
|
|
2278
|
+
onReady,
|
|
2279
|
+
}: PhotoLayerProps) {
|
|
2280
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
2281
|
+
const rendererRef = useRef<{ destroy(): void; update(e: PhotoEdit): void } | null>(null);
|
|
2282
|
+
const active = !isDefaultEdit(edit);
|
|
2283
|
+
|
|
2284
|
+
// Mount / tear down the pixi compositor only while an edit is active. The
|
|
2285
|
+
// compositor is DYNAMICALLY imported (lazy-bundle guarantee — see header).
|
|
2286
|
+
// Re-created only when the source/box identity changes; edit-param changes are
|
|
2287
|
+
// pushed via the second effect below (no teardown → smooth live scrub).
|
|
2288
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `edit` is deliberately excluded — re-creating the pixi Application on every scrub would thrash; edit updates flow through the second effect's `update()` (mount seeds from the current edit).
|
|
2289
|
+
useEffect(() => {
|
|
2290
|
+
if (!active) return;
|
|
2291
|
+
const canvas = canvasRef.current;
|
|
2292
|
+
if (!canvas) return;
|
|
2293
|
+
let disposed = false;
|
|
2294
|
+
import('./photo/pipeline.ts')
|
|
2295
|
+
.then(({ PhotoRenderer }) =>
|
|
2296
|
+
PhotoRenderer.create({
|
|
2297
|
+
canvas,
|
|
2298
|
+
source,
|
|
2299
|
+
edit: (edit ?? {}) as PhotoEdit,
|
|
2300
|
+
width,
|
|
2301
|
+
height,
|
|
2302
|
+
resolveUrl,
|
|
2303
|
+
})
|
|
2304
|
+
)
|
|
2305
|
+
.then((r) => {
|
|
2306
|
+
if (disposed) {
|
|
2307
|
+
r.destroy();
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
rendererRef.current = r;
|
|
2311
|
+
onReady?.();
|
|
2312
|
+
})
|
|
2313
|
+
.catch((err) => {
|
|
2314
|
+
console.error('[PhotoLayer] compositor failed to mount', err);
|
|
2315
|
+
});
|
|
2316
|
+
return () => {
|
|
2317
|
+
disposed = true;
|
|
2318
|
+
rendererRef.current?.destroy();
|
|
2319
|
+
rendererRef.current = null;
|
|
2320
|
+
};
|
|
2321
|
+
}, [active, source, width, height, resolveUrl, onReady]);
|
|
2322
|
+
|
|
2323
|
+
// Live-update the mounted compositor on edit-param change (no re-create).
|
|
2324
|
+
useEffect(() => {
|
|
2325
|
+
if (active && edit && rendererRef.current) rendererRef.current.update(edit);
|
|
2326
|
+
}, [edit, active]);
|
|
2327
|
+
|
|
2328
|
+
if (!active) {
|
|
2329
|
+
const src = resolveUrl ? resolveUrl(source) : source;
|
|
2330
|
+
return (
|
|
2331
|
+
<img src={src} width={width} height={height} alt={alt} className={className} style={style} />
|
|
2332
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
return (
|
|
2335
|
+
<canvas
|
|
2336
|
+
ref={canvasRef}
|
|
2337
|
+
width={width}
|
|
2338
|
+
height={height}
|
|
2339
|
+
role="img"
|
|
2340
|
+
aria-label={alt || 'Edited photo'}
|
|
2341
|
+
className={className}
|
|
2342
|
+
style={{ width, height, display: 'block', ...style }}
|
|
2343
|
+
/>
|
|
2344
|
+
);
|
|
2345
|
+
}
|
|
2346
|
+
PhotoLayer.displayName = 'PhotoLayer';
|
|
2347
|
+
|
|
2348
|
+
// PhotoPreviewBridge (feature-photo-editor) — applies a live/persisted
|
|
2349
|
+
// PhotoEdit DIRECTLY to the real photo element (artboard `<img>` or
|
|
2350
|
+
// annotation `<image>`) by swapping its `src`/`href` to a baked data URL,
|
|
2351
|
+
// instead of floating a separate WebGL-rendered decoy on top of it.
|
|
2352
|
+
//
|
|
2353
|
+
// Iteration 1 of this bridge did the "decoy" version: a `position:fixed` div
|
|
2354
|
+
// tracked the real element's screen rect via a per-frame rAF loop and hid the
|
|
2355
|
+
// original underneath (`visibility:hidden`) while a live `<PhotoLayer>` pixi
|
|
2356
|
+
// canvas rendered on top. That broke every bit of free native DOM behavior
|
|
2357
|
+
// the real element used to have:
|
|
2358
|
+
// - cmd+click / right-click hit-testing landed on whatever was BEHIND the
|
|
2359
|
+
// now-invisible original (a hidden element isn't hit-testable), while the
|
|
2360
|
+
// decoy was `pointer-events:none` so it couldn't take the click either —
|
|
2361
|
+
// net result, nothing was clickable.
|
|
2362
|
+
// - the decoy rendered at a STABLE "world" pixel size and was CSS-stretched
|
|
2363
|
+
// to the live screen box on zoom; the stretch didn't reliably track the
|
|
2364
|
+
// real box, so the visible photo grew/shrank relative to its own frame.
|
|
2365
|
+
// - it needed its own z-index (originally 30 — drew over the context menu)
|
|
2366
|
+
// instead of just sitting at the element's normal stacking position.
|
|
2367
|
+
// - it only knew about an edit via the transient postMessage below, so any
|
|
2368
|
+
// iframe remount (Cmd+R, HMR) reset it to nothing until a human reopened
|
|
2369
|
+
// the Inspector and nudged a knob.
|
|
2370
|
+
// Swapping the REAL element's `src`/`href` sidesteps all of it: resize, zoom,
|
|
2371
|
+
// hit-testing, and stacking become the browser's native `<img>`/`<image>`
|
|
2372
|
+
// behavior, not a hand-rolled tracker. Non-destructive still holds — only the
|
|
2373
|
+
// LIVE DOM attribute is mutated, never the authored TSX/SVG source; the
|
|
2374
|
+
// on-disk `PhotoEdit` sidecar (`/_api/photo-edit`) stays the persisted source
|
|
2375
|
+
// of truth, re-applied by the hydration scan below on every canvas (re)mount.
|
|
2376
|
+
// The bake is at the source's NATIVE resolution (`renderPhotoDataUrl`), so
|
|
2377
|
+
// the result stays sharp across any later resize/zoom with no re-bake.
|
|
2378
|
+
|
|
2379
|
+
/** `assets/<sha8>.<ext>` substring inside a `src`/`href`/`xlink:href`. */
|
|
2380
|
+
const ASSET_REF_RE = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
|
|
2381
|
+
|
|
2382
|
+
/** Matches a photo element by the `data-photo-asset` tag `apply()` stamps on
|
|
2383
|
+
* first touch, falling back to a literal src/href substring match for an
|
|
2384
|
+
* element this bridge hasn't touched yet. The tag is load-bearing: once
|
|
2385
|
+
* baked, the element's `src`/`href` is a `data:` URL that no longer contains
|
|
2386
|
+
* the original asset path, so the substring match alone would lose track of
|
|
2387
|
+
* it on the very next edit. */
|
|
2388
|
+
function findPhotoEl(asset: string): Element | null {
|
|
2389
|
+
if (typeof document === 'undefined') return null;
|
|
2390
|
+
// Scoped to img/image (not a bare `[data-photo-asset]` attribute selector) —
|
|
2391
|
+
// the canvas iframe is untrusted content (DDR-054); an authored canvas could
|
|
2392
|
+
// otherwise stamp the tag on an arbitrary element to redirect a bake.
|
|
2393
|
+
for (const n of document.querySelectorAll('img[data-photo-asset], image[data-photo-asset]')) {
|
|
2394
|
+
if (n.getAttribute('data-photo-asset') === asset) return n;
|
|
2395
|
+
}
|
|
2396
|
+
for (const n of document.querySelectorAll('img, image')) {
|
|
2397
|
+
const src =
|
|
2398
|
+
n.getAttribute('src') || n.getAttribute('href') || n.getAttribute('xlink:href') || '';
|
|
2399
|
+
if (src.includes(asset)) return n;
|
|
2400
|
+
}
|
|
2401
|
+
return null;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
function extractAssetRef(el: Element): string | null {
|
|
2405
|
+
// Only trust `data-photo-asset` when it actually has the `assets/<sha8>.<ext>`
|
|
2406
|
+
// shape — the tag is attacker-controllable (untrusted canvas content,
|
|
2407
|
+
// DDR-054), and an unshaped value would otherwise ride unbounded into
|
|
2408
|
+
// `_active.json`/the WS broadcast via inspect.ts's `enrich()`.
|
|
2409
|
+
const tagged = el.getAttribute('data-photo-asset');
|
|
2410
|
+
if (tagged && ASSET_REF_RE.test(tagged)) return tagged;
|
|
2411
|
+
const ref =
|
|
2412
|
+
el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
|
|
2413
|
+
return ref.match(ASSET_REF_RE)?.[0] ?? null;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
function setPhotoElSrc(el: Element, url: string): void {
|
|
2417
|
+
if (el.tagName.toLowerCase() === 'image') {
|
|
2418
|
+
el.setAttribute(el.hasAttribute('xlink:href') ? 'xlink:href' : 'href', url);
|
|
2419
|
+
} else {
|
|
2420
|
+
el.setAttribute('src', url);
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
const BAKE_DEBOUNCE_MS = 80;
|
|
2425
|
+
|
|
2426
|
+
export function PhotoPreviewBridge() {
|
|
2427
|
+
// The ORIGINAL (unedited) src/href per asset, captured the first time this
|
|
2428
|
+
// bridge touches that element — so turning an edit off restores exactly
|
|
2429
|
+
// what the element pointed to, not a guess.
|
|
2430
|
+
const originalRef = useRef<Map<string, string>>(new Map());
|
|
2431
|
+
// Per-asset bake generation — guards a slow (or out-of-order) render from
|
|
2432
|
+
// clobbering a NEWER edit that already resolved first.
|
|
2433
|
+
const tokenRef = useRef<Map<string, number>>(new Map());
|
|
2434
|
+
const bakeTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
2435
|
+
|
|
2436
|
+
const bake = useCallback((asset: string, original: string, edit: PhotoEdit) => {
|
|
2437
|
+
const token = (tokenRef.current.get(asset) ?? 0) + 1;
|
|
2438
|
+
tokenRef.current.set(asset, token);
|
|
2439
|
+
import('./photo/pipeline.ts')
|
|
2440
|
+
.then(({ renderPhotoDataUrl }) => renderPhotoDataUrl({ source: original, edit }))
|
|
2441
|
+
.then((dataUrl) => {
|
|
2442
|
+
if (tokenRef.current.get(asset) !== token) return; // superseded by a newer edit
|
|
2443
|
+
const live = findPhotoEl(asset);
|
|
2444
|
+
if (live) setPhotoElSrc(live, dataUrl);
|
|
2445
|
+
})
|
|
2446
|
+
.catch((err) => {
|
|
2447
|
+
console.error('[PhotoPreviewBridge] bake failed', err);
|
|
2448
|
+
});
|
|
2449
|
+
}, []);
|
|
2450
|
+
|
|
2451
|
+
const apply = useCallback(
|
|
2452
|
+
(asset: string, edit: PhotoEdit | null) => {
|
|
2453
|
+
const el = findPhotoEl(asset);
|
|
2454
|
+
if (!el) return;
|
|
2455
|
+
if (!el.hasAttribute('data-photo-asset')) el.setAttribute('data-photo-asset', asset);
|
|
2456
|
+
if (!originalRef.current.has(asset)) {
|
|
2457
|
+
const orig =
|
|
2458
|
+
el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
|
|
2459
|
+
originalRef.current.set(asset, orig);
|
|
2460
|
+
}
|
|
2461
|
+
const original = originalRef.current.get(asset) ?? '';
|
|
2462
|
+
const timers = bakeTimers.current;
|
|
2463
|
+
clearTimeout(timers.get(asset));
|
|
2464
|
+
if (!edit || isDefaultEdit(edit)) {
|
|
2465
|
+
timers.delete(asset);
|
|
2466
|
+
setPhotoElSrc(el, original);
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
timers.set(
|
|
2470
|
+
asset,
|
|
2471
|
+
setTimeout(() => bake(asset, original, edit), BAKE_DEBOUNCE_MS)
|
|
2472
|
+
);
|
|
2473
|
+
},
|
|
2474
|
+
[bake]
|
|
2475
|
+
);
|
|
2476
|
+
|
|
2477
|
+
useEffect(() => {
|
|
2478
|
+
const timers = bakeTimers.current;
|
|
2479
|
+
return () => {
|
|
2480
|
+
for (const t of timers.values()) clearTimeout(t);
|
|
2481
|
+
};
|
|
2482
|
+
}, []);
|
|
2483
|
+
|
|
2484
|
+
useEffect(() => {
|
|
2485
|
+
const onMsg = (e: MessageEvent) => {
|
|
2486
|
+
const m = e.data as { dgn?: string; asset?: unknown; edit?: unknown; busy?: unknown } | null;
|
|
2487
|
+
if (!m) return;
|
|
2488
|
+
// Background-removal busy shimmer (Task 12 reveal) — a `data-photo-busy`
|
|
2489
|
+
// attribute toggle on the real element, styled by inspect.ts's single CSS
|
|
2490
|
+
// injection point (mirrors `.dc-activity-scan`'s sweep language). No
|
|
2491
|
+
// separate tracked overlay — see the header comment above `apply()`.
|
|
2492
|
+
if (m.dgn === 'photo-busy' && typeof m.asset === 'string') {
|
|
2493
|
+
const el = findPhotoEl(m.asset);
|
|
2494
|
+
el?.toggleAttribute('data-photo-busy', !!m.busy);
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
if (m.dgn !== 'photo-preview' || typeof m.asset !== 'string') return;
|
|
2498
|
+
const asset = m.asset;
|
|
2499
|
+
const edit = (m.edit ?? null) as PhotoEdit | null;
|
|
2500
|
+
apply(asset, edit);
|
|
2501
|
+
};
|
|
2502
|
+
window.addEventListener('message', onMsg);
|
|
2503
|
+
return () => window.removeEventListener('message', onMsg);
|
|
2504
|
+
}, [apply]);
|
|
2505
|
+
// Boot-time (+ ongoing) hydration from the PERSISTED sidecar, not just the
|
|
2506
|
+
// live `photo-preview` message above. Without this, a saved PhotoEdit is
|
|
2507
|
+
// invisible after anything that re-mounts the canvas doc (Cmd+R, an
|
|
2508
|
+
// HMR remount, or a resize that recreates the photo's DOM node) — the
|
|
2509
|
+
// message-only bridge starts every fresh mount with an empty `edits` map,
|
|
2510
|
+
// and nothing re-sends the already-saved edit until a human happens to
|
|
2511
|
+
// reopen the Inspector Photo tab and touch a knob. A MutationObserver
|
|
2512
|
+
// re-scan (not just an initial one-shot) is what makes this self-heal after
|
|
2513
|
+
// those remounts, since the photo element's DOM node is often a NEW node
|
|
2514
|
+
// post-remount, not the one the original message targeted.
|
|
2515
|
+
useEffect(() => {
|
|
2516
|
+
if (typeof document === 'undefined') return;
|
|
2517
|
+
let cancelled = false;
|
|
2518
|
+
let raf = 0;
|
|
2519
|
+
// Every asset gets AT MOST one fetch attempt for this bridge's lifetime —
|
|
2520
|
+
// without this, a canvas whose DOM keeps mutating (an animation, a
|
|
2521
|
+
// re-rendering component) reissues the full unfetched set on every
|
|
2522
|
+
// mutation frame, forever (security review finding: unbounded fetch
|
|
2523
|
+
// amplification against the dev server from a zero-gesture background
|
|
2524
|
+
// scan).
|
|
2525
|
+
const attempted = new Set<string>();
|
|
2526
|
+
// Safety ceiling per pass — a pathological/hostile canvas DOM (thousands
|
|
2527
|
+
// of img/image elements) shouldn't be able to fan out unbounded fetches
|
|
2528
|
+
// in one scan; the next mutation-triggered pass picks up where this left
|
|
2529
|
+
// off since `attempted` persists across passes.
|
|
2530
|
+
const MAX_SCAN_PER_PASS = 500;
|
|
2531
|
+
const scan = () => {
|
|
2532
|
+
let scanned = 0;
|
|
2533
|
+
for (const n of document.querySelectorAll('img, image')) {
|
|
2534
|
+
const asset = extractAssetRef(n);
|
|
2535
|
+
if (!asset || attempted.has(asset)) continue;
|
|
2536
|
+
if (++scanned > MAX_SCAN_PER_PASS) break;
|
|
2537
|
+
attempted.add(asset);
|
|
2538
|
+
fetch(`/_api/photo-edit?asset=${encodeURIComponent(asset)}`)
|
|
2539
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
2540
|
+
.then((edit) => {
|
|
2541
|
+
if (cancelled || !edit || isDefaultEdit(edit)) return;
|
|
2542
|
+
apply(asset, edit);
|
|
2543
|
+
})
|
|
2544
|
+
.catch(() => {});
|
|
2545
|
+
}
|
|
2546
|
+
};
|
|
2547
|
+
scan();
|
|
2548
|
+
const observer = new MutationObserver(() => {
|
|
2549
|
+
cancelAnimationFrame(raf);
|
|
2550
|
+
raf = requestAnimationFrame(scan);
|
|
2551
|
+
});
|
|
2552
|
+
observer.observe(document.body, {
|
|
2553
|
+
childList: true,
|
|
2554
|
+
subtree: true,
|
|
2555
|
+
attributes: true,
|
|
2556
|
+
attributeFilter: ['src', 'href'],
|
|
2557
|
+
});
|
|
2558
|
+
return () => {
|
|
2559
|
+
cancelled = true;
|
|
2560
|
+
cancelAnimationFrame(raf);
|
|
2561
|
+
observer.disconnect();
|
|
2562
|
+
};
|
|
2563
|
+
}, [apply]);
|
|
2564
|
+
return null; // mutates the real elements directly — no visible DOM of its own.
|
|
2565
|
+
}
|
|
2566
|
+
PhotoPreviewBridge.displayName = 'PhotoPreviewBridge';
|
|
2567
|
+
|
|
2568
|
+
// PhotoBgRemoveHarness (feature-photo-editor, Task 18) — the headless CLI proof
|
|
2569
|
+
// harness `photo-bg-remove.sh` mounts inside a throwaway `_photo/<slug>.bgremove.tsx`
|
|
2570
|
+
// canvas (mirrors DrawProof's role for the draw engine). Runs the EXACT SAME
|
|
2571
|
+
// client-side ML flow the interactive "Remove Background" button uses (Task 12,
|
|
2572
|
+
// app.jsx `onPhotoRemoveBackground`) — @imgly/background-removal, WASM/WebGPU,
|
|
2573
|
+
// pixels never leave the browser — then persists the result and reports back to
|
|
2574
|
+
// the driving CLI script via DOM attributes it polls (no return value crosses
|
|
2575
|
+
// the process boundary; agent-browser reads attributes off the DOM instead).
|
|
2576
|
+
//
|
|
2577
|
+
// `/_api/asset` and `/_api/photo-edit` are BOTH canvas-safe routes (see their
|
|
2578
|
+
// CANVAS_SAFE_API comments in http.ts), so this harness posts directly from the
|
|
2579
|
+
// canvas origin — no main-origin relay / cross-origin workaround needed, despite
|
|
2580
|
+
// the split-origin (DDR-054) boundary the rest of the canvas runs inside.
|
|
2581
|
+
|
|
2582
|
+
export interface PhotoBgRemoveHarnessProps {
|
|
2583
|
+
/** Relative `assets/<sha8>.<ext>` source to remove the background from. */
|
|
2584
|
+
source: string;
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
export function PhotoBgRemoveHarness({ source }: PhotoBgRemoveHarnessProps) {
|
|
2588
|
+
const [status, setStatus] = useState<'pending' | 'done' | 'error'>('pending');
|
|
2589
|
+
const [result, setResult] = useState<string | null>(null);
|
|
2590
|
+
const [error, setError] = useState<string | null>(null);
|
|
2591
|
+
const ranRef = useRef(false);
|
|
2592
|
+
|
|
2593
|
+
useEffect(() => {
|
|
2594
|
+
if (ranRef.current) return; // one ML pass per mount — a dev-mode double-effect must not double-run it
|
|
2595
|
+
ranRef.current = true;
|
|
2596
|
+
let cancelled = false;
|
|
2597
|
+
(async () => {
|
|
2598
|
+
try {
|
|
2599
|
+
const srcRes = await fetch(`/${source.replace(/^\/+/, '')}`);
|
|
2600
|
+
if (!srcRes.ok) throw new Error(`source fetch failed: ${srcRes.status}`);
|
|
2601
|
+
const srcBlob = await srcRes.blob();
|
|
2602
|
+
const { removeBackground } = await import('@imgly/background-removal');
|
|
2603
|
+
const matte = await removeBackground(srcBlob);
|
|
2604
|
+
const up = await fetch('/_api/asset', {
|
|
2605
|
+
method: 'POST',
|
|
2606
|
+
headers: { 'content-type': matte.type || 'image/png' },
|
|
2607
|
+
body: matte,
|
|
2608
|
+
});
|
|
2609
|
+
const upJson = (await up.json().catch(() => ({}))) as { path?: string };
|
|
2610
|
+
if (!up.ok || !upJson.path) throw new Error(`asset upload failed: ${up.status}`);
|
|
2611
|
+
const maskAsset = upJson.path;
|
|
2612
|
+
|
|
2613
|
+
// Merge onto whatever's already in the sidecar (mirrors photo-adjust.sh's
|
|
2614
|
+
// merge-by-default behavior) instead of clobbering unrelated fields.
|
|
2615
|
+
const base: unknown = await fetch(`/_api/photo-edit?asset=${encodeURIComponent(source)}`)
|
|
2616
|
+
.then((r) => (r.ok ? r.json() : {}))
|
|
2617
|
+
.catch(() => ({}));
|
|
2618
|
+
const nextEdit = {
|
|
2619
|
+
...(base && typeof base === 'object' ? base : {}),
|
|
2620
|
+
backgroundRemoved: { enabled: true, maskAsset },
|
|
2621
|
+
};
|
|
2622
|
+
const put = await fetch(`/_api/photo-edit?asset=${encodeURIComponent(source)}`, {
|
|
2623
|
+
method: 'PUT',
|
|
2624
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2625
|
+
body: JSON.stringify(nextEdit),
|
|
2626
|
+
});
|
|
2627
|
+
if (!put.ok) throw new Error(`photo-edit save failed: ${put.status}`);
|
|
2628
|
+
if (cancelled) return;
|
|
2629
|
+
setResult(maskAsset);
|
|
2630
|
+
setStatus('done');
|
|
2631
|
+
} catch (err) {
|
|
2632
|
+
if (cancelled) return;
|
|
2633
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
2634
|
+
setStatus('error');
|
|
2635
|
+
}
|
|
2636
|
+
})();
|
|
2637
|
+
return () => {
|
|
2638
|
+
cancelled = true;
|
|
2639
|
+
};
|
|
2640
|
+
}, [source]);
|
|
2641
|
+
|
|
2642
|
+
return (
|
|
2643
|
+
<div
|
|
2644
|
+
data-photo-bgremove-status={status}
|
|
2645
|
+
data-photo-bgremove-result={result ?? ''}
|
|
2646
|
+
data-photo-bgremove-error={error ?? ''}
|
|
2647
|
+
style={{ padding: 24, font: '13px monospace' }}
|
|
2648
|
+
>
|
|
2649
|
+
photo-bg-remove: {status}
|
|
2650
|
+
{result ? ` → ${result}` : ''}
|
|
2651
|
+
{error ? ` (${error})` : ''}
|
|
2652
|
+
</div>
|
|
2653
|
+
);
|
|
2654
|
+
}
|
|
2655
|
+
PhotoBgRemoveHarness.displayName = 'PhotoBgRemoveHarness';
|
|
2656
|
+
|
|
2139
2657
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2140
2658
|
// SnapGuideOverlay (Phase 4.2) — renders 1 px guide lines while a drag is in
|
|
2141
2659
|
// flight. Mounted by canvas-shell as a chrome layer outside `.dc-world`, so
|
|
@@ -158,21 +158,65 @@ function jsxElementName(opening: AnyNode): string {
|
|
|
158
158
|
return '?';
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
function
|
|
161
|
+
function hasJsxAttr(opening: AnyNode, name: string): boolean {
|
|
162
162
|
const attrs = opening?.attributes;
|
|
163
163
|
if (!Array.isArray(attrs)) return false;
|
|
164
164
|
for (const a of attrs) {
|
|
165
|
-
if (
|
|
166
|
-
a?.type === 'JSXAttribute' &&
|
|
167
|
-
a.name?.type === 'JSXIdentifier' &&
|
|
168
|
-
a.name.name === 'data-cd-id'
|
|
169
|
-
) {
|
|
165
|
+
if (a?.type === 'JSXAttribute' && a.name?.type === 'JSXIdentifier' && a.name.name === name) {
|
|
170
166
|
return true;
|
|
171
167
|
}
|
|
172
168
|
}
|
|
173
169
|
return false;
|
|
174
170
|
}
|
|
175
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Phase 6 (unified-text-editing) — mirror of `applyTextEdit`'s editability
|
|
174
|
+
* test (canvas-edit.ts): the element's children are exactly ONE meaningful
|
|
175
|
+
* node, and it is either a static JSXText or a `{'string literal'}`
|
|
176
|
+
* expression container (DDR-150 P1). Elements that pass get a build-time
|
|
177
|
+
* `data-cd-editable="text"` marker so the shell only offers inline editing
|
|
178
|
+
* where a commit will actually save — the rendered DOM cannot distinguish
|
|
179
|
+
* `<p>Total: {1 + 1} items</p>` (multiple text nodes, mixed source → the
|
|
180
|
+
* engine refuses the edit) from honest leaf text, but the AST can. KEEP THE
|
|
181
|
+
* TWO PREDICATES IN LOCKSTEP — if applyTextEdit's acceptance changes, this
|
|
182
|
+
* must change with it, or the marker starts lying.
|
|
183
|
+
*/
|
|
184
|
+
function inlineEditableKind(node: AnyNode): 'text' | 'var' | null {
|
|
185
|
+
const children: AnyNode[] = Array.isArray(node?.children) ? node.children : [];
|
|
186
|
+
const meaningful = children.filter(
|
|
187
|
+
(c) => !(c?.type === 'JSXText' && typeof c.value === 'string' && c.value.trim() === '')
|
|
188
|
+
);
|
|
189
|
+
if (meaningful.length !== 1) return null;
|
|
190
|
+
const only = meaningful[0];
|
|
191
|
+
if (only?.type === 'JSXText') return 'text';
|
|
192
|
+
if (only?.type === 'JSXExpressionContainer') {
|
|
193
|
+
const expr = only.expression;
|
|
194
|
+
// A `{'string literal'}` rewrites in place (DDR-150 P1) — same as JSXText.
|
|
195
|
+
if (
|
|
196
|
+
expr &&
|
|
197
|
+
(expr.type === 'Literal' || expr.type === 'StringLiteral') &&
|
|
198
|
+
typeof expr.value === 'string'
|
|
199
|
+
) {
|
|
200
|
+
return 'text';
|
|
201
|
+
}
|
|
202
|
+
// A `{variable}` or `{item.prop}` — the string lives in a const or a
|
|
203
|
+
// `.map()`ed array; the edit engine traces it back at commit time
|
|
204
|
+
// (canvas-edit.ts resolveDynamicTextSpan). Computed forms (calls, template
|
|
205
|
+
// strings, `a + b`, ternaries) are NOT offered — no single source string.
|
|
206
|
+
if (expr?.type === 'Identifier') return 'var';
|
|
207
|
+
if (
|
|
208
|
+
expr?.type === 'MemberExpression' &&
|
|
209
|
+
!expr.computed &&
|
|
210
|
+
expr.object?.type === 'Identifier' &&
|
|
211
|
+
expr.property?.type === 'Identifier'
|
|
212
|
+
) {
|
|
213
|
+
return 'var';
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
176
220
|
function computeId(componentName: string, idx: number): string {
|
|
177
221
|
// 8 hex chars from Bun.hash — 32 bits of entropy. Per-component collision
|
|
178
222
|
// probability for the canvas-scale we ship (≤ ~300 JSX elements) is < 1e-5.
|
|
@@ -215,7 +259,7 @@ function walkInjectIds(
|
|
|
215
259
|
const idx = frame.jsxIndex;
|
|
216
260
|
frame.jsxIndex += 1;
|
|
217
261
|
|
|
218
|
-
if (!
|
|
262
|
+
if (!hasJsxAttr(opening, 'data-cd-id')) {
|
|
219
263
|
const id = computeId(frame.componentName, idx);
|
|
220
264
|
// Insert " data-cd-id=\"<id>\"" right after the tag name. magic-string's
|
|
221
265
|
// appendLeft puts the new text before any subsequent attribute. JSX
|
|
@@ -235,6 +279,20 @@ function walkInjectIds(
|
|
|
235
279
|
}
|
|
236
280
|
}
|
|
237
281
|
|
|
282
|
+
// Phase 6 (unified-text-editing) — build-time editability marker, so
|
|
283
|
+
// the shell's inline-edit entry and the edit engine can never disagree
|
|
284
|
+
// (inlineEditableKind mirrors applyTextEdit's acceptance). `text` = a
|
|
285
|
+
// literal rewritten in place; `var` = a `{variable}` traced back to its
|
|
286
|
+
// source string at commit (canvas-edit.ts). Independent of the id
|
|
287
|
+
// injection above: hand-authored data-cd-id elements still get it.
|
|
288
|
+
const editKind = inlineEditableKind(node);
|
|
289
|
+
if (editKind && !hasJsxAttr(opening, 'data-cd-editable')) {
|
|
290
|
+
const insertAt: number | undefined = opening?.name?.end;
|
|
291
|
+
if (typeof insertAt === 'number') {
|
|
292
|
+
s.appendLeft(insertAt, ` data-cd-editable="${editKind}"`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
238
296
|
// Recurse into opening attributes + children, with the jsxPath extended.
|
|
239
297
|
frame.jsxPath.push(elName);
|
|
240
298
|
if (opening) visit(opening.attributes);
|