@nodaro/shared 2.8.0 → 2.10.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.
@@ -0,0 +1,152 @@
1
+ import { z } from "zod"
2
+
3
+ /**
4
+ * Organizations — wire contract for the second tenancy axis
5
+ * (Organization -> Workspace -> Member).
6
+ *
7
+ * Lives in @nodaro/shared because SDK/MCP consumers need these enums, the
8
+ * request schemas the API validates, and the error codes it returns. This
9
+ * package carries the CONTRACT ONLY — no resolution logic, no presets, no
10
+ * vocabulary, no access rule. Those are server-side.
11
+ */
12
+
13
+ /**
14
+ * Selects which workspace a request LISTS from and CREATES into. It never
15
+ * authorizes: reading, updating, deleting or running an identified object is
16
+ * decided by that object's own workspace, so a forgotten or forged header can
17
+ * neither widen access nor move a charge.
18
+ *
19
+ * Fastify lower-cases incoming header keys, hence the second constant — read
20
+ * `req.headers[WORKSPACE_HEADER_LOWER]`, send `WORKSPACE_HEADER`.
21
+ */
22
+ export const WORKSPACE_HEADER = "X-Nodaro-Workspace"
23
+ export const WORKSPACE_HEADER_LOWER = "x-nodaro-workspace"
24
+
25
+ export const ORG_KINDS = ["school", "team"] as const
26
+ export type OrgKind = (typeof ORG_KINDS)[number]
27
+
28
+ export const ORG_ROLES = ["owner", "admin", "member"] as const
29
+ export type OrgRole = (typeof ORG_ROLES)[number]
30
+
31
+ export const WORKSPACE_ROLES = ["admin", "member"] as const
32
+ export type WorkspaceRole = (typeof WORKSPACE_ROLES)[number]
33
+
34
+ export const MEMBER_STATUSES = ["active", "suspended"] as const
35
+ export type MemberStatus = (typeof MEMBER_STATUSES)[number]
36
+
37
+ /** `pending` = created, awaiting platform-admin approval. */
38
+ export const ORG_STATUSES = ["pending", "active", "suspended", "deleted"] as const
39
+ export type OrgStatus = (typeof ORG_STATUSES)[number]
40
+
41
+ /** An explicit per-workflow grant (works for personal workflows too). */
42
+ export const COLLABORATOR_ROLES = ["editor", "viewer"] as const
43
+ export type CollaboratorRole = (typeof COLLABORATOR_ROLES)[number]
44
+
45
+ export const WORKFLOW_VISIBILITIES = ["private", "workspace"] as const
46
+ export type WorkflowVisibility = (typeof WORKFLOW_VISIBILITIES)[number]
47
+
48
+ /** What an identity may do with a workflow, strongest first. */
49
+ export const ACCESS_LEVELS = ["own", "edit", "view", "none"] as const
50
+ export type AccessLevel = (typeof ACCESS_LEVELS)[number]
51
+
52
+ /** The access a setting may grant to a non-creator. */
53
+ export const GRANTED_ACCESS = ["view", "edit"] as const
54
+ export type GrantedAccess = (typeof GRANTED_ACCESS)[number]
55
+
56
+ export const SUBMISSION_STATUSES = ["submitted", "in_review", "returned", "approved"] as const
57
+ export type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number]
58
+
59
+ /**
60
+ * Error codes the organization endpoints add to the standard envelope
61
+ * (`{ error: { code, message } }`). Clients dispatch on the code, never on
62
+ * the message text.
63
+ */
64
+ export const ORG_ERROR_CODES = [
65
+ "not_a_member",
66
+ "insufficient_role",
67
+ "org_not_active",
68
+ "member_suspended",
69
+ "workspace_archived",
70
+ "personal_space_disabled",
71
+ "token_workspace_mismatch",
72
+ "run_requires_authenticated_member",
73
+ "budget_exceeded",
74
+ "member_cap_exceeded",
75
+ "model_not_allowed",
76
+ "invitation_expired",
77
+ "invitation_revoked",
78
+ "email_mismatch",
79
+ "join_code_invalid",
80
+ "domain_not_allowed",
81
+ "already_started",
82
+ "collab_unavailable",
83
+ // Organization, workspace and membership endpoints.
84
+ "terms_required",
85
+ "not_org_member",
86
+ "already_a_member",
87
+ "owner_cannot_leave",
88
+ "has_active_workspaces",
89
+ // Invitations and join codes.
90
+ "invitation_not_found",
91
+ "invitation_accepted",
92
+ "bulk_invite_cap_exceeded",
93
+ ] as const
94
+ export type OrgErrorCode = (typeof ORG_ERROR_CODES)[number]
95
+
96
+ /**
97
+ * The settings every organization kind has a default for. `organizations.
98
+ * settings` and `workspaces.settings` store PARTIAL overrides of this shape;
99
+ * `resolveEffectiveSettings` (./settings.ts) produces the full one.
100
+ */
101
+ export const PresetSettingsSchema = z.object({
102
+ /** What org/workspace admins may do with a member's workflow. */
103
+ admin_access: z.enum(GRANTED_ACCESS),
104
+ default_workflow_visibility: z.enum(WORKFLOW_VISIBILITIES),
105
+ /** What a plain member may do with a `visibility = workspace` workflow. */
106
+ member_access_to_shared: z.enum(GRANTED_ACCESS),
107
+ members_can_create_projects: z.boolean(),
108
+ member_caps_enabled: z.boolean(),
109
+ /** Whether members keep a personal (non-workspace) space at all. */
110
+ personal_space_enabled: z.boolean(),
111
+ /** Whether a workspace admin may invite NEW people into the org. */
112
+ workspace_admins_can_invite: z.boolean(),
113
+ /** Whether an editor collaborator may invite further collaborators. */
114
+ collaborators_can_invite: z.boolean(),
115
+ /**
116
+ * When the organization is SUSPENDED, do its content rules still bind its
117
+ * members?
118
+ *
119
+ * Today this governs exactly one rule — `personal_space_enabled` — and that
120
+ * is not an accident: every other key above governs behaviour INSIDE a
121
+ * workspace, and a suspended organization grants no workspace context at
122
+ * all, so those are already moot.
123
+ *
124
+ * The name is general because an organization is deciding a principle here,
125
+ * not one checkbox's fate. Default `false`, which is today's behaviour: a
126
+ * suspended organization stops binding, and its members work independently
127
+ * until it resumes. An organization whose reason for disabling the personal
128
+ * space is contractual — the work made here belongs to the institution —
129
+ * turns this on, because an unpaid invoice does not void a contract.
130
+ */
131
+ policy_survives_suspension: z.boolean(),
132
+ })
133
+ export type PresetSettings = z.infer<typeof PresetSettingsSchema>
134
+ export type PresetSettingKey = keyof PresetSettings
135
+ export const PRESET_SETTING_KEYS = Object.freeze(
136
+ Object.keys(PresetSettingsSchema.shape) as readonly PresetSettingKey[],
137
+ )
138
+
139
+ /** `workspaces.settings` — per-workspace overrides only. */
140
+ export const WorkspaceSettingsSchema = PresetSettingsSchema.partial()
141
+ export type WorkspaceSettings = z.infer<typeof WorkspaceSettingsSchema>
142
+
143
+ const EMAIL_DOMAIN_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/
144
+
145
+ /** `organizations.settings` — preset overrides plus org-only keys. */
146
+ export const OrgSettingsSchema = PresetSettingsSchema.partial().extend({
147
+ /** Lower-case domains that join codes / domain auto-join accept. Empty = any. */
148
+ allowed_email_domains: z.array(z.string().regex(EMAIL_DOMAIN_PATTERN)).optional(),
149
+ /** Per-org relabelling of the kind vocabulary (./vocabulary.ts). */
150
+ vocabulary_overrides: z.record(z.string(), z.string()).optional(),
151
+ })
152
+ export type OrgSettings = z.infer<typeof OrgSettingsSchema>
@@ -0,0 +1,220 @@
1
+ import type {
2
+ MemberStatus,
3
+ OrgKind,
4
+ OrgRole,
5
+ OrgSettings,
6
+ OrgStatus,
7
+ WorkspaceRole,
8
+ WorkspaceSettings,
9
+ } from "./types.js"
10
+
11
+ /**
12
+ * What the organization endpoints RETURN.
13
+ *
14
+ * `types.ts` carries what a client must send and the codes it must dispatch
15
+ * on; this carries the other half of the same wire contract — the shapes that
16
+ * come back. It lives here for the same reason: the SDK, the CLI, the app and
17
+ * any third-party integration all read these, and a shape described in three
18
+ * places is a shape that drifts in two of them.
19
+ *
20
+ * CONTRACT ONLY, like its sibling. There is no resolution logic here, no
21
+ * access rule, no vocabulary — a view names fields, it does not decide who
22
+ * may see them. Fields the server omits for a caller without the standing to
23
+ * see them are OPTIONAL here rather than nullable: absent means "not for
24
+ * you", `null` means "genuinely unset", and a client that cannot tell those
25
+ * apart will render the wrong thing.
26
+ */
27
+
28
+ export interface OrganizationView {
29
+ id: string
30
+ slug: string
31
+ name: string
32
+ kind: OrgKind
33
+ status: OrgStatus
34
+ ownerUserId: string
35
+ settings: OrgSettings
36
+ termsAcceptedAt: string | null
37
+ createdAt: string
38
+ updatedAt: string
39
+ /** The CALLER's role. Absent on a read that did not establish membership. */
40
+ role?: OrgRole
41
+ memberStatus?: MemberStatus
42
+ }
43
+
44
+ export interface OrgMemberView {
45
+ userId: string
46
+ role: OrgRole
47
+ status: MemberStatus
48
+ joinedAt: string
49
+ email: string | null
50
+ displayName: string | null
51
+ avatarUrl: string | null
52
+ }
53
+
54
+ export interface WorkspaceView {
55
+ id: string
56
+ orgId: string
57
+ name: string
58
+ slug: string
59
+ description: string | null
60
+ settings: WorkspaceSettings
61
+ defaultProjectId: string | null
62
+ archived: boolean
63
+ archivedAt: string | null
64
+ createdAt: string
65
+ updatedAt: string
66
+ /** The CALLER's role. Absent on a read that did not establish membership. */
67
+ role?: WorkspaceRole
68
+ memberStatus?: MemberStatus
69
+ }
70
+
71
+ export interface WorkspaceMemberView {
72
+ userId: string
73
+ role: WorkspaceRole
74
+ displayName: string | null
75
+ avatarUrl: string | null
76
+ addedAt: string
77
+ /** Workspace admins only — absent for a plain member's read. */
78
+ status?: MemberStatus
79
+ creditCap?: number | null
80
+ }
81
+
82
+ /** Where an invitation stands. `expired` is derived from `expiresAt`, not stored. */
83
+ export type InvitationState = "open" | "accepted" | "revoked" | "expired"
84
+
85
+ export interface InvitationView {
86
+ id: string
87
+ orgId: string
88
+ workspaceId: string | null
89
+ email: string
90
+ orgRole: OrgRole
91
+ workspaceRole: WorkspaceRole | null
92
+ invitedBy: string | null
93
+ state: InvitationState
94
+ expiresAt: string
95
+ acceptedAt: string | null
96
+ revokedAt: string | null
97
+ createdAt: string
98
+ }
99
+
100
+ /**
101
+ * One row per address a create/resend was asked for.
102
+ *
103
+ * `link` is present whenever the address was NOT emailed — an install with no
104
+ * mail provider, or a delivery that failed. A client MUST surface it: the
105
+ * invitation exists either way, and without the link nobody can reach it.
106
+ */
107
+ export interface InvitationDelivery {
108
+ email: string
109
+ status: "sent" | "link_only" | "failed"
110
+ link?: string
111
+ }
112
+
113
+ /**
114
+ * What an invitee sees BEFORE signing in — the one organization read that
115
+ * needs no token. `email` comes back masked, so the invitee can recognise
116
+ * their own address without the link disclosing it to whoever holds it.
117
+ */
118
+ export interface InvitationPreview {
119
+ orgName: string
120
+ kind: OrgKind
121
+ vocabulary: Record<string, string>
122
+ inviterName: string | null
123
+ workspaceName: string | null
124
+ email: string
125
+ expiresAt: string
126
+ state: InvitationState
127
+ }
128
+
129
+ export interface JoinCodeView {
130
+ code: string
131
+ enabled: boolean
132
+ rotatedAt: string
133
+ rotatedBy: string | null
134
+ }
135
+
136
+ /**
137
+ * What `GET /v1/me` reports about the caller's memberships.
138
+ *
139
+ * Deliberately a SUMMARY, not the full views above: this is the payload every
140
+ * client loads on every session start, and it answers one question — what am
141
+ * I a member of, and what may I call each thing. Names, roles, and the
142
+ * resolved vocabulary are here because a switcher cannot render without them;
143
+ * descriptions, timestamps and default projects are not, because a switcher
144
+ * never shows them and `GET /v1/orgs/:id` exists.
145
+ *
146
+ * The settings block is narrowed to the three keys a CLIENT can act on. The
147
+ * rest of an organization's settings are enforced server-side, and shipping
148
+ * them here would invite a client to enforce them badly.
149
+ */
150
+ export interface OrganizationSummary {
151
+ id: string
152
+ slug: string
153
+ name: string
154
+ kind: OrgKind
155
+ status: OrgStatus
156
+ /** The caller's own role and standing — always present in this payload. */
157
+ role: OrgRole
158
+ memberStatus: MemberStatus
159
+ settings: {
160
+ personal_space_enabled: boolean
161
+ allowed_email_domains: string[]
162
+ vocabulary_overrides: Record<string, string>
163
+ }
164
+ /** Resolved labels, so no client hard-codes "Class" or "Team". */
165
+ vocabulary: Record<string, string>
166
+ }
167
+
168
+ export interface WorkspaceSummary {
169
+ id: string
170
+ orgId: string
171
+ name: string
172
+ slug: string
173
+ role: WorkspaceRole
174
+ memberStatus: MemberStatus
175
+ archived: boolean
176
+ }
177
+
178
+ /**
179
+ * The organizations block on `GET /v1/me`.
180
+ *
181
+ * THREE distinct states, and a client that collapses them is wrong in a way
182
+ * users feel: the fields ABSENT means this install has no organizations at
183
+ * all; present and empty means the account belongs to none; and
184
+ * `organizationsUnavailable` means the lookup FAILED — in which case a
185
+ * client must KEEP whatever selection it already had, because telling someone
186
+ * their school vanished during a cache blip is worse than a stale switcher.
187
+ */
188
+ export interface MeOrganizations {
189
+ organizations?: OrganizationSummary[]
190
+ workspaces?: WorkspaceSummary[]
191
+ lastWorkspaceId?: string | null
192
+ organizationsUnavailable?: boolean
193
+ }
194
+
195
+ /**
196
+ * One recorded action in an organization's audit log.
197
+ *
198
+ * `action` is an OPEN vocabulary and a client must not exhaust it: new
199
+ * actions are added as the product grows, and a switch that throws on an
200
+ * unknown one turns a new feature into a broken page. Render what you
201
+ * recognise, fall back to the raw string for the rest.
202
+ *
203
+ * `actor` is null for anything the system did on nobody's behalf.
204
+ */
205
+ export interface OrgAuditEntry {
206
+ id: string
207
+ workspaceId: string | null
208
+ action: string
209
+ targetType: string | null
210
+ targetId: string | null
211
+ details: Record<string, unknown>
212
+ createdAt: string
213
+ actor: { userId: string; displayName: string | null; email: string | null } | null
214
+ }
215
+
216
+ /** A cursor-paged read. The cursor is part of the answer, not a side channel. */
217
+ export interface OrgPage<T> {
218
+ data: T[]
219
+ nextCursor: string | null
220
+ }
@@ -79,6 +79,12 @@ export const VIDEO_PRODUCER_TYPES: ReadonlySet<string> = new Set([
79
79
  "cinematic-avatar",
80
80
  // Assemble Narrated Video: fits N (clip, voice) blocks into one MP4 → video.
81
81
  "assemble-narrated-video",
82
+ // Still to Video: one still image + one audio track → MP4 (local FFmpeg,
83
+ // no provider). Emits generatedVideoUrl like every other ffmpeg video node.
84
+ "still-to-video",
85
+ // Slideshow: 2-100 stills + one optional audio track → MP4 (local FFmpeg,
86
+ // no provider). Same contract; images arrive via the image-collage lane.
87
+ "slideshow",
82
88
  ])
83
89
 
84
90
  /**
@@ -1,19 +1,12 @@
1
1
  /**
2
- * Smart-cut best-pair SEARCH WINDOWS — the shared bound + clamp for
2
+ * Smart-cut SEARCH WINDOWS — the shared bound + clamp for
3
3
  * generate-video-pro's `smartCutFramesPrev` / `smartCutFramesNext`.
4
4
  *
5
- * What they do: the best-pair matcher (the mode the node calls "legacy-8x8")
6
- * PSNR-compares the last N frames of a segment against the first M of the
7
- * next, ends the previous clip ON the best match and starts the next right
8
- * AFTER its twin so the duplicated frame plays once and motion stays
9
- * continuous. N and M are those windows. Absent the engine's own 8/8
10
- * default, which is byte-identical to the behavior before they were
11
- * exposed.
12
- *
13
- * Why wider helps: a continuation can re-enact a longer stretch of the
14
- * previous tail than 8 frames covers, and a match outside the window is
15
- * simply never found — the boundary silently falls back to the fixed
16
- * freeze-trims. recast pins 24/24 for exactly this reason.
5
+ * They bound how much of each side of a boundary the engine considers when
6
+ * it places the cut: N frames from the end of a segment and M from the start
7
+ * of the next. Absent the engine's own default, byte-identical to the
8
+ * behavior before they were exposed. A boundary the engine cannot resolve
9
+ * inside the window falls back to the fixed freeze-trims; recast pins 24/24.
17
10
  *
18
11
  * Why a shared clamp: the canvas node (single-node Run) and the orchestrator
19
12
  * (workflow Run) are two independent send paths into the same engine route,
@@ -23,10 +16,8 @@
23
16
  * and the two paths cannot drift apart.
24
17
  */
25
18
 
26
- /** Widest window the UI offers. The engine route itself accepts up to 48;
27
- * 24 is the product cap it already covers a full second of re-enactment
28
- * at 24fps, and every frame added past the real overlap only costs match
29
- * time and invites a spurious pairing. */
19
+ /** Widest window the UI offers (the engine route itself accepts up to 48).
20
+ * Past this, added frames only cost search time and invite a false match. */
30
21
  export const SMART_CUT_WINDOW_MAX = 24
31
22
  /** Narrowest meaningful window — one frame each side. */
32
23
  export const SMART_CUT_WINDOW_MIN = 1
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Node types whose output carries Suno chaining ids (`sunoTrackId` /
3
+ * `sunoTaskId`) for a downstream Suno node (extend / separate / replace /
4
+ * add-vocals / …) to chain off.
5
+ *
6
+ * One set for the three readers — the canvas resolver, the orchestrator
7
+ * resolver, and the config panels' "Inherited" hint (#819). They used to keep
8
+ * their own copies and drifted: the canvas read ids off a `suno-separate`
9
+ * (whose output is stems, not a track) while the orchestrator ignored it, so
10
+ * the same graph resolved on one path and not the other. Structural
11
+ * vocabulary only — node type names, no prompt content.
12
+ */
13
+ export const SUNO_TRACK_SOURCE_TYPES: ReadonlySet<string> = new Set([
14
+ "suno-generate",
15
+ "suno-cover",
16
+ "suno-extend",
17
+ "suno-mashup",
18
+ "suno-replace-section",
19
+ "suno-add-instrumental",
20
+ "suno-add-vocals",
21
+ "suno-convert-wav",
22
+ "suno-upload-extend",
23
+ ])
package/src/surround.ts CHANGED
@@ -1,48 +1,25 @@
1
1
  /**
2
- * Surround continuation — shared single source of truth.
2
+ * Surround continuation — the shared WIRE CONTRACT.
3
3
  *
4
4
  * The Location 360° "look-around" builds each ring view (45°, 90°, …) as an
5
- * image-to-image continuation of the previous view. The platform forces
6
- * geometric continuity by handing the model a half-done frame: one edge holds
7
- * the previous view's carried pixels, the rest is flat gray, and the model is
8
- * asked to paint the gray region.
5
+ * image-to-image continuation of the previous one.
9
6
  *
10
- * This module owns the bits that are pure and reused across the route Zod
11
- * schema, the SDK input type, and the worker: the direction enum, the carried
12
- * fraction defaults, and the fill prompt. The geometry math + sharp compositing
13
- * + color harmonization live backend-side (they need `sharp`).
7
+ * This module owns only what the route Zod schema, the SDK input type, and the
8
+ * worker all need to agree on: the direction enum and the carried-fraction
9
+ * defaults. The fill prompt lives in `@nodaro/prompts` (never published) and
10
+ * the compositing/harmonization engine is private.
14
11
  */
15
12
 
16
13
  /**
17
- * The carry/paint axis for a continuation.
18
- *
19
- * PAN (horizontal — half-carry continuation):
20
- * - `right` — turning right: the new frame's LEFT edge continues the previous
21
- * view's RIGHT edge, so the carried band sits on the LEFT, painted on the RIGHT.
22
- * - `left` — turning left: the new frame's RIGHT edge continues the previous
23
- * view's LEFT edge, so the carried band sits on the RIGHT, painted on the LEFT.
24
- * (Mirror of `right`. Lets studio chain BOTH ways from a keyframe, capping
25
- * chain depth so quality doesn't compound down a long one-way chain.)
26
- *
27
- * TILT (vertical — thin-strip, subject-driven re-render):
28
- * - `up` — tilting straight up: render the open SKY overhead. A thin strip of
29
- * the establishing shot's TOP edge is carried into the new frame's BOTTOM for
30
- * a soft horizon transition; the rest is painted as sky (NOT a mirrored
31
- * landscape).
32
- * - `down` — tilting straight down: render the GROUND below. A thin strip of the
33
- * BOTTOM edge is carried into the new frame's TOP.
14
+ * The camera move a continuation represents: `right` / `left` pan the view
15
+ * horizontally, `up` / `down` tilt it vertically.
34
16
  */
35
17
  export const SURROUND_DIRECTIONS = ["right", "left", "up", "down"] as const
36
18
  export type SurroundDirection = (typeof SURROUND_DIRECTIONS)[number]
37
19
 
38
- /** Half the frame is carried for a horizontal pan (matches studio's composite). */
20
+ /** Default carried fraction for a horizontal pan. */
39
21
  export const DEFAULT_CARRIED_FRACTION = 0.5
40
- /**
41
- * Tilts carry only a thin horizon strip. Carrying half of a horizontal frame is
42
- * exactly what makes the model echo/mirror the landscape vertically instead of
43
- * rendering what's actually overhead/underfoot — so tilts keep the carry small
44
- * and let the tilt prompt drive the subject.
45
- */
22
+ /** Default carried fraction for a vertical tilt. */
46
23
  export const TILT_CARRIED_FRACTION = 0.12
47
24
 
48
25
  /** True for the vertical tilt directions (up/down), false for the pans. */
@@ -54,60 +31,3 @@ export function isTiltDirection(direction: SurroundDirection): boolean {
54
31
  export function defaultCarriedFraction(direction: SurroundDirection): number {
55
32
  return isTiltDirection(direction) ? TILT_CARRIED_FRACTION : DEFAULT_CARRIED_FRACTION
56
33
  }
57
-
58
- /** Which edge of the NEW frame holds the carried pixels vs the painted region. */
59
- const EDGE: Record<SurroundDirection, { carried: string; painted: string }> = {
60
- right: { carried: "left", painted: "right" },
61
- left: { carried: "right", painted: "left" },
62
- up: { carried: "bottom", painted: "top" },
63
- down: { carried: "top", painted: "bottom" },
64
- }
65
-
66
- /** What a tilt must actually render (NOT a continuation of the landscape). */
67
- const TILT_SUBJECT: Record<"up" | "down", { word: string; subject: string; where: string }> = {
68
- up: {
69
- word: "up",
70
- subject: "the open sky directly overhead — sky, clouds, or (for an interior) the canopy or ceiling",
71
- where: "overhead",
72
- },
73
- down: {
74
- word: "down",
75
- subject: "the ground directly below — terrain, floor, or water surface",
76
- where: "below",
77
- },
78
- }
79
-
80
- /**
81
- * Build the fill prompt the model receives alongside the half-carry composite.
82
- *
83
- * `userPrompt` (an optional scene hint from the caller) is woven in front. PAN
84
- * directions get the seamless-continuation prompt (with the anti-golden-hour
85
- * negative that fights the documented warm-regrade drift). TILT directions get a
86
- * subject-forcing prompt — render the sky / ground overhead / below, explicitly
87
- * NOT a mirrored landscape — which is what stops the vertical echo.
88
- */
89
- export function buildSurroundFillPrompt(direction: SurroundDirection, userPrompt?: string): string {
90
- const scene = userPrompt && userPrompt.trim() ? `${userPrompt.trim()}. ` : ""
91
- const { carried, painted } = EDGE[direction]
92
-
93
- if (direction === "up" || direction === "down") {
94
- const t = TILT_SUBJECT[direction]
95
- return (
96
- `${scene}` +
97
- `This is a camera tilted straight ${t.word} from the same scene. The ${carried} strip holds real, finished pixels from the edge of the horizon view; the ${painted} region is flat gray and MUST be painted as ${t.subject}. ` +
98
- `Render what is genuinely ${t.where} — do NOT repeat, mirror, or continue the landscape, and do NOT draw a horizon line or distant scenery in the painted region. ` +
99
- `CRITICAL: keep the ${carried} strip unchanged and match the scene's EXACT lighting, time of day, white balance, and color grade — the same light as the ${carried} strip; no golden hour, no sunset, no warm relight, no cinematic regrade. ` +
100
- `Blend smoothly into the ${carried} strip with no visible seam. No people, no text, no labels, no watermarks.`
101
- )
102
- }
103
-
104
- // pan (right / left)
105
- return (
106
- `${scene}` +
107
- `This is a partial frame: the ${carried} portion contains real, finished pixels and the ${painted} portion is flat gray that MUST be painted in. ` +
108
- `Paint ONLY the ${painted} gray region as a natural, seamless continuation of the ${carried} portion — same scene, same perspective, continuing the horizon, geometry, and content across the boundary with no break. ` +
109
- `Keep the ${carried} portion completely unchanged. ` +
110
- `CRITICAL: do NOT change the lighting, exposure, white balance, or time of day. Match the ${carried} portion's EXACT light, color temperature, and contrast across the whole frame — if it is flat overcast daylight, keep flat overcast daylight. No golden hour, no sunset, no warm relight, no cinematic regrade. ` +
111
- `The seam between the ${carried} and ${painted} portions must be invisible. No people, no text, no labels, no watermarks.`
112
- )
113
- }