@nodaro/shared 3.4.0 → 3.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The worked examples here are repeated verbatim in
3
+ * docs/nodes/processing-video/image-overlay.md — change both or neither.
4
+ */
5
+ import { describe, it, expect } from "vitest"
6
+ import { imageOverlayCredits, imageOverlayBillableVariants, IMAGE_OVERLAY_BASE_CREDITS, IMAGE_OVERLAY_VARIANT_CREDITS } from "../credit-estimators/image-overlay"
7
+ import { OVERLAY_PLATFORM_IDS } from "../image-overlay-platforms"
8
+
9
+ describe("imageOverlayCredits", () => {
10
+ it("base + 2 per extra platform: 0 → 10, 2 → 14, all twelve → 34", () => {
11
+ expect(IMAGE_OVERLAY_BASE_CREDITS).toBe(10)
12
+ expect(IMAGE_OVERLAY_VARIANT_CREDITS).toBe(2)
13
+ expect(imageOverlayCredits(undefined)).toBe(10)
14
+ expect(imageOverlayCredits([])).toBe(10)
15
+ expect(imageOverlayCredits(["instagram-post", "youtube-thumbnail"])).toBe(14)
16
+ expect(OVERLAY_PLATFORM_IDS.length).toBe(12)
17
+ expect(imageOverlayCredits(OVERLAY_PLATFORM_IDS)).toBe(34)
18
+ })
19
+
20
+ it("counts only distinct, real platform ids — duplicates and junk are free because they are not rendered", () => {
21
+ expect(imageOverlayBillableVariants(["instagram-post", "instagram-post", 7, "not-a-platform", "x-header"])).toEqual(["instagram-post", "x-header"])
22
+ expect(imageOverlayCredits(["instagram-post", "instagram-post", 7, "not-a-platform", "x-header"])).toBe(14)
23
+ })
24
+ })
@@ -364,6 +364,30 @@ describe("scene3d v2 — entities", () => {
364
364
  expectRejects(plan, 'declares anchor "roof" twice')
365
365
  })
366
366
 
367
+ it("round-trips an explicit node-local anchor on an asset", () => {
368
+ const plan = planV2()
369
+ const anchor = { name: "door.tip", nodeName: "car/door.hinge", position: [1, 0, 0] as [number, number, number] }
370
+ plan.objects[1].anchors = [anchor]
371
+ expect(scene3DPlanV2Schema.parse(JSON.parse(JSON.stringify(plan))).objects[1].anchors).toEqual([anchor])
372
+ })
373
+
374
+ it("rejects a bound anchor on a primitive", () => {
375
+ const plan = planV2()
376
+ plan.objects[2].anchors = [{ name: "top", nodeName: "mesh", position: [0, 1, 0] }]
377
+ expectRejects(plan, "can bind anchor nodes only with an asset visual")
378
+ })
379
+
380
+ it("bounds raw anchor node names without imposing the root-ID charset", () => {
381
+ const plan = planV2()
382
+ plan.objects[1].anchors = [{ name: "tip", nodeName: `${"a".repeat(64)}/${"b".repeat(64)}`,
383
+ position: [0, 0, 0] }]
384
+ expect(scene3DPlanV2Schema.safeParse(plan).success).toBe(true)
385
+ for (const nodeName of ["", "bad\nname", "x".repeat(SCENE3D_V2_LIMITS.maxNodeNameLength + 1)]) {
386
+ plan.objects[1].anchors = [{ name: "tip", nodeName, position: [0, 0, 0] }]
387
+ expect(scene3DPlanV2Schema.safeParse(plan).success).toBe(false)
388
+ }
389
+ })
390
+
367
391
  it("bounds an animation binding to the timeline", () => {
368
392
  const past = planV2()
369
393
  past.objects[1].visual = {
@@ -0,0 +1,30 @@
1
+ import { overlayPlatformById } from "../image-overlay-platforms.js"
2
+
3
+ /**
4
+ * Image Overlay pricing — one formula shared by the backend route
5
+ * (creditGuard computeCredits), the orchestrator's reservation override, the
6
+ * workflow estimator and the canvas Run button, so every surface quotes and
7
+ * charges the same number.
8
+ *
9
+ * credits = BASE + PER_VARIANT × (number of distinct, valid platform ids)
10
+ *
11
+ * The base covers the composite and its mask; every extra platform render is
12
+ * one more resize + encode + upload. Worked examples (pinned by the tests and
13
+ * repeated in docs/nodes/processing-video/image-overlay.md — keep them equal):
14
+ * 0 variants → 10, 2 → 14, 12 (every platform) → 34.
15
+ */
16
+ export const IMAGE_OVERLAY_BASE_CREDITS = 10
17
+ export const IMAGE_OVERLAY_VARIANT_CREDITS = 2
18
+
19
+ /** Distinct platform ids the registry knows — the count the price is built from. */
20
+ export function imageOverlayBillableVariants(variants: ReadonlyArray<unknown> | undefined): string[] {
21
+ const seen = new Set<string>()
22
+ for (const v of variants ?? []) {
23
+ if (typeof v === "string" && overlayPlatformById(v)) seen.add(v)
24
+ }
25
+ return [...seen]
26
+ }
27
+
28
+ export function imageOverlayCredits(variants: ReadonlyArray<unknown> | undefined): number {
29
+ return IMAGE_OVERLAY_BASE_CREDITS + IMAGE_OVERLAY_VARIANT_CREDITS * imageOverlayBillableVariants(variants).length
30
+ }
@@ -7,6 +7,13 @@ export {
7
7
  assembleNarratedVideoCredits,
8
8
  } from "./video-utils.js"
9
9
 
10
+ export {
11
+ IMAGE_OVERLAY_BASE_CREDITS,
12
+ IMAGE_OVERLAY_VARIANT_CREDITS,
13
+ imageOverlayBillableVariants,
14
+ imageOverlayCredits,
15
+ } from "./image-overlay.js"
16
+
10
17
  export type {
11
18
  LoopVideoEstimatorInput,
12
19
  TrimVideoEstimatorInput,
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Image Overlay — the layer KINDS a node can carry beyond a wired image, and
3
+ * the wire contract for each. Shared by the route's Zod, the MCP tool, the
4
+ * worker's renderer and the canvas preview/editor, so a field exists once.
5
+ *
6
+ * image — a wired picture (logo, cut-out, sticker); placement + effects
7
+ * text — real typography from a bundled font (rendered server-side from
8
+ * the same TTF the browser previews with, so the two agree)
9
+ * qr — a QR code generated from a payload string
10
+ * shape — a flat rectangle / pill / circle / ribbon (badges, price tags,
11
+ * text backgrounds)
12
+ *
13
+ * Sizes are PERCENT of the base image (text `fontSize` = % of base height),
14
+ * pixels are only used where a real pixel count is the honest unit (stroke,
15
+ * padding, radius, blur — on the base image).
16
+ */
17
+ import { z } from "zod"
18
+
19
+ export const OVERLAY_LAYER_KINDS = ["image", "text", "qr", "shape"] as const
20
+ export type OverlayLayerKind = (typeof OVERLAY_LAYER_KINDS)[number]
21
+
22
+ export const OVERLAY_TEXT_ALIGNS = ["left", "center", "right"] as const
23
+ export type OverlayTextAlign = (typeof OVERLAY_TEXT_ALIGNS)[number]
24
+
25
+ import { OVERLAY_SHAPES } from "./image-overlay-shapes.js"
26
+
27
+ export const OVERLAY_IMAGE_MASKS = ["none", "circle"] as const
28
+ export type OverlayImageMask = (typeof OVERLAY_IMAGE_MASKS)[number]
29
+
30
+ /**
31
+ * The bundled font faces. `file` names the TTF under
32
+ * backend/src/assets/fonts (served at GET /v1/fonts/:file for the preview);
33
+ * `variable` faces carry a `wght` axis and honour any weight 100–900, static
34
+ * faces render at their single weight. `scripts` tells the picker which faces
35
+ * can set Hebrew / Arabic.
36
+ */
37
+ export const OVERLAY_FONTS = [
38
+ { id: "inter", family: "Inter", file: "Inter.ttf", variable: true, scripts: ["latin"], category: "sans" },
39
+ { id: "montserrat", family: "Montserrat", file: "Montserrat.ttf", variable: true, scripts: ["latin"], category: "sans" },
40
+ { id: "space-grotesk", family: "Space Grotesk", file: "SpaceGrotesk.ttf", variable: true, scripts: ["latin"], category: "sans" },
41
+ { id: "playfair-display", family: "Playfair Display", file: "PlayfairDisplay.ttf", variable: true, scripts: ["latin"], category: "serif" },
42
+ { id: "oswald", family: "Oswald", file: "Oswald.ttf", variable: true, scripts: ["latin"], category: "display" },
43
+ { id: "bebas-neue", family: "Bebas Neue", file: "BebasNeue.ttf", variable: false, scripts: ["latin"], category: "display" },
44
+ { id: "anton", family: "Anton", file: "Anton.ttf", variable: false, scripts: ["latin"], category: "display" },
45
+ { id: "pacifico", family: "Pacifico", file: "Pacifico.ttf", variable: false, scripts: ["latin"], category: "script" },
46
+ { id: "rubik", family: "Rubik", file: "Rubik.ttf", variable: true, scripts: ["latin", "hebrew"], category: "sans" },
47
+ { id: "heebo", family: "Heebo", file: "Heebo.ttf", variable: true, scripts: ["latin", "hebrew"], category: "sans" },
48
+ ] as const
49
+
50
+ export type OverlayFontId = (typeof OVERLAY_FONTS)[number]["id"]
51
+ export const OVERLAY_FONT_IDS = OVERLAY_FONTS.map((f) => f.id) as unknown as readonly [OverlayFontId, ...OverlayFontId[]]
52
+
53
+ export function overlayFontById(id: string): (typeof OVERLAY_FONTS)[number] | undefined {
54
+ return OVERLAY_FONTS.find((f) => f.id === id)
55
+ }
56
+
57
+ const hex6 = z.string().regex(/^#[0-9a-fA-F]{6}$/, "Expected a #RRGGBB color")
58
+
59
+ export const overlayStrokeSchema = z.object({
60
+ /** px on the base image */
61
+ width: z.number().min(0).max(50),
62
+ color: hex6,
63
+ })
64
+
65
+ /** Text layer style — everything the renderer and the preview need. */
66
+ export const overlayTextStyleSchema = z.object({
67
+ text: z.string().min(1).max(500),
68
+ fontId: z.enum(OVERLAY_FONT_IDS).default("inter"),
69
+ fontWeight: z.number().int().min(100).max(900).default(700),
70
+ /** % of the BASE height */
71
+ fontSize: z.number().min(1).max(50).default(8),
72
+ color: hex6.default("#ffffff"),
73
+ align: z.enum(OVERLAY_TEXT_ALIGNS).default("center"),
74
+ /** em */
75
+ letterSpacing: z.number().min(-0.2).max(1).default(0),
76
+ lineHeight: z.number().min(0.8).max(2.5).default(1.15),
77
+ uppercase: z.boolean().default(false),
78
+ stroke: overlayStrokeSchema.optional(),
79
+ /** A filled box behind the text — a pill, a badge, a lower third. */
80
+ background: z
81
+ .object({
82
+ color: hex6,
83
+ opacity: z.number().min(0).max(1).default(1),
84
+ /** px on the base image */
85
+ padding: z.number().min(0).max(300).default(24),
86
+ /** px corner radius; a huge value makes a pill */
87
+ radius: z.number().min(0).max(1000).default(0),
88
+ })
89
+ .optional(),
90
+ })
91
+ export type OverlayTextStyle = z.infer<typeof overlayTextStyleSchema>
92
+
93
+ export const overlayQrStyleSchema = z.object({
94
+ /** The link / text the code opens. May be empty ONLY while `fromInput` is
95
+ * set — the run then fills it from the node's "QR link" text handle. */
96
+ text: z.string().max(2000).default(""),
97
+ /** Take the payload from the node's QR link handle (a Text node, a List
98
+ * column, any text output) instead of this style's `text`. */
99
+ fromInput: z.boolean().optional(),
100
+ color: hex6.default("#000000"),
101
+ /** Absent = transparent quiet zone and background. */
102
+ background: hex6.optional(),
103
+ /** Quiet-zone modules */
104
+ margin: z.number().int().min(0).max(8).default(1),
105
+ })
106
+ export type OverlayQrStyle = z.infer<typeof overlayQrStyleSchema>
107
+
108
+ export const overlayShapeStyleSchema = z.object({
109
+ shape: z.enum(OVERLAY_SHAPES).default("rect"),
110
+ color: hex6.default("#ff0073"),
111
+ stroke: overlayStrokeSchema.optional(),
112
+ })
113
+ export type OverlayShapeStyle = z.infer<typeof overlayShapeStyleSchema>
114
+
115
+ /** Finishing for IMAGE layers: cut to a circle, fade the edges, outline, glow. */
116
+ export const overlayImageEffectsSchema = z.object({
117
+ mask: z.enum(OVERLAY_IMAGE_MASKS).optional(),
118
+ /** px on the base image — edge fade width */
119
+ feather: z.number().min(0).max(500).optional(),
120
+ stroke: overlayStrokeSchema.optional(),
121
+ glow: z
122
+ .object({
123
+ blur: z.number().min(0).max(200),
124
+ color: hex6,
125
+ opacity: z.number().min(0).max(1),
126
+ })
127
+ .optional(),
128
+ })
129
+ export type OverlayImageEffects = z.infer<typeof overlayImageEffectsSchema>
130
+
131
+ /** Sensible starting points for a freshly added non-image layer. */
132
+ export const DEFAULT_OVERLAY_TEXT: OverlayTextStyle = {
133
+ text: "Your text",
134
+ fontId: "inter",
135
+ fontWeight: 700,
136
+ fontSize: 8,
137
+ color: "#ffffff",
138
+ align: "center",
139
+ letterSpacing: 0,
140
+ lineHeight: 1.15,
141
+ uppercase: false,
142
+ }
143
+ export const DEFAULT_OVERLAY_QR: OverlayQrStyle = { text: "https://nodaro.ai", color: "#000000", background: "#ffffff", margin: 1 }
144
+ export const DEFAULT_OVERLAY_SHAPE: OverlayShapeStyle = { shape: "pill", color: "#ff0073" }
145
+
146
+ /** True when the string carries a right-to-left script (Hebrew / Arabic ranges). */
147
+ export function isRtlText(text: string): boolean {
148
+ return /[֐-׿؀-ۿݐ-ݿࢠ-ࣿיִ-﷿ﹰ-]/.test(text)
149
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Image Overlay — platform canvas presets. Picking one sets the output canvas
3
+ * to the platform's pixel size and draws its SAFE AREA in the preview (the
4
+ * part of the image every surface actually shows — a YouTube banner is
5
+ * cropped to a 1546×423 strip on phones, a LinkedIn personal cover has the
6
+ * profile photo over its bottom-left, an Instagram story hides the top and
7
+ * bottom under the UI). The same ids drive "export also for" — one
8
+ * composition rendered into several platform sizes in one run.
9
+ *
10
+ * Safe areas are FRACTIONS of the canvas (x, y, w, h in 0..1). Sizes are the
11
+ * platforms' documented recommendations as of 2026-09.
12
+ */
13
+ export interface OverlayPlatformPreset {
14
+ readonly id: string
15
+ readonly label: string
16
+ readonly width: number
17
+ readonly height: number
18
+ /** The always-visible region, as fractions of the canvas. Absent = whole canvas. */
19
+ readonly safe?: { readonly x: number; readonly y: number; readonly w: number; readonly h: number }
20
+ /** Per-device viewports drawn as nested boxes (the YouTube banner shows TV /
21
+ * desktop / every device). `safe` is the innermost one. Ids are stable
22
+ * keys the UI localises; absent = only `safe` is drawn. */
23
+ readonly zones?: readonly OverlayPlatformZone[]
24
+ readonly note?: string
25
+ }
26
+
27
+ export interface OverlayPlatformZone {
28
+ readonly id: "tv" | "desktop" | "all"
29
+ readonly x: number
30
+ readonly y: number
31
+ readonly w: number
32
+ readonly h: number
33
+ }
34
+
35
+ const YT_BANNER_ALL = { x: (2560 - 1546) / 2 / 2560, y: (1440 - 423) / 2 / 1440, w: 1546 / 2560, h: 423 / 1440 }
36
+
37
+ export const OVERLAY_PLATFORMS: readonly OverlayPlatformPreset[] = [
38
+ { id: "youtube-thumbnail", label: "YouTube thumbnail", width: 1280, height: 720, safe: { x: 0, y: 0, w: 1, h: 0.86 }, note: "The bottom-right corner carries the duration badge." },
39
+ {
40
+ id: "youtube-banner", label: "YouTube channel banner", width: 2560, height: 1440,
41
+ safe: YT_BANNER_ALL,
42
+ zones: [
43
+ { id: "tv", x: 0, y: 0, w: 1, h: 1 },
44
+ { id: "desktop", x: 0, y: (1440 - 423) / 2 / 1440, w: 1, h: 423 / 1440 },
45
+ { id: "all", ...YT_BANNER_ALL },
46
+ ],
47
+ note: "TV shows the whole banner, desktop a 2560×423 strip, phones only the centre 1546×423.",
48
+ },
49
+ { id: "linkedin-company", label: "LinkedIn company cover", width: 1128, height: 191, safe: { x: 0.18, y: 0, w: 0.82, h: 1 }, note: "The company logo sits over the left ~200px." },
50
+ { id: "linkedin-personal", label: "LinkedIn personal cover", width: 1584, height: 396, safe: { x: 0.24, y: 0, w: 0.76, h: 0.9 }, note: "The profile photo covers the bottom-left." },
51
+ { id: "x-header", label: "X / Twitter header", width: 1500, height: 500, safe: { x: 0.2, y: 0.05, w: 0.78, h: 0.9 }, note: "The profile photo covers the bottom-left." },
52
+ { id: "facebook-cover", label: "Facebook page cover", width: 820, height: 312, safe: { x: 0.05, y: 0.08, w: 0.9, h: 0.84 } },
53
+ { id: "instagram-post", label: "Instagram post (1:1)", width: 1080, height: 1080 },
54
+ { id: "instagram-portrait", label: "Instagram post (4:5)", width: 1080, height: 1350 },
55
+ { id: "instagram-story", label: "Instagram / TikTok story (9:16)", width: 1080, height: 1920, safe: { x: 0, y: 250 / 1920, w: 1, h: (1920 - 500) / 1920 }, note: "The top and bottom 250px sit under the story UI." },
56
+ { id: "open-graph", label: "Link preview (Open Graph)", width: 1200, height: 630 },
57
+ { id: "presentation", label: "Presentation slide (16:9)", width: 1920, height: 1080 },
58
+ { id: "a4-print", label: "Print A4 @300dpi", width: 2480, height: 3508, safe: { x: 0.05, y: 0.05, w: 0.9, h: 0.9 }, note: "5% bleed margin." },
59
+ ] as const
60
+
61
+ export type OverlayPlatformId = (typeof OVERLAY_PLATFORMS)[number]["id"]
62
+ export const OVERLAY_PLATFORM_IDS = OVERLAY_PLATFORMS.map((p) => p.id) as unknown as readonly [OverlayPlatformId, ...OverlayPlatformId[]]
63
+
64
+ export function overlayPlatformById(id: string | undefined | null): OverlayPlatformPreset | undefined {
65
+ return id ? OVERLAY_PLATFORMS.find((p) => p.id === id) : undefined
66
+ }
67
+
68
+ /** How many extra platform renders one run may produce — every platform in
69
+ * the registry, so "all of them" is one run. The node's handle column is
70
+ * sized for this (image-overlay-handles.test.ts pins it). */
71
+ export const OVERLAY_MAX_VARIANTS = OVERLAY_PLATFORMS.length
72
+
73
+ /**
74
+ * Every platform ticked under "export also for" is ALSO a source handle on
75
+ * the node — `variant:<platformId>` — so the X header can feed an X publisher
76
+ * while the YouTube thumbnail feeds YouTube, in one run. The main `image`
77
+ * handle stays the primary composite.
78
+ */
79
+ export const OVERLAY_VARIANT_HANDLE_PREFIX = "variant:"
80
+ export function overlayVariantHandle(platformId: string): string {
81
+ return `${OVERLAY_VARIANT_HANDLE_PREFIX}${platformId}`
82
+ }
83
+ /** The platform id a variant handle names, or null for any other handle. */
84
+ export function overlayVariantIdFromHandle(handle: string | null | undefined): string | null {
85
+ if (!handle || !handle.startsWith(OVERLAY_VARIANT_HANDLE_PREFIX)) return null
86
+ const id = handle.slice(OVERLAY_VARIANT_HANDLE_PREFIX.length)
87
+ return overlayPlatformById(id) ? id : null
88
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Flat-shape vocabulary for Image Overlay shape layers, and the ONE geometry
3
+ * both renderers draw: the server serialises the element to SVG for sharp,
4
+ * the editor renders the same element in an inline <svg> — so the stage shows
5
+ * exactly the shape the run produces. Every shape fills its w × h box.
6
+ */
7
+
8
+ export const OVERLAY_SHAPES = [
9
+ "rect",
10
+ "rounded",
11
+ "pill",
12
+ "circle",
13
+ "ribbon",
14
+ "triangle",
15
+ "diamond",
16
+ "hexagon",
17
+ "star",
18
+ "burst",
19
+ "arrow",
20
+ ] as const
21
+ export type OverlayShape = (typeof OVERLAY_SHAPES)[number]
22
+
23
+ export interface OverlayShapeElement {
24
+ readonly tag: "rect" | "ellipse" | "polygon"
25
+ /** Plain SVG attributes (already valid React props too: rx, cx, points…). */
26
+ readonly attrs: Readonly<Record<string, number | string>>
27
+ }
28
+
29
+ function round(n: number): number {
30
+ return Math.round(n * 100) / 100
31
+ }
32
+
33
+ function points(list: ReadonlyArray<readonly [number, number]>): string {
34
+ return list.map(([x, y]) => `${round(x)},${round(y)}`).join(" ")
35
+ }
36
+
37
+ /** A radial polygon: `n` outer points at (rx, ry), an inner point between each at `inner` × radius. */
38
+ function radial(cx: number, cy: number, rx: number, ry: number, n: number, inner: number): string {
39
+ const pts: Array<readonly [number, number]> = []
40
+ for (let i = 0; i < n * 2; i++) {
41
+ const a = -Math.PI / 2 + (i * Math.PI) / n
42
+ const f = i % 2 === 0 ? 1 : inner
43
+ pts.push([cx + Math.cos(a) * rx * f, cy + Math.sin(a) * ry * f])
44
+ }
45
+ return points(pts)
46
+ }
47
+
48
+ /**
49
+ * The element for `shape` inside a w × h box. `inset` keeps a stroke inside
50
+ * the box (pass half the stroke width); geometry is identical for inset 0.
51
+ */
52
+ export function overlayShapeElement(shape: OverlayShape, w: number, h: number, inset = 0): OverlayShapeElement {
53
+ const i = Math.max(0, inset)
54
+ const iw = Math.max(0, w - 2 * i)
55
+ const ih = Math.max(0, h - 2 * i)
56
+ const cx = w / 2
57
+ const cy = h / 2
58
+ switch (shape) {
59
+ case "rounded":
60
+ return { tag: "rect", attrs: { x: i, y: i, width: iw, height: ih, rx: round(Math.min(iw, ih) * 0.18) } }
61
+ case "pill":
62
+ return { tag: "rect", attrs: { x: i, y: i, width: iw, height: ih, rx: round(ih / 2) } }
63
+ case "circle":
64
+ return { tag: "ellipse", attrs: { cx, cy, rx: round(iw / 2), ry: round(ih / 2) } }
65
+ case "ribbon": {
66
+ // A banner with notched ends — the "50% OFF" strip.
67
+ const notch = Math.min(w / 4, h / 2)
68
+ return { tag: "polygon", attrs: { points: points([[i, i], [w - i, i], [w - notch, cy], [w - i, h - i], [i, h - i], [notch, cy]]) } }
69
+ }
70
+ case "triangle":
71
+ return { tag: "polygon", attrs: { points: points([[cx, i], [w - i, h - i], [i, h - i]]) } }
72
+ case "diamond":
73
+ return { tag: "polygon", attrs: { points: points([[cx, i], [w - i, cy], [cx, h - i], [i, cy]]) } }
74
+ case "hexagon":
75
+ return { tag: "polygon", attrs: { points: points([[w * 0.25, i], [w * 0.75, i], [w - i, cy], [w * 0.75, h - i], [w * 0.25, h - i], [i, cy]]) } }
76
+ case "star":
77
+ return { tag: "polygon", attrs: { points: radial(cx, cy, iw / 2, ih / 2, 5, 0.42) } }
78
+ case "burst":
79
+ return { tag: "polygon", attrs: { points: radial(cx, cy, iw / 2, ih / 2, 14, 0.82) } }
80
+ case "arrow":
81
+ // Right-pointing: a shaft half the box tall, a head on the last 40 %.
82
+ return {
83
+ tag: "polygon",
84
+ attrs: { points: points([[i, h * 0.25], [w * 0.6, h * 0.25], [w * 0.6, i], [w - i, cy], [w * 0.6, h - i], [w * 0.6, h * 0.75], [i, h * 0.75]]) },
85
+ }
86
+ default:
87
+ return { tag: "rect", attrs: { x: i, y: i, width: iw, height: ih } }
88
+ }
89
+ }
package/src/index.ts CHANGED
@@ -1004,6 +1004,9 @@ export * from "./audio-fx-presets.js"
1004
1004
 
1005
1005
  // --- Remotion renderer: supported font names (shared with backend Zod validation) ---
1006
1006
  export * from "./supported-fonts.js"
1007
+ export * from "./image-overlay-layers.js"
1008
+ export * from "./image-overlay-shapes.js"
1009
+ export * from "./image-overlay-platforms.js"
1007
1010
  // --- Shot-sequence visual elements (text/shape/image; shared with backend Zod validation) ---
1008
1011
  export type { ShotElement, ShotTextElement, ShotShapeElement, ShotImageElement } from "./shot-element.js"
1009
1012
  // --- Entity image-handle parity (entity `image` source handle → plain image) ---
@@ -155,6 +155,12 @@ function checkEntities(plan: Scene3DPlanV2, byId: Map<string, Scene3DEntityV2>,
155
155
 
156
156
  const anchorNames = new Set<string>()
157
157
  ;(entity.anchors ?? []).forEach((anchor, anchorIndex) => {
158
+ if (anchor.nodeName !== undefined && entity.visual.kind !== "asset") {
159
+ issues.push({
160
+ path: at("anchors", anchorIndex, "nodeName"),
161
+ message: `entity "${entity.id}" can bind anchor nodes only with an asset visual`,
162
+ })
163
+ }
158
164
  if (anchorNames.has(anchor.name)) {
159
165
  issues.push({
160
166
  path: at("anchors", anchorIndex, "name"),
package/src/scene3d-v2.ts CHANGED
@@ -123,6 +123,7 @@ export const SCENE3D_V2_LIMITS = {
123
123
  maxIdLength: SCENE3D_LIMITS.maxIdLength,
124
124
  maxAssetIdLength: 128,
125
125
  maxNodeIdLength: 128,
126
+ maxNodeNameLength: 256,
126
127
  maxNameLength: SCENE3D_LIMITS.maxNameLength,
127
128
  maxLabelLength: SCENE3D_LIMITS.maxNameLength,
128
129
  maxMaterialNameLength: 120,
@@ -257,13 +258,17 @@ export type Scene3DClayLightingPreset = (typeof SCENE3D_CLAY_LIGHTING_PRESETS)[n
257
258
  // Types
258
259
  // ---------------------------------------------------------------------------
259
260
 
260
- /** A stable contact/selection location in entity-local space. Names are free
261
+ /** A stable contact/selection location in entity-local space, or in the local
262
+ * space of an explicitly bound GLB node. Names are free
261
263
  * structural labels (`face`, `seat`, `wheel.frontLeft`, `roof`, `lookAt`) —
262
264
  * human anatomy is never required. */
263
265
  export interface Scene3DAnchor {
264
266
  name: string
265
267
  position: Vec3
266
- /** Euler XYZ radians. Absent = identity orientation. */
268
+ /** Owned raw GLB node name. When present, position/rotation use that node's
269
+ * local coordinates and follow its animation. Only asset visuals bind nodes. */
270
+ nodeName?: string
271
+ /** Euler XYZ radians in the same space as position. Absent = identity. */
267
272
  rotation?: Vec3
268
273
  }
269
274
 
@@ -447,6 +452,10 @@ export const scene3DNodeIdSchema = z
447
452
  .max(SCENE3D_V2_LIMITS.maxNodeIdLength)
448
453
  .regex(/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/, "node id must be an exporter-generated stable id")
449
454
 
455
+ /** Subparts retain raw GLB names, including separators that a root ID forbids. */
456
+ export const scene3DNodeNameSchema = z.string().min(1).max(SCENE3D_V2_LIMITS.maxNodeNameLength)
457
+ .regex(/^[^\u0000-\u001f\u007f]+$/, "node name must not contain control characters")
458
+
450
459
  export const scene3DSha256Schema = z
451
460
  .string()
452
461
  .regex(/^[0-9a-f]{64}$/, "sha256 must be 64 lowercase hex characters")
@@ -492,6 +501,7 @@ export const scene3DAnchorSchema = z
492
501
  .object({
493
502
  name: scene3DAnchorNameSchema,
494
503
  position: vec3Schema,
504
+ nodeName: scene3DNodeNameSchema.optional(),
495
505
  rotation: rotationVec3Schema.optional(),
496
506
  })
497
507
  .strict()