@nodaro/shared 3.4.0 → 3.6.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/dist/index.cjs +259 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +318 -4
- package/dist/index.d.ts +318 -4
- package/dist/index.js +231 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/image-overlay-credits.test.ts +24 -0
- package/src/__tests__/scene3d-v2-resources.test.ts +3 -0
- package/src/__tests__/scene3d-v2.test.ts +27 -0
- package/src/credit-estimators/image-overlay.ts +30 -0
- package/src/credit-estimators/index.ts +7 -0
- package/src/image-overlay-layers.ts +149 -0
- package/src/image-overlay-platforms.ts +88 -0
- package/src/image-overlay-shapes.ts +89 -0
- package/src/index.ts +3 -0
- package/src/scene3d-v2-plan.ts +6 -0
- package/src/scene3d-v2.ts +13 -3
package/package.json
CHANGED
|
@@ -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
|
+
})
|
|
@@ -199,6 +199,9 @@ describe("scene3d v2 resources — content hash", () => {
|
|
|
199
199
|
|
|
200
200
|
it("changes when any content changes", async () => {
|
|
201
201
|
const base = await computeScene3DPlanV2ContentHash(planV2())
|
|
202
|
+
const shadowed = planV2()
|
|
203
|
+
shadowed.lighting = { ...shadowed.lighting, preset: "clay-studio-v2" }
|
|
204
|
+
expect(await computeScene3DPlanV2ContentHash(shadowed)).not.toBe(base)
|
|
202
205
|
const recoloured = planV2()
|
|
203
206
|
recoloured.objects[1].materialBindings = [
|
|
204
207
|
{ role: "bodyPaint", materialName: "Body Paint", color: "#00ff00" },
|
|
@@ -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 = {
|
|
@@ -730,6 +754,9 @@ describe("scene3d v2 — references and engines", () => {
|
|
|
730
754
|
})
|
|
731
755
|
|
|
732
756
|
it("pins the clay lighting preset", () => {
|
|
757
|
+
for (const preset of ["clay-studio-v1", "clay-studio-v2"] as const) {
|
|
758
|
+
expect(scene3DPlanV2Schema.safeParse(planV2({ lighting: { ...planV2().lighting, preset } })).success).toBe(true)
|
|
759
|
+
}
|
|
733
760
|
expect(scene3DPlanV2Schema.safeParse(planV2({ lighting: { ...planV2().lighting, preset: "neon" as never } })).success).toBe(
|
|
734
761
|
false,
|
|
735
762
|
)
|
|
@@ -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) ---
|
package/src/scene3d-v2-plan.ts
CHANGED
|
@@ -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,
|
|
@@ -250,20 +251,24 @@ export const SCENE3D_PRIMITIVE_MATERIAL_ROLE = "identity"
|
|
|
250
251
|
|
|
251
252
|
/** The standardized clay look, pinned by id. Browser preview, critic stills and
|
|
252
253
|
* the final export must implement a given preset identically. */
|
|
253
|
-
export const SCENE3D_CLAY_LIGHTING_PRESETS = ["clay-studio-v1"] as const
|
|
254
|
+
export const SCENE3D_CLAY_LIGHTING_PRESETS = ["clay-studio-v1", "clay-studio-v2"] as const
|
|
254
255
|
export type Scene3DClayLightingPreset = (typeof SCENE3D_CLAY_LIGHTING_PRESETS)[number]
|
|
255
256
|
|
|
256
257
|
// ---------------------------------------------------------------------------
|
|
257
258
|
// Types
|
|
258
259
|
// ---------------------------------------------------------------------------
|
|
259
260
|
|
|
260
|
-
/** A stable contact/selection location in entity-local space
|
|
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
|
-
/**
|
|
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()
|