@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
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file photo/schema.ts — PhotoEdit sidecar data model (Stage A, Task 1)
|
|
3
|
+
* @scope apps/studio/photo/schema.ts
|
|
4
|
+
* @purpose The single source of truth for the non-destructive photo-edit
|
|
5
|
+
* object persisted next to a source asset as `assets/<sha8>.photo.json`
|
|
6
|
+
* (DDR-115 taxonomy: VERSIONED, alongside the asset it edits).
|
|
7
|
+
*
|
|
8
|
+
* A `PhotoEdit` is *parameters only* — never pixels. The live
|
|
9
|
+
* compositor (`photo/pipeline.ts` → canvas-lib `<PhotoLayer>`) and
|
|
10
|
+
* the headless CLI (`bin/photo-*.sh`) both read this shape and
|
|
11
|
+
* reproduce the identical WebGL render. An absent sidecar, or one
|
|
12
|
+
* that `isDefaultEdit()` reports as neutral, renders as the plain
|
|
13
|
+
* untouched `<img>` / `<image>` with ZERO pixi.js cost.
|
|
14
|
+
*
|
|
15
|
+
* @invariant DEPENDENCY-FREE. This module is imported by BOTH the server
|
|
16
|
+
* (`photo-store.ts`, the `/_api/photo-edit` route) and the client
|
|
17
|
+
* WebGL compositor. It MUST NOT `import 'pixi.js'` or any browser /
|
|
18
|
+
* native lib — the server bundle must never pull in a WebGL runtime.
|
|
19
|
+
* Pure TS types + plain-object helpers + a hand-rolled structural
|
|
20
|
+
* validator (no Ajv) only.
|
|
21
|
+
*
|
|
22
|
+
* Field naming/style mirrors `annotations-model.ts`'s flat explicit
|
|
23
|
+
* per-type interfaces (`ImageStroke`, :245-260).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Schema version. Bumped when the shape changes incompatibly so a stored
|
|
28
|
+
* sidecar authored by an older Studio can be detected + migrated rather than
|
|
29
|
+
* mis-rendered. (BREAKER's flagged risk: a new versioned data-model type needs
|
|
30
|
+
* an explicit version marker — this is it.)
|
|
31
|
+
*/
|
|
32
|
+
export const PHOTO_EDIT_VERSION = 1 as const;
|
|
33
|
+
|
|
34
|
+
/** Pattern-overlay texture families (built via `PIXI.TilingSprite`, Task 4). */
|
|
35
|
+
export type PatternType = 'dots' | 'grid' | 'lines' | 'diagonal' | 'crosshatch';
|
|
36
|
+
export const PATTERN_TYPES: readonly PatternType[] = [
|
|
37
|
+
'dots',
|
|
38
|
+
'grid',
|
|
39
|
+
'lines',
|
|
40
|
+
'diagonal',
|
|
41
|
+
'crosshatch',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** Blend modes the pattern overlay may composite with (pixi v8 BLEND_MODES subset). */
|
|
45
|
+
export type PatternBlend = 'normal' | 'multiply' | 'screen' | 'overlay' | 'soft-light';
|
|
46
|
+
export const PATTERN_BLENDS: readonly PatternBlend[] = [
|
|
47
|
+
'normal',
|
|
48
|
+
'multiply',
|
|
49
|
+
'screen',
|
|
50
|
+
'overlay',
|
|
51
|
+
'soft-light',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** Preset alpha masks (built via `PIXI.Graphics`, Task 4). */
|
|
55
|
+
export type MaskPreset = 'none' | 'vignette' | 'radial-reveal' | 'edge-fade';
|
|
56
|
+
export const MASK_PRESETS: readonly MaskPreset[] = [
|
|
57
|
+
'none',
|
|
58
|
+
'vignette',
|
|
59
|
+
'radial-reveal',
|
|
60
|
+
'edge-fade',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Basic tonal adjustments. Every field is a NORMALIZED delta with a neutral
|
|
65
|
+
* origin so an all-zero object is a no-op (renders identical to the source):
|
|
66
|
+
* - brightness / contrast / saturation / exposure: −1 … +1, 0 = neutral.
|
|
67
|
+
* - hue: −180 … +180 degrees, 0 = neutral.
|
|
68
|
+
* - sepia / grayscale / invert: 0 … 1 amount, 0 = off.
|
|
69
|
+
* `filters.ts` maps these onto `PIXI.ColorMatrixFilter`'s convenience methods.
|
|
70
|
+
*/
|
|
71
|
+
export interface PhotoAdjustments {
|
|
72
|
+
brightness?: number;
|
|
73
|
+
contrast?: number;
|
|
74
|
+
saturation?: number;
|
|
75
|
+
exposure?: number;
|
|
76
|
+
hue?: number;
|
|
77
|
+
sepia?: number;
|
|
78
|
+
grayscale?: number;
|
|
79
|
+
invert?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Duotone remap — luminance → two-color gradient-map lerp. Needs a hand-authored
|
|
84
|
+
* GLSL fragment shader (`DuotoneFilter`, Task 4) because a two-point gradient map
|
|
85
|
+
* is a per-pixel remap that `ColorMatrixFilter`'s linear affine transform cannot
|
|
86
|
+
* express. `colorA`/`colorB` are `#rrggbb` hex (shadow → highlight).
|
|
87
|
+
*/
|
|
88
|
+
export interface PhotoDuotone {
|
|
89
|
+
enabled?: boolean;
|
|
90
|
+
colorA?: string;
|
|
91
|
+
colorB?: string;
|
|
92
|
+
/** 0 … 1 — crossfade between original and full duotone. */
|
|
93
|
+
intensity?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Film grain via `PIXI.NoiseFilter`. `amount` 0…1, `size` a px grain scale ≥ 1. */
|
|
97
|
+
export interface PhotoGrain {
|
|
98
|
+
enabled?: boolean;
|
|
99
|
+
amount?: number;
|
|
100
|
+
size?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Repeating pattern overlay via `PIXI.TilingSprite` + blend mode. */
|
|
104
|
+
export interface PhotoPattern {
|
|
105
|
+
enabled?: boolean;
|
|
106
|
+
type?: PatternType;
|
|
107
|
+
/** Tile scale multiplier (1 = native). */
|
|
108
|
+
scale?: number;
|
|
109
|
+
/** 0 … 1 overlay opacity. */
|
|
110
|
+
opacity?: number;
|
|
111
|
+
blend?: PatternBlend;
|
|
112
|
+
/** `#rrggbb` ink color the pattern is drawn in (default white). Load-bearing
|
|
113
|
+
* for the blend modes: a WHITE pattern under `multiply` is a no-op (white ×
|
|
114
|
+
* base = base) so the pattern "vanishes" — a dark color makes multiply darken. */
|
|
115
|
+
color?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Preset alpha mask + its strength (0 … 1). */
|
|
119
|
+
export interface PhotoMask {
|
|
120
|
+
preset?: MaskPreset;
|
|
121
|
+
strength?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Non-destructive background removal. When `enabled`, `maskAsset` is the
|
|
126
|
+
* content-addressed `assets/<sha8>.png` cutout/alpha-matte produced client-side
|
|
127
|
+
* by `@imgly/background-removal` and uploaded through the existing
|
|
128
|
+
* `POST /_api/asset` route (Task 10). Turning `enabled` off restores the
|
|
129
|
+
* original — the matte asset is retained, never deleted.
|
|
130
|
+
*/
|
|
131
|
+
export interface PhotoBackgroundRemoved {
|
|
132
|
+
enabled?: boolean;
|
|
133
|
+
/** `assets/<sha8>.png` — absent until the removal has actually run. */
|
|
134
|
+
maskAsset?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The full non-destructive edit for one source photo. Every field is optional;
|
|
139
|
+
* an empty object is a valid "unedited" sidecar. `source` is the
|
|
140
|
+
* `assets/<sha8>.<ext>` the edit applies to (redundant with the sidecar's own
|
|
141
|
+
* filename, but self-describing for the CLI + audit).
|
|
142
|
+
*/
|
|
143
|
+
export interface PhotoEdit {
|
|
144
|
+
/** Schema version of THIS object (defaults to PHOTO_EDIT_VERSION on write). */
|
|
145
|
+
version?: number;
|
|
146
|
+
/** `assets/<sha8>.<ext>` — the source the edit targets. */
|
|
147
|
+
source?: string;
|
|
148
|
+
adjustments?: PhotoAdjustments;
|
|
149
|
+
duotone?: PhotoDuotone;
|
|
150
|
+
grain?: PhotoGrain;
|
|
151
|
+
pattern?: PhotoPattern;
|
|
152
|
+
mask?: PhotoMask;
|
|
153
|
+
backgroundRemoved?: PhotoBackgroundRemoved;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Pipeline order is FIXED and load-bearing — filters are not commutative.
|
|
158
|
+
* `filters.ts` MUST build its graph in exactly this sequence. Exported so the
|
|
159
|
+
* compositor + tests share one source of truth.
|
|
160
|
+
*/
|
|
161
|
+
export const PHOTO_PIPELINE_ORDER = ['adjustments', 'duotone', 'grain', 'pattern', 'mask'] as const;
|
|
162
|
+
|
|
163
|
+
const EPS = 1e-6;
|
|
164
|
+
const near = (v: number | undefined, origin: number): boolean =>
|
|
165
|
+
v == null || Math.abs(v - origin) < EPS;
|
|
166
|
+
|
|
167
|
+
/** True when the adjustments block has no visible effect. */
|
|
168
|
+
function adjustmentsAreNeutral(a?: PhotoAdjustments): boolean {
|
|
169
|
+
if (!a) return true;
|
|
170
|
+
return (
|
|
171
|
+
near(a.brightness, 0) &&
|
|
172
|
+
near(a.contrast, 0) &&
|
|
173
|
+
near(a.saturation, 0) &&
|
|
174
|
+
near(a.exposure, 0) &&
|
|
175
|
+
near(a.hue, 0) &&
|
|
176
|
+
near(a.sepia, 0) &&
|
|
177
|
+
near(a.grayscale, 0) &&
|
|
178
|
+
near(a.invert, 0)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* True when the edit produces a render identical to the untouched source, so
|
|
184
|
+
* `<PhotoLayer>` can skip mounting pixi.js entirely (the lazy-bundle guarantee).
|
|
185
|
+
* "Neutral" = every section either absent, disabled, or at its no-op value.
|
|
186
|
+
*/
|
|
187
|
+
export function isDefaultEdit(edit?: PhotoEdit | null): boolean {
|
|
188
|
+
if (!edit) return true;
|
|
189
|
+
if (!adjustmentsAreNeutral(edit.adjustments)) return false;
|
|
190
|
+
if (edit.duotone?.enabled && (edit.duotone.intensity ?? 1) > EPS) return false;
|
|
191
|
+
if (edit.grain?.enabled && (edit.grain.amount ?? 1) > EPS) return false;
|
|
192
|
+
if (edit.pattern?.enabled && (edit.pattern.opacity ?? 1) > EPS) return false;
|
|
193
|
+
if (edit.mask?.preset && edit.mask.preset !== 'none' && (edit.mask.strength ?? 1) > EPS)
|
|
194
|
+
return false;
|
|
195
|
+
if (edit.backgroundRemoved?.enabled && edit.backgroundRemoved.maskAsset) return false;
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A fresh, fully-neutral edit for `source` (used when opening the Photo tab). */
|
|
200
|
+
export function emptyPhotoEdit(source?: string): PhotoEdit {
|
|
201
|
+
return { version: PHOTO_EDIT_VERSION, ...(source ? { source } : {}) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Structural validation (dependency-free — no Ajv) ─────────────────────────
|
|
205
|
+
// The `/_api/photo-edit` route (Task 8) validates untrusted JSON with this
|
|
206
|
+
// before persisting. Reject anything unexpected: unknown top-level keys, wrong
|
|
207
|
+
// types, out-of-range numbers, non-hex colors, non-relative asset paths. This
|
|
208
|
+
// is a security surface (DDR-088) — a crafted field must not round-trip to disk.
|
|
209
|
+
|
|
210
|
+
export interface ValidationResult {
|
|
211
|
+
ok: boolean;
|
|
212
|
+
errors: string[];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
|
216
|
+
// Relative content-addressed asset path only — mirrors the annotation-layer
|
|
217
|
+
// `<image>` href allowlist; NEVER a data:/blob:/absolute/traversing path.
|
|
218
|
+
const ASSET_REL_RE = /^assets\/[0-9a-f]{8,}[A-Za-z0-9._-]*$/;
|
|
219
|
+
|
|
220
|
+
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
221
|
+
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
222
|
+
|
|
223
|
+
function num(errors: string[], obj: Record<string, unknown>, key: string, lo: number, hi: number) {
|
|
224
|
+
if (!(key in obj) || obj[key] == null) return;
|
|
225
|
+
const v = obj[key];
|
|
226
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
|
227
|
+
errors.push(`${key}: must be a finite number`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (v < lo || v > hi) errors.push(`${key}: ${v} out of range [${lo}, ${hi}]`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function bool(errors: string[], obj: Record<string, unknown>, key: string) {
|
|
234
|
+
if (key in obj && obj[key] != null && typeof obj[key] !== 'boolean')
|
|
235
|
+
errors.push(`${key}: must be a boolean`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function assertKeys(
|
|
239
|
+
errors: string[],
|
|
240
|
+
obj: Record<string, unknown>,
|
|
241
|
+
allowed: string[],
|
|
242
|
+
where: string
|
|
243
|
+
) {
|
|
244
|
+
for (const k of Object.keys(obj))
|
|
245
|
+
if (!allowed.includes(k)) errors.push(`${where}: unknown key "${k}"`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Structurally validate an untrusted value as a `PhotoEdit`. Returns collected
|
|
250
|
+
* errors (empty ⇒ valid). Unknown keys, wrong types, out-of-range numbers,
|
|
251
|
+
* malformed colors, and non-relative asset paths all fail.
|
|
252
|
+
*/
|
|
253
|
+
export function validatePhotoEdit(input: unknown): ValidationResult {
|
|
254
|
+
const errors: string[] = [];
|
|
255
|
+
if (!isPlainObject(input)) return { ok: false, errors: ['root: must be an object'] };
|
|
256
|
+
|
|
257
|
+
assertKeys(
|
|
258
|
+
errors,
|
|
259
|
+
input,
|
|
260
|
+
[
|
|
261
|
+
'version',
|
|
262
|
+
'source',
|
|
263
|
+
'adjustments',
|
|
264
|
+
'duotone',
|
|
265
|
+
'grain',
|
|
266
|
+
'pattern',
|
|
267
|
+
'mask',
|
|
268
|
+
'backgroundRemoved',
|
|
269
|
+
],
|
|
270
|
+
'root'
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
if ('version' in input && input.version != null && typeof input.version !== 'number')
|
|
274
|
+
errors.push('version: must be a number');
|
|
275
|
+
if ('source' in input && input.source != null) {
|
|
276
|
+
if (typeof input.source !== 'string' || !ASSET_REL_RE.test(input.source))
|
|
277
|
+
errors.push('source: must be a relative assets/<sha8>.<ext> path');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (input.adjustments != null) {
|
|
281
|
+
const a = input.adjustments;
|
|
282
|
+
if (!isPlainObject(a)) errors.push('adjustments: must be an object');
|
|
283
|
+
else {
|
|
284
|
+
assertKeys(
|
|
285
|
+
errors,
|
|
286
|
+
a,
|
|
287
|
+
['brightness', 'contrast', 'saturation', 'exposure', 'hue', 'sepia', 'grayscale', 'invert'],
|
|
288
|
+
'adjustments'
|
|
289
|
+
);
|
|
290
|
+
num(errors, a, 'brightness', -1, 1);
|
|
291
|
+
num(errors, a, 'contrast', -1, 1);
|
|
292
|
+
num(errors, a, 'saturation', -1, 1);
|
|
293
|
+
num(errors, a, 'exposure', -1, 1);
|
|
294
|
+
num(errors, a, 'hue', -180, 180);
|
|
295
|
+
num(errors, a, 'sepia', 0, 1);
|
|
296
|
+
num(errors, a, 'grayscale', 0, 1);
|
|
297
|
+
num(errors, a, 'invert', 0, 1);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (input.duotone != null) {
|
|
302
|
+
const d = input.duotone;
|
|
303
|
+
if (!isPlainObject(d)) errors.push('duotone: must be an object');
|
|
304
|
+
else {
|
|
305
|
+
assertKeys(errors, d, ['enabled', 'colorA', 'colorB', 'intensity'], 'duotone');
|
|
306
|
+
bool(errors, d, 'enabled');
|
|
307
|
+
for (const c of ['colorA', 'colorB'] as const)
|
|
308
|
+
if (c in d && d[c] != null && (typeof d[c] !== 'string' || !HEX_RE.test(d[c] as string)))
|
|
309
|
+
errors.push(`duotone.${c}: must be a #rrggbb hex color`);
|
|
310
|
+
num(errors, d, 'intensity', 0, 1);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (input.grain != null) {
|
|
315
|
+
const g = input.grain;
|
|
316
|
+
if (!isPlainObject(g)) errors.push('grain: must be an object');
|
|
317
|
+
else {
|
|
318
|
+
assertKeys(errors, g, ['enabled', 'amount', 'size'], 'grain');
|
|
319
|
+
bool(errors, g, 'enabled');
|
|
320
|
+
num(errors, g, 'amount', 0, 1);
|
|
321
|
+
num(errors, g, 'size', 1, 32);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (input.pattern != null) {
|
|
326
|
+
const p = input.pattern;
|
|
327
|
+
if (!isPlainObject(p)) errors.push('pattern: must be an object');
|
|
328
|
+
else {
|
|
329
|
+
assertKeys(errors, p, ['enabled', 'type', 'scale', 'opacity', 'blend', 'color'], 'pattern');
|
|
330
|
+
bool(errors, p, 'enabled');
|
|
331
|
+
if ('type' in p && p.type != null && !PATTERN_TYPES.includes(p.type as PatternType))
|
|
332
|
+
errors.push(`pattern.type: must be one of ${PATTERN_TYPES.join(', ')}`);
|
|
333
|
+
if ('blend' in p && p.blend != null && !PATTERN_BLENDS.includes(p.blend as PatternBlend))
|
|
334
|
+
errors.push(`pattern.blend: must be one of ${PATTERN_BLENDS.join(', ')}`);
|
|
335
|
+
if ('color' in p && p.color != null && (typeof p.color !== 'string' || !HEX_RE.test(p.color)))
|
|
336
|
+
errors.push('pattern.color: must be a #rrggbb hex color');
|
|
337
|
+
num(errors, p, 'scale', 0.1, 16);
|
|
338
|
+
num(errors, p, 'opacity', 0, 1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (input.mask != null) {
|
|
343
|
+
const m = input.mask;
|
|
344
|
+
if (!isPlainObject(m)) errors.push('mask: must be an object');
|
|
345
|
+
else {
|
|
346
|
+
assertKeys(errors, m, ['preset', 'strength'], 'mask');
|
|
347
|
+
if ('preset' in m && m.preset != null && !MASK_PRESETS.includes(m.preset as MaskPreset))
|
|
348
|
+
errors.push(`mask.preset: must be one of ${MASK_PRESETS.join(', ')}`);
|
|
349
|
+
num(errors, m, 'strength', 0, 1);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (input.backgroundRemoved != null) {
|
|
354
|
+
const b = input.backgroundRemoved;
|
|
355
|
+
if (!isPlainObject(b)) errors.push('backgroundRemoved: must be an object');
|
|
356
|
+
else {
|
|
357
|
+
assertKeys(errors, b, ['enabled', 'maskAsset'], 'backgroundRemoved');
|
|
358
|
+
bool(errors, b, 'enabled');
|
|
359
|
+
if ('maskAsset' in b && b.maskAsset != null) {
|
|
360
|
+
if (typeof b.maskAsset !== 'string' || !ASSET_REL_RE.test(b.maskAsset))
|
|
361
|
+
errors.push('backgroundRemoved.maskAsset: must be a relative assets/<sha8>.png path');
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return { ok: errors.length === 0, errors };
|
|
367
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file photo-store.ts — PhotoEdit sidecar persistence (Stage C, Task 8)
|
|
3
|
+
* @scope apps/studio/photo-store.ts
|
|
4
|
+
* @purpose Read / write the non-destructive `assets/<sha8>.photo.json` sidecar
|
|
5
|
+
* that backs the photo editor. Mirrors `inspect.ts`'s `createX(ctx)`
|
|
6
|
+
* factory + `Bun.write`/`Bun.file` shape. Server-side only — imports
|
|
7
|
+
* the DEPENDENCY-FREE `photo/schema.ts` (never `pixi.js`).
|
|
8
|
+
*
|
|
9
|
+
* @security The `/_api/photo-edit` route is CANVAS-SAFE (reachable from the
|
|
10
|
+
* untrusted canvas iframe — DDR-054), and its write PATH is derived
|
|
11
|
+
* from an attacker-controllable `asset` param. Unlike `/_api/asset`
|
|
12
|
+
* (content-addressed → path not attacker-chosen), this route's path
|
|
13
|
+
* comes from the caller, so the DDR-088 cap stack is load-bearing:
|
|
14
|
+
* 1. strict sha8 extraction/validation (hex only, bounded length) —
|
|
15
|
+
* rejects `..`, `/`, `%2f`, absolute paths by construction;
|
|
16
|
+
* 2. explicit containment assert (belt-and-braces vs a poisoned
|
|
17
|
+
* designRoot — mirrors api.ts saveAssetFromStream);
|
|
18
|
+
* 3. structural `validatePhotoEdit` (unknown keys / bad types /
|
|
19
|
+
* out-of-range numbers / non-hex colors / non-relative asset
|
|
20
|
+
* paths all rejected — no crafted field round-trips to disk);
|
|
21
|
+
* 4. body/file size cap (64 KB — generous for parameters).
|
|
22
|
+
* Threat-table (DDR-088): path-traversal → (1)+(2); stored-XSS via a
|
|
23
|
+
* crafted field → (3), the JSON is only ever re-read by the schema-
|
|
24
|
+
* typed compositor, never eval'd/HTML-injected; oversized-body DoS →
|
|
25
|
+
* (4). No `sameOriginWrite` (would block the legit canvas origin, e.g.
|
|
26
|
+
* the headless bg-remove harness) — `isLoopbackHost` on the route is
|
|
27
|
+
* the DNS-rebinding guard. Residual (accepted, DDR-054 baseline): an
|
|
28
|
+
* already-untrusted canvas can write a WELL-FORMED sidecar for any
|
|
29
|
+
* sha8 — a low-severity integrity nuisance, bounded by (3), same class
|
|
30
|
+
* as its existing ability to write arbitrary images via /_api/asset.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
import type { Context } from './context.ts';
|
|
36
|
+
import { PHOTO_EDIT_VERSION, type PhotoEdit, validatePhotoEdit } from './photo/schema.ts';
|
|
37
|
+
|
|
38
|
+
/** Max bytes for a sidecar (read + write). Parameters are tiny; this is slack. */
|
|
39
|
+
export const PHOTO_EDIT_MAX_BYTES = 64 * 1024;
|
|
40
|
+
|
|
41
|
+
const SHA_RE = /^[0-9a-f]{8,64}$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract + validate the content-address sha8 from an `asset` param. Accepts the
|
|
45
|
+
* source in any of the forms a caller might hold — `assets/<sha8>.<ext>`,
|
|
46
|
+
* `/assets/<sha8>.png`, `<sha8>.png`, or bare `<sha8>` — and returns the bare,
|
|
47
|
+
* validated sha8, or null if it isn't clean hex. The hex regex is the primary
|
|
48
|
+
* traversal defense: `..`, `/`, `%2f`, backslashes, and absolute paths can never
|
|
49
|
+
* match, so no path segment survives extraction.
|
|
50
|
+
*/
|
|
51
|
+
export function assetSha8(param: string | null | undefined): string | null {
|
|
52
|
+
if (typeof param !== 'string') return null;
|
|
53
|
+
let s = param.trim();
|
|
54
|
+
if (!s) return null;
|
|
55
|
+
s = s.replace(/^\/+/, ''); // drop leading slashes
|
|
56
|
+
s = s.replace(/^assets\//, ''); // drop the assets/ prefix
|
|
57
|
+
s = s.replace(/\.[A-Za-z0-9]+$/, ''); // drop a single trailing extension
|
|
58
|
+
return SHA_RE.test(s) ? s : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PhotoStore {
|
|
62
|
+
/** Absolute path of the sidecar for a validated sha8 (throws if it escapes). */
|
|
63
|
+
editPathForSha8(sha8: string): string;
|
|
64
|
+
/** Read the sidecar → PhotoEdit, or null if absent / unreadable / oversized. */
|
|
65
|
+
getPhotoEdit(assetParam: string | null | undefined): Promise<PhotoEdit | null>;
|
|
66
|
+
/** Validate + persist. Returns a discriminated result (never throws on bad input). */
|
|
67
|
+
savePhotoEdit(
|
|
68
|
+
assetParam: string | null | undefined,
|
|
69
|
+
edit: unknown
|
|
70
|
+
): Promise<
|
|
71
|
+
{ ok: true; path: string; edit: PhotoEdit } | { ok: false; status: number; error: string }
|
|
72
|
+
>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createPhotoStore(ctx: Context): PhotoStore {
|
|
76
|
+
const assetsDir = path.join(ctx.paths.designRoot, 'assets');
|
|
77
|
+
|
|
78
|
+
function editPathForSha8(sha8: string): string {
|
|
79
|
+
if (!SHA_RE.test(sha8)) throw new Error(`invalid sha8: ${sha8}`);
|
|
80
|
+
const abs = path.join(assetsDir, `${sha8}.photo.json`);
|
|
81
|
+
// Belt-and-braces containment (defense in depth vs a poisoned designRoot —
|
|
82
|
+
// sha8 is already validated hex, so this can only fail pathologically).
|
|
83
|
+
const rel = path.relative(assetsDir, abs);
|
|
84
|
+
if (rel !== `${sha8}.photo.json` || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
85
|
+
throw new Error(`photo-edit path escaped assets/: ${abs}`);
|
|
86
|
+
}
|
|
87
|
+
return abs;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function getPhotoEdit(assetParam: string | null | undefined): Promise<PhotoEdit | null> {
|
|
91
|
+
const sha8 = assetSha8(assetParam);
|
|
92
|
+
if (!sha8) return null;
|
|
93
|
+
try {
|
|
94
|
+
const file = Bun.file(editPathForSha8(sha8));
|
|
95
|
+
if (!(await file.exists())) return null;
|
|
96
|
+
if (file.size > PHOTO_EDIT_MAX_BYTES) return null; // corrupt / oversized — ignore
|
|
97
|
+
const raw = await file.text();
|
|
98
|
+
const parsed = JSON.parse(raw);
|
|
99
|
+
// A stored sidecar is trusted-ish (we wrote it validated), but re-validate
|
|
100
|
+
// so a hand-edited / corrupt file never reaches the compositor malformed.
|
|
101
|
+
return validatePhotoEdit(parsed).ok ? (parsed as PhotoEdit) : null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function savePhotoEdit(
|
|
108
|
+
assetParam: string | null | undefined,
|
|
109
|
+
edit: unknown
|
|
110
|
+
): Promise<
|
|
111
|
+
{ ok: true; path: string; edit: PhotoEdit } | { ok: false; status: number; error: string }
|
|
112
|
+
> {
|
|
113
|
+
const sha8 = assetSha8(assetParam);
|
|
114
|
+
if (!sha8) return { ok: false, status: 400, error: 'invalid or missing asset (expected sha8)' };
|
|
115
|
+
|
|
116
|
+
// Size cap BEFORE validation (a giant blob shouldn't be walked field-by-field).
|
|
117
|
+
let serialized: string;
|
|
118
|
+
try {
|
|
119
|
+
serialized = JSON.stringify(edit ?? {});
|
|
120
|
+
} catch {
|
|
121
|
+
return { ok: false, status: 400, error: 'body is not serializable JSON' };
|
|
122
|
+
}
|
|
123
|
+
if (Buffer.byteLength(serialized, 'utf8') > PHOTO_EDIT_MAX_BYTES) {
|
|
124
|
+
return { ok: false, status: 413, error: `photo edit exceeds ${PHOTO_EDIT_MAX_BYTES} bytes` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const check = validatePhotoEdit(edit);
|
|
128
|
+
if (!check.ok) {
|
|
129
|
+
return { ok: false, status: 400, error: `invalid PhotoEdit: ${check.errors.join('; ')}` };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Stamp the schema version + canonical source on write (never trust the
|
|
133
|
+
// client's version claim; the sidecar's own filename is the source of truth).
|
|
134
|
+
const toWrite: PhotoEdit = {
|
|
135
|
+
...(edit as PhotoEdit),
|
|
136
|
+
version: PHOTO_EDIT_VERSION,
|
|
137
|
+
};
|
|
138
|
+
let abs: string;
|
|
139
|
+
try {
|
|
140
|
+
abs = editPathForSha8(sha8);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return { ok: false, status: 400, error: e instanceof Error ? e.message : 'bad path' };
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
// Bun.write creates the assets/ dir if absent.
|
|
146
|
+
await Bun.write(abs, JSON.stringify(toWrite, null, 2));
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return { ok: false, status: 500, error: e instanceof Error ? e.message : 'write failed' };
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, path: path.posix.join('assets', `${sha8}.photo.json`), edit: toWrite };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { editPathForSha8, getPhotoEdit, savePhotoEdit };
|
|
154
|
+
}
|
|
@@ -47,12 +47,26 @@ export const RUNTIME_PACKAGES = [
|
|
|
47
47
|
'react-dom/client',
|
|
48
48
|
'react/jsx-runtime',
|
|
49
49
|
'react/jsx-dev-runtime',
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
50
|
+
// Pixi.js v8 — per-iframe runtime bundle for the non-destructive photo editor
|
|
51
|
+
// (feature-photo-editor). The canvas-lib `<PhotoLayer>` reaches the WebGL
|
|
52
|
+
// compositor (`photo/pipeline.ts`) through a LAZY runtime `import('pixi.js')`,
|
|
53
|
+
// so a canvas with no edited photo never pays the ~500 KB bundle cost (the
|
|
54
|
+
// lazy-bundle guarantee — regression-tested in test/photo-canvas-bundle.test.ts).
|
|
55
|
+
// Pre-built into dist/runtime/pixi-js.js + floored in .min-sizes.json.
|
|
56
|
+
// (Originally parked by DDR-024 for snapshot-to-texture rendering; the photo
|
|
57
|
+
// editor is the first real activation — see the photo-editor architecture DDR.)
|
|
55
58
|
'pixi.js',
|
|
59
|
+
// @imgly/background-removal (feature-photo-editor) — client-side ML background
|
|
60
|
+
// removal (WASM/WebGPU via onnxruntime-web, ZERO native deps; the Node/native
|
|
61
|
+
// `-node` variant is NEVER imported — bun-compile-hostile, DDR-070 sharp-class
|
|
62
|
+
// exclusion). Externalised for the same lazy per-iframe bundling as pixi: the
|
|
63
|
+
// bg-remove harness (PhotoBgRemoveHarness) reaches it through a runtime
|
|
64
|
+
// `import('@imgly/background-removal')`, so a canvas that never removes a
|
|
65
|
+
// background pays zero cost. Model weights (~11–44 MB isnet) are fetched at
|
|
66
|
+
// first use (IMG.LY CDN by default; `publicPath` self-hosts for air-gapped —
|
|
67
|
+
// a documented follow-up, see the photo-editor DDR). Pre-built into
|
|
68
|
+
// dist/runtime + floored in .min-sizes.json.
|
|
69
|
+
'@imgly/background-removal',
|
|
56
70
|
// Phase 3.7 / DDR-049 — Motion One (motion/react) is the canonical motion
|
|
57
71
|
// library for the canvas-lib + handoff pipeline. Externalised here so the
|
|
58
72
|
// canvas-lib motion helpers (<MotionDemo>, etc.) resolve through the same
|
|
@@ -220,7 +234,16 @@ export async function getRuntimeBundle(
|
|
|
220
234
|
// The `as any` cast tolerates names like `__INTERNAL_DO_NOT_USE_OR_WARN`
|
|
221
235
|
// that aren't declared in the package's .d.ts; the destructure still
|
|
222
236
|
// succeeds at runtime, which is what matters.
|
|
223
|
-
|
|
237
|
+
// pixi.js compiles shaders with `new Function()` — which the split-origin
|
|
238
|
+
// canvas iframe's strict CSP (DDR-054) forbids (no `unsafe-eval`), so the
|
|
239
|
+
// photo compositor threw "Current environment does not allow unsafe-eval" and
|
|
240
|
+
// the live preview silently failed in the DEFAULT mode (only same-origin, the
|
|
241
|
+
// opt-out, had no CSP to trip). pixi ships `pixi.js/unsafe-eval` — a
|
|
242
|
+
// side-effect module that swaps the eval-based shader/uniform sync for
|
|
243
|
+
// eval-free polyfills. Baking it into the runtime bundle here makes EVERY
|
|
244
|
+
// canvas's `import('pixi.js')` CSP-safe transparently (feature-photo-editor).
|
|
245
|
+
const sideEffects = pkg === 'pixi.js' ? `import "pixi.js/unsafe-eval";\n` : '';
|
|
246
|
+
const entryContent = `${sideEffects}import * as __mod__ from ${JSON.stringify(pkg)};\n${
|
|
224
247
|
exportNames.length > 0
|
|
225
248
|
? `const {\n${namedLines}\n} = __mod__ as any;\n` + `export {\n${namedLines}\n};\n`
|
|
226
249
|
: ''
|
package/apps/studio/server.ts
CHANGED
|
@@ -242,12 +242,16 @@ function startCanvasServer(port: number): BunServer {
|
|
|
242
242
|
'/_api/git-user': http.routes['/_api/git-user'],
|
|
243
243
|
'/_api/canvas-meta': http.routes['/_api/canvas-meta'],
|
|
244
244
|
'/_api/annotations': http.routes['/_api/annotations'],
|
|
245
|
-
// Phase 23 — capped binary image upload (magic-byte sniff +
|
|
245
|
+
// Phase 23 — capped binary image upload (magic-byte sniff + category cap +
|
|
246
246
|
// content-addressed name + traversal guard + no-SVG, in api.saveAsset).
|
|
247
247
|
// Bun matches `routes` BEFORE `fetch`, so the route must be listed here
|
|
248
248
|
// explicitly — the CANVAS_SAFE_API entry alone only opens the fetch
|
|
249
249
|
// fall-through (which serves files, not route handlers). See DDR (Task 9).
|
|
250
250
|
'/_api/asset': http.routes['/_api/asset'],
|
|
251
|
+
// feature-photo-editor — PhotoEdit sidecar GET/PUT. MUST be here AND in
|
|
252
|
+
// CANVAS_SAFE_API (http.ts): Bun matches `routes` before `fetch`, so a
|
|
253
|
+
// one-list entry 404s from the canvas iframe (the DDR-088 rollout bug).
|
|
254
|
+
'/_api/photo-edit': http.routes['/_api/photo-edit'],
|
|
251
255
|
'/_api/git-committers': http.routes['/_api/git-committers'],
|
|
252
256
|
'/_api/ai': http.routes['/_api/ai'],
|
|
253
257
|
'/_comments': http.routes['/_comments'],
|