@nodaro/shared 2.24.0 → 2.26.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 +50 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +59 -310
- package/dist/index.d.ts +59 -310
- package/dist/index.js +48 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/studio-transient.test.ts +205 -0
- package/src/index.ts +6 -8
- package/src/studio-transient.ts +124 -0
- package/src/studio-production-wire.ts +0 -344
package/package.json
CHANGED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
STUDIO_SHOT_TRANSIENT_KEYS,
|
|
5
|
+
STUDIO_TRANSIENT_KEYS,
|
|
6
|
+
stripStudioTransientSettings,
|
|
7
|
+
} from "../studio-transient.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The public projection of `settings.studio` (D12).
|
|
11
|
+
*
|
|
12
|
+
* A shared production is read by anyone holding the link, and the document
|
|
13
|
+
* carries the OWNER's working state beside the film: the recycle bin (every
|
|
14
|
+
* shot, still and clip they deleted, prompts and urls intact), the jobs in
|
|
15
|
+
* flight, and an unsaved editor draft.
|
|
16
|
+
*
|
|
17
|
+
* The fixture below is a real saved document, pinned here byte for byte, and
|
|
18
|
+
* the LEVEL is the whole finding this test exists for: the in-flight markers
|
|
19
|
+
* are written PER SHOT, inside `settings.studio.shots[]`, and a strip that only
|
|
20
|
+
* walks the top level hands a viewer all of them while a fixture shaped to the
|
|
21
|
+
* top level says it does not.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const PENDING = {
|
|
25
|
+
jobId: "job-2",
|
|
26
|
+
provider: "seedance-2",
|
|
27
|
+
prompt: "a slow dolly in",
|
|
28
|
+
startedAt: 1_756_000_000_000,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TRASHED = {
|
|
32
|
+
kind: "still",
|
|
33
|
+
id: "trash-1",
|
|
34
|
+
shotId: "s1",
|
|
35
|
+
index: 0,
|
|
36
|
+
deletedAt: "2026-09-01T10:00:00.000Z",
|
|
37
|
+
stillBase: { nodeId: "img-1", provider: "flux-2", prompt: "a lighthouse at dawn" },
|
|
38
|
+
result: { url: "https://cdn/deleted.png" },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A saved document carrying every marker its writer can write. */
|
|
42
|
+
function written(): Record<string, unknown> {
|
|
43
|
+
return {
|
|
44
|
+
studio: {
|
|
45
|
+
version: 3,
|
|
46
|
+
shots: [
|
|
47
|
+
{
|
|
48
|
+
id: "s1",
|
|
49
|
+
imageNodeId: "img-1",
|
|
50
|
+
stillProvider: "flux-2",
|
|
51
|
+
pendingClips: [{ ...PENDING }],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
selectedShotId: "s1",
|
|
55
|
+
shotOrder: ["img-1"],
|
|
56
|
+
shared: true,
|
|
57
|
+
freecutDraftUrl: "https://cdn/draft.json",
|
|
58
|
+
trash: [{ ...TRASHED }],
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `settings.studio` of a stripped document. */
|
|
64
|
+
function studioOf(settings: unknown): Record<string, unknown> {
|
|
65
|
+
return (settings as { studio: Record<string, unknown> }).studio
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("stripStudioTransientSettings", () => {
|
|
69
|
+
it("drops the bin and the draft the writer put at the top level", () => {
|
|
70
|
+
const settings = written()
|
|
71
|
+
// The oracle: the document really does carry these two here.
|
|
72
|
+
expect(studioOf(settings).trash).toHaveLength(1)
|
|
73
|
+
expect(studioOf(settings).freecutDraftUrl).toBe("https://cdn/draft.json")
|
|
74
|
+
|
|
75
|
+
const studio = studioOf(stripStudioTransientSettings(settings))
|
|
76
|
+
expect(studio.trash).toBeUndefined()
|
|
77
|
+
expect(studio.freecutDraftUrl).toBeUndefined()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("drops the in-flight markers the writer put PER SHOT", () => {
|
|
81
|
+
const settings = written()
|
|
82
|
+
// The oracle again, and the whole point: `pendingClips` is a SHOT's key.
|
|
83
|
+
const stored = (studioOf(settings).shots as Array<Record<string, unknown>>)[0]
|
|
84
|
+
expect(stored.pendingClips).toEqual([PENDING])
|
|
85
|
+
|
|
86
|
+
const shots = studioOf(stripStudioTransientSettings(settings)).shots as Array<
|
|
87
|
+
Record<string, unknown>
|
|
88
|
+
>
|
|
89
|
+
expect(shots[0].pendingClips).toBeUndefined()
|
|
90
|
+
// ...and the shot itself survives, film intact.
|
|
91
|
+
expect(shots[0].id).toBe("s1")
|
|
92
|
+
expect(shots[0].imageNodeId).toBe("img-1")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("leaves the film — the shots, the order, the share flag — untouched", () => {
|
|
96
|
+
const studio = studioOf(stripStudioTransientSettings(written()))
|
|
97
|
+
expect(studio.version).toBe(3)
|
|
98
|
+
expect(studio.shotOrder).toEqual(["img-1"])
|
|
99
|
+
expect(studio.shared).toBe(true)
|
|
100
|
+
expect(studio.selectedShotId).toBe("s1")
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("never mutates the caller's document", () => {
|
|
104
|
+
const settings = written()
|
|
105
|
+
const before = JSON.stringify(settings)
|
|
106
|
+
stripStudioTransientSettings(settings)
|
|
107
|
+
expect(JSON.stringify(settings)).toBe(before)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("hands back the very same object when there is nothing to strip", () => {
|
|
111
|
+
// An ordinary share read of an ordinary production allocates nothing.
|
|
112
|
+
const settings: Record<string, unknown> = {
|
|
113
|
+
studio: { version: 3, shots: [{ id: "s1" }], selectedShotId: "s1", shotOrder: [] },
|
|
114
|
+
}
|
|
115
|
+
expect(stripStudioTransientSettings(settings)).toBe(settings)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("drops a shot's pendingStills — the still marker D5 lands there", () => {
|
|
119
|
+
// `pendingStills` is additive: the generation routes write it onto the same
|
|
120
|
+
// shot entry `pendingClips` rides on, so the strip has to know the key
|
|
121
|
+
// before its writer exists — otherwise the first framing batch in flight
|
|
122
|
+
// ships to every share viewer.
|
|
123
|
+
const settings = written()
|
|
124
|
+
const studio = studioOf(settings)
|
|
125
|
+
const shots = (studio.shots as Array<Record<string, unknown>>).map((s) => ({
|
|
126
|
+
...s,
|
|
127
|
+
pendingStills: [{ jobId: "job-1", batchId: "batch-1", count: 4 }],
|
|
128
|
+
}))
|
|
129
|
+
const withStills = { ...settings, studio: { ...studio, shots } }
|
|
130
|
+
|
|
131
|
+
const out = studioOf(stripStudioTransientSettings(withStills))
|
|
132
|
+
expect((out.shots as Array<Record<string, unknown>>)[0].pendingStills).toBeUndefined()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("drops a legacy single `pendingClip` too", () => {
|
|
136
|
+
// Pre-concurrent-markers saves wrote one marker under the singular key; the
|
|
137
|
+
// reader still migrates it, so it is still in-flight state a viewer must
|
|
138
|
+
// not receive.
|
|
139
|
+
const settings = written()
|
|
140
|
+
const studio = studioOf(settings)
|
|
141
|
+
const shots = [{ id: "s2", pendingClip: PENDING }]
|
|
142
|
+
const legacy = { ...settings, studio: { ...studio, shots } }
|
|
143
|
+
|
|
144
|
+
const out = studioOf(stripStudioTransientSettings(legacy))
|
|
145
|
+
expect((out.shots as Array<Record<string, unknown>>)[0]).toEqual({ id: "s2" })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("leaves a workflow that is not a production alone", () => {
|
|
149
|
+
const settings = { presentationSettings: { shareReadOnly: true } }
|
|
150
|
+
expect(stripStudioTransientSettings(settings)).toBe(settings)
|
|
151
|
+
expect(stripStudioTransientSettings(null)).toBeNull()
|
|
152
|
+
expect(stripStudioTransientSettings(undefined)).toBeUndefined()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("survives a document whose shots are not what the editor writes", () => {
|
|
156
|
+
// The strip runs on whatever is in the column, including a row written by
|
|
157
|
+
// something that is not the studio editor. It must project, never throw.
|
|
158
|
+
const odd = { studio: { version: 3, shots: ["nonsense", null, 7] } }
|
|
159
|
+
expect(() => stripStudioTransientSettings(odd)).not.toThrow()
|
|
160
|
+
expect(studioOf(stripStudioTransientSettings(odd)).shots).toEqual(["nonsense", null, 7])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("drops the DOCUMENT's own pendingMusic and pendingDraft (D5)", () => {
|
|
164
|
+
// The document-level twin of the per-shot pair: a soundtrack render and a
|
|
165
|
+
// Director run in flight. Same rule — they name jobs on the owner's
|
|
166
|
+
// account, and no viewer can read or land one.
|
|
167
|
+
const settings = written()
|
|
168
|
+
const studio = studioOf(settings)
|
|
169
|
+
const inFlight = {
|
|
170
|
+
...settings,
|
|
171
|
+
studio: {
|
|
172
|
+
...studio,
|
|
173
|
+
pendingMusic: { jobId: "job-3", startedAt: 1_756_000_000_000 },
|
|
174
|
+
pendingDraft: { jobId: "job-4", startedAt: 1_756_000_000_000 },
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
// The oracle: the document really does carry both before the public read.
|
|
178
|
+
expect(studioOf(inFlight).pendingMusic).toBeDefined()
|
|
179
|
+
expect(studioOf(inFlight).pendingDraft).toBeDefined()
|
|
180
|
+
|
|
181
|
+
const out = studioOf(stripStudioTransientSettings(inFlight))
|
|
182
|
+
expect(out.pendingMusic).toBeUndefined()
|
|
183
|
+
expect(out.pendingDraft).toBeUndefined()
|
|
184
|
+
// ...and the film is still there.
|
|
185
|
+
expect(out.version).toBe(3)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("pins the two lists — a key added to the type alone strips nothing", () => {
|
|
189
|
+
// The lists are the contract: the codec's own strip re-exports them, so a
|
|
190
|
+
// key that falls off here falls off there too, silently, on both sides.
|
|
191
|
+
expect([...STUDIO_TRANSIENT_KEYS]).toEqual([
|
|
192
|
+
"trash",
|
|
193
|
+
"pendingStills",
|
|
194
|
+
"pendingClips",
|
|
195
|
+
"pendingMusic",
|
|
196
|
+
"pendingDraft",
|
|
197
|
+
"freecutDraftUrl",
|
|
198
|
+
])
|
|
199
|
+
expect([...STUDIO_SHOT_TRANSIENT_KEYS]).toEqual([
|
|
200
|
+
"pendingClips",
|
|
201
|
+
"pendingClip",
|
|
202
|
+
"pendingStills",
|
|
203
|
+
])
|
|
204
|
+
})
|
|
205
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -1052,11 +1052,9 @@ export {
|
|
|
1052
1052
|
export { ENTITY_NODE_KINDS } from "./entity-node-fields.js"
|
|
1053
1053
|
export type { EntityNodeKind } from "./entity-node-fields.js"
|
|
1054
1054
|
|
|
1055
|
-
// ---
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
// are narrowed, and pinned, by the package that owns them.
|
|
1062
|
-
export type * from "./studio-production-wire.js"
|
|
1055
|
+
// --- transient studio keys — the public share read strips them ---
|
|
1056
|
+
export {
|
|
1057
|
+
STUDIO_TRANSIENT_KEYS,
|
|
1058
|
+
STUDIO_SHOT_TRANSIENT_KEYS,
|
|
1059
|
+
stripStudioTransientSettings,
|
|
1060
|
+
} from "./studio-transient.js"
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of `settings.studio` that must not leave the owner's account.
|
|
3
|
+
*
|
|
4
|
+
* A shared production is read by anyone with the link. Three things inside the
|
|
5
|
+
* document are the OWNER'S working state and nobody else's business:
|
|
6
|
+
*
|
|
7
|
+
* - `trash` — the recycle bin, which holds every shot, still and clip they
|
|
8
|
+
* deleted, with prompts and urls intact. A share viewer receiving the bin is
|
|
9
|
+
* the sharpest of the three: it hands out work the owner explicitly threw away.
|
|
10
|
+
* - the in-flight job markers — `pendingClips` / `pendingStills` per shot, and
|
|
11
|
+
* `pendingMusic` / `pendingDraft` on the document. A viewer cannot land any
|
|
12
|
+
* of them and does not own them; all they carry across is job ids.
|
|
13
|
+
* - `freecutDraftUrl` — an unsaved editor draft.
|
|
14
|
+
*
|
|
15
|
+
* They do NOT all live at the same level, and that is the whole reason this
|
|
16
|
+
* file exists rather than one array: the writer puts `trash` and
|
|
17
|
+
* `freecutDraftUrl` on `settings.studio` itself, and puts the per-shot markers
|
|
18
|
+
* on the `settings.studio.shots[]` entry. A strip that walked only the top
|
|
19
|
+
* level would pass its own test and still hand a share viewer every marker in
|
|
20
|
+
* the production.
|
|
21
|
+
*
|
|
22
|
+
* It lives in `@nodaro/shared` because two independent readers need the SAME
|
|
23
|
+
* list: the public share read (which is the reason the list exists) and the
|
|
24
|
+
* production writer's own bundle projection. A second copy of a list like this
|
|
25
|
+
* does not stay equal — it goes one key stale and the stale side is the one
|
|
26
|
+
* that publishes.
|
|
27
|
+
*
|
|
28
|
+
* This is a plain JSON walker on purpose. `settings` is a free-form column that
|
|
29
|
+
* a client owns end to end; the projection reads the keys it must drop and
|
|
30
|
+
* nothing else, so it never needs — and must never grow — a dependency on
|
|
31
|
+
* whatever writes the rest of the document.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `settings.studio`'s OWN transient keys.
|
|
36
|
+
*
|
|
37
|
+
* The per-shot pending lists are on this list as well as the shot one on
|
|
38
|
+
* purpose: nothing writes them here today, and a stray one from an older
|
|
39
|
+
* client — or from a client that is not the studio editor at all — still must
|
|
40
|
+
* not ride out to a viewer.
|
|
41
|
+
*/
|
|
42
|
+
export const STUDIO_TRANSIENT_KEYS = [
|
|
43
|
+
"trash",
|
|
44
|
+
"pendingStills",
|
|
45
|
+
"pendingClips",
|
|
46
|
+
// The two markers that genuinely DO live at this level: a soundtrack render
|
|
47
|
+
// and a story-planning run in flight. Same rule as the per-shot pair — they
|
|
48
|
+
// name jobs on the owner's account and nobody else can land them.
|
|
49
|
+
"pendingMusic",
|
|
50
|
+
"pendingDraft",
|
|
51
|
+
"freecutDraftUrl",
|
|
52
|
+
] as const
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* ...and a SHOT entry's, which is where the per-shot markers actually are.
|
|
56
|
+
*
|
|
57
|
+
* `pendingClip` (singular) is the pre-concurrent-markers shape; the editor's
|
|
58
|
+
* reader still migrates it on parse, so a row can still be carrying one and it
|
|
59
|
+
* is still in-flight state.
|
|
60
|
+
*/
|
|
61
|
+
export const STUDIO_SHOT_TRANSIENT_KEYS = ["pendingClips", "pendingClip", "pendingStills"] as const
|
|
62
|
+
|
|
63
|
+
function withoutKeys(
|
|
64
|
+
source: Record<string, unknown>,
|
|
65
|
+
drop: ReadonlyArray<string>,
|
|
66
|
+
): Record<string, unknown> {
|
|
67
|
+
const kept: Record<string, unknown> = {}
|
|
68
|
+
for (const [key, value] of Object.entries(source)) {
|
|
69
|
+
if (drop.includes(key)) continue
|
|
70
|
+
kept[key] = value
|
|
71
|
+
}
|
|
72
|
+
return kept
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `settings.studio.shots` with every shot's in-flight markers removed.
|
|
77
|
+
*
|
|
78
|
+
* Returns the SAME array when no shot carried one, so an idle production's
|
|
79
|
+
* share read allocates nothing. Anything that is not a shot-shaped object rides
|
|
80
|
+
* through untouched: this runs on whatever is in the column, and a projection
|
|
81
|
+
* that threw on an unexpected row would take the share read down with it.
|
|
82
|
+
*/
|
|
83
|
+
function stripShots(value: unknown): unknown {
|
|
84
|
+
if (!Array.isArray(value)) return value
|
|
85
|
+
let changed = false
|
|
86
|
+
const out = value.map((entry) => {
|
|
87
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry
|
|
88
|
+
const shot = entry as Record<string, unknown>
|
|
89
|
+
if (!STUDIO_SHOT_TRANSIENT_KEYS.some((key) => key in shot)) return entry
|
|
90
|
+
changed = true
|
|
91
|
+
return withoutKeys(shot, STUDIO_SHOT_TRANSIENT_KEYS)
|
|
92
|
+
})
|
|
93
|
+
return changed ? out : value
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A production's `settings` with the owner's working state removed.
|
|
98
|
+
*
|
|
99
|
+
* Copy-on-write, and structurally: it rebuilds the objects without those keys
|
|
100
|
+
* rather than deleting from the caller's, so the stored row is untouched. A
|
|
101
|
+
* `settings` with no `studio` comes back unchanged — this is a studio concern,
|
|
102
|
+
* and a workflow that is not a production has nothing here to strip.
|
|
103
|
+
*
|
|
104
|
+
* Takes and returns `unknown` because the column is free-form and every caller
|
|
105
|
+
* already holds it as whatever its own layer calls JSON; narrowing here would
|
|
106
|
+
* only move the cast one line up.
|
|
107
|
+
*/
|
|
108
|
+
export function stripStudioTransientSettings(settings: unknown): unknown {
|
|
109
|
+
if (!settings || typeof settings !== "object") return settings
|
|
110
|
+
const studio = (settings as { studio?: unknown }).studio
|
|
111
|
+
if (!studio || typeof studio !== "object" || Array.isArray(studio)) return settings
|
|
112
|
+
|
|
113
|
+
const source = studio as Record<string, unknown>
|
|
114
|
+
const kept = withoutKeys(source, STUDIO_TRANSIENT_KEYS)
|
|
115
|
+
const shots = stripShots(source.shots)
|
|
116
|
+
if (source.shots !== undefined) kept.shots = shots
|
|
117
|
+
|
|
118
|
+
// Nothing to drop at either level — hand back the original object so an
|
|
119
|
+
// ordinary share read allocates nothing.
|
|
120
|
+
if (Object.keys(kept).length === Object.keys(source).length && shots === source.shots) {
|
|
121
|
+
return settings
|
|
122
|
+
}
|
|
123
|
+
return { ...(settings as Record<string, unknown>), studio: kept }
|
|
124
|
+
}
|