@hraness/slopcamera 3.8.1 → 3.9.1
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/NOTICE.md +14 -0
- package/README.md +13 -6
- package/apps/desktop/application/html-overlay-integrity.ts +56 -6
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/capability-manifest.ts +15 -2
- package/apps/desktop/cli/commands.ts +1 -1
- package/apps/desktop/cli/help.ts +23 -4
- package/apps/desktop/cli/html-overlay-renderer.ts +23 -9
- package/apps/desktop/cli/portable-surface.ts +1 -0
- package/apps/desktop/cli/verbs.ts +4 -0
- package/apps/desktop/dist/cli/main.js +306 -284
- package/dist/cli.js +24 -4
- package/dist/{index-6jrxaser.js → index-jmyg6t9c.js} +4 -4
- package/dist/{index-842t02pp.js → index-m6g0ca9b.js} +1 -1
- package/dist/{index-y8spxs77.js → index-mgychzh3.js} +6 -3
- package/dist/index.js +1 -1
- package/dist/operations.js +1 -1
- package/dist/workflow.js +1 -1
- package/package.json +4 -2
- package/skills/slopcamera/SKILL.md +2 -0
- package/skills/slopcamera/references/customization.md +8 -1
- package/skills/slopcamera/references/icon-place.md +76 -0
- package/skills/slopcamera/references/install.md +2 -2
- package/skills/slopcamera/references/music-video.md +10 -2
- package/skills/slopcamera/references/soundtracks.md +58 -0
- package/src/bounded-file.ts +135 -0
- package/src/cli.ts +191 -0
- package/src/icon-scene.ts +695 -0
- package/src/index.ts +2 -0
- package/src/mcp/tools.ts +311 -0
- package/src/mcp/types.ts +4 -0
- package/src/operations.ts +306 -0
- package/src/soundtrack.ts +489 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { beatGrid, importMidi } from "@hraness/soundfish/midi"
|
|
2
|
+
import { compose, verify } from "@hraness/soundfish/protocol"
|
|
3
|
+
import { applyTemplate } from "@hraness/soundfish/templates"
|
|
4
|
+
import {
|
|
5
|
+
BoundedFileError,
|
|
6
|
+
decodeUtf8Source,
|
|
7
|
+
hasPathCollision,
|
|
8
|
+
publishReplaceableFile,
|
|
9
|
+
readBoundedFile,
|
|
10
|
+
sha256Hex,
|
|
11
|
+
} from "./bounded-file.js"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Soundtrack scores and beat grids behind the `slopcamera.soundtrack.compose`
|
|
15
|
+
* and `slopcamera.soundtrack.grid` operations.
|
|
16
|
+
*
|
|
17
|
+
* Parsing, verification, MIDI import and beat-grid derivation come from the
|
|
18
|
+
* exact-version `@hraness/soundfish` library, called in process. Nothing is
|
|
19
|
+
* spawned, fetched, rendered to audio or evaluated. Slopcamera owns the
|
|
20
|
+
* operation codes, bounds, file publication and receipts, and projects the
|
|
21
|
+
* grid into the `{ bpm, beatOffsetUs, beatsPerBar }` music timing that HTML
|
|
22
|
+
* scene requests already accept.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The exact admitted library release. A test pins this to the lockfile. */
|
|
26
|
+
export const slopcameraSoundtrackEngine = Object.freeze({
|
|
27
|
+
package: "@hraness/soundfish",
|
|
28
|
+
version: "0.7.0",
|
|
29
|
+
} as const)
|
|
30
|
+
|
|
31
|
+
export const slopcameraSoundtrackLimits = Object.freeze({
|
|
32
|
+
sourceBytes: 1024 * 1024,
|
|
33
|
+
outputBytes: 4 * 1024 * 1024,
|
|
34
|
+
sections: 512,
|
|
35
|
+
warnings: 64,
|
|
36
|
+
/** Matches the HTML scene music clock's time and tempo bounds. */
|
|
37
|
+
maxTimeUs: 3_600_000_000,
|
|
38
|
+
minBpm: 20,
|
|
39
|
+
maxBpm: 400,
|
|
40
|
+
minBeatsPerBar: 1,
|
|
41
|
+
maxBeatsPerBar: 32,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export const slopcameraSoundtrackFormats = Object.freeze(
|
|
45
|
+
["compose", "song", "json", "midi"] as const,
|
|
46
|
+
)
|
|
47
|
+
export type SlopcameraSoundtrackFormat = (typeof slopcameraSoundtrackFormats)[number]
|
|
48
|
+
|
|
49
|
+
export class SlopcameraSoundtrackError extends Error {
|
|
50
|
+
readonly code: "INVALID_SOUNDTRACK_SOURCE" | "INVALID_SOUNDTRACK_INPUT" | "SOUNDTRACK_OUT_OF_RANGE"
|
|
51
|
+
|
|
52
|
+
constructor(code: SlopcameraSoundtrackError["code"], message: string) {
|
|
53
|
+
super(message)
|
|
54
|
+
this.name = "SlopcameraSoundtrackError"
|
|
55
|
+
this.code = code
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SlopcameraSoundtrackFileRecord {
|
|
60
|
+
readonly path: string
|
|
61
|
+
readonly sha256: string
|
|
62
|
+
readonly bytes: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SlopcameraSoundtrackSourceRecord extends SlopcameraSoundtrackFileRecord {
|
|
66
|
+
readonly format: SlopcameraSoundtrackFormat
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SlopcameraSoundtrackWarning {
|
|
70
|
+
readonly code: string
|
|
71
|
+
readonly message: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Exactly the explicit music timing HTML scene requests accept. */
|
|
75
|
+
export interface SlopcameraSoundtrackMusicTiming {
|
|
76
|
+
readonly bpm: number
|
|
77
|
+
readonly beatOffsetUs: number
|
|
78
|
+
readonly beatsPerBar: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SlopcameraSoundtrackCue {
|
|
82
|
+
/** 1-based play order; a repeated section appears once per repeat. */
|
|
83
|
+
readonly index: number
|
|
84
|
+
readonly label: string
|
|
85
|
+
/** 1-based repeat of that section. */
|
|
86
|
+
readonly repeat: number
|
|
87
|
+
readonly startBar: number
|
|
88
|
+
readonly endBar: number
|
|
89
|
+
readonly startBeat: number
|
|
90
|
+
readonly endBeat: number
|
|
91
|
+
readonly startUs: number
|
|
92
|
+
readonly endUs: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SlopcameraSoundtrackComposeInput {
|
|
96
|
+
readonly sourcePath: string
|
|
97
|
+
readonly outputPath?: string
|
|
98
|
+
readonly format?: SlopcameraSoundtrackFormat
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface SlopcameraSoundtrackComposeReceipt {
|
|
102
|
+
readonly receiptVersion: 1
|
|
103
|
+
readonly operation: "slopcamera.soundtrack.compose"
|
|
104
|
+
readonly engine: typeof slopcameraSoundtrackEngine
|
|
105
|
+
readonly source: SlopcameraSoundtrackSourceRecord
|
|
106
|
+
readonly kind: "loop" | "song"
|
|
107
|
+
readonly title: string
|
|
108
|
+
/** The library's canonical document digest. */
|
|
109
|
+
readonly digest: string
|
|
110
|
+
/** The loop's music CID or the song's arrangement CID. */
|
|
111
|
+
readonly contentId: string
|
|
112
|
+
readonly bpm: number
|
|
113
|
+
readonly beatsPerBar: number
|
|
114
|
+
readonly bars: number
|
|
115
|
+
readonly durationUs: number
|
|
116
|
+
/** Loop tracks, or null for a song. */
|
|
117
|
+
readonly tracks: number | null
|
|
118
|
+
/** Song sections and distinct loops, or null for a loop. */
|
|
119
|
+
readonly sections: number | null
|
|
120
|
+
readonly loops: number | null
|
|
121
|
+
readonly warnings: readonly SlopcameraSoundtrackWarning[]
|
|
122
|
+
/** The canonical score document, written only when an output path was supplied. */
|
|
123
|
+
readonly output: SlopcameraSoundtrackFileRecord | null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface SlopcameraSoundtrackGridInput {
|
|
127
|
+
readonly sourcePath: string
|
|
128
|
+
readonly outputPath?: string
|
|
129
|
+
readonly format?: SlopcameraSoundtrackFormat
|
|
130
|
+
/** Where the soundtrack's first beat lands on the video timeline. */
|
|
131
|
+
readonly startUs?: number
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface SlopcameraSoundtrackGrid {
|
|
135
|
+
readonly schemaVersion: 1
|
|
136
|
+
readonly kind: "slopcamera.soundtrack-grid"
|
|
137
|
+
readonly digest: string
|
|
138
|
+
readonly title: string
|
|
139
|
+
readonly music: SlopcameraSoundtrackMusicTiming
|
|
140
|
+
readonly beatUnit: number
|
|
141
|
+
/** 0–100. Swing delays off-beat sixteenths only; beats and bars stay on the grid. */
|
|
142
|
+
readonly swing: number
|
|
143
|
+
readonly bars: number
|
|
144
|
+
readonly beats: number
|
|
145
|
+
readonly startUs: number
|
|
146
|
+
readonly durationUs: number
|
|
147
|
+
readonly endUs: number
|
|
148
|
+
readonly sections: readonly SlopcameraSoundtrackCue[]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface SlopcameraSoundtrackGridReceipt extends Omit<SlopcameraSoundtrackGrid, "kind" | "schemaVersion"> {
|
|
152
|
+
readonly receiptVersion: 1
|
|
153
|
+
readonly operation: "slopcamera.soundtrack.grid"
|
|
154
|
+
readonly engine: typeof slopcameraSoundtrackEngine
|
|
155
|
+
readonly source: SlopcameraSoundtrackSourceRecord
|
|
156
|
+
readonly scoreKind: "loop" | "song"
|
|
157
|
+
readonly warnings: readonly SlopcameraSoundtrackWarning[]
|
|
158
|
+
/** The grid document, written only when an output path was supplied. */
|
|
159
|
+
readonly output: SlopcameraSoundtrackFileRecord | null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The beat-grid fields the projection reads; structurally the library's grid. */
|
|
163
|
+
export interface SlopcameraSoundtrackBeatGrid {
|
|
164
|
+
readonly title: string
|
|
165
|
+
readonly bpm: number
|
|
166
|
+
readonly beatsPerBar: number
|
|
167
|
+
readonly beatUnit: number
|
|
168
|
+
readonly swing: number
|
|
169
|
+
readonly secondsPerBeat: number
|
|
170
|
+
readonly bars: number
|
|
171
|
+
readonly beats: number
|
|
172
|
+
readonly sections: readonly {
|
|
173
|
+
readonly index: number
|
|
174
|
+
readonly label: string
|
|
175
|
+
readonly repeat: number
|
|
176
|
+
readonly startBar: number
|
|
177
|
+
readonly endBar: number
|
|
178
|
+
readonly startBeat: number
|
|
179
|
+
readonly endBeat: number
|
|
180
|
+
}[]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
type BeatGrid = SlopcameraSoundtrackBeatGrid
|
|
184
|
+
|
|
185
|
+
interface LoadedScore {
|
|
186
|
+
readonly format: SlopcameraSoundtrackFormat
|
|
187
|
+
readonly document: Record<string, unknown>
|
|
188
|
+
readonly warnings: readonly SlopcameraSoundtrackWarning[]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type LibraryFailure = { readonly ok: false; readonly error: { readonly code: string; readonly message: string } }
|
|
192
|
+
|
|
193
|
+
function invalidSource(message: string): never {
|
|
194
|
+
throw new SlopcameraSoundtrackError("INVALID_SOUNDTRACK_SOURCE", message)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function invalidInput(message: string): never {
|
|
198
|
+
throw new SlopcameraSoundtrackError("INVALID_SOUNDTRACK_INPUT", message)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function printable(value: string, limit = 240): string {
|
|
202
|
+
const text = [...value]
|
|
203
|
+
.filter((character) => {
|
|
204
|
+
const code = character.codePointAt(0) ?? 0
|
|
205
|
+
return code >= 0x20 && code !== 0x7f
|
|
206
|
+
})
|
|
207
|
+
.join("")
|
|
208
|
+
return text.length > limit ? `${text.slice(0, limit - 3)}...` : text
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function libraryFailure(result: LibraryFailure, action: string): never {
|
|
212
|
+
return invalidSource(`${action}: ${printable(result.error.code, 64)}: ${printable(result.error.message)}`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
216
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Standard MIDI files open with the `MThd` header chunk. */
|
|
220
|
+
function isMidi(bytes: Uint8Array): boolean {
|
|
221
|
+
return bytes.length >= 4 && bytes[0] === 0x4d && bytes[1] === 0x54 && bytes[2] === 0x68 && bytes[3] === 0x64
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function detectSlopcameraSoundtrackFormat(bytes: Uint8Array): SlopcameraSoundtrackFormat {
|
|
225
|
+
if (isMidi(bytes)) return "midi"
|
|
226
|
+
const text = decodeUtf8Source(bytes, "Soundtrack source")
|
|
227
|
+
if (text.trimStart().startsWith("{")) return "json"
|
|
228
|
+
return /^[ \t]*section[ \t]/mu.test(text) ? "song" : "compose"
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function guarded<T>(work: () => Promise<T>, action: string): Promise<T> {
|
|
232
|
+
try {
|
|
233
|
+
return await work()
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error instanceof SlopcameraSoundtrackError || error instanceof BoundedFileError) throw error
|
|
236
|
+
return invalidSource(`${action}: ${printable(error instanceof Error ? error.message : String(error))}`)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function loadScore(
|
|
241
|
+
bytes: Uint8Array,
|
|
242
|
+
requested: SlopcameraSoundtrackFormat | undefined,
|
|
243
|
+
): Promise<LoadedScore> {
|
|
244
|
+
const format = requested ?? detectSlopcameraSoundtrackFormat(bytes)
|
|
245
|
+
if (format === "midi") {
|
|
246
|
+
if (!isMidi(bytes)) invalidSource("A MIDI source must be a Standard MIDI file.")
|
|
247
|
+
const result = await guarded(async () => await importMidi(bytes.slice()), "MIDI import failed")
|
|
248
|
+
if (!result.ok) libraryFailure(result, "MIDI import failed")
|
|
249
|
+
return {
|
|
250
|
+
format,
|
|
251
|
+
document: result.document,
|
|
252
|
+
warnings: result.warnings.map((warning) => ({
|
|
253
|
+
code: printable(warning.code, 64),
|
|
254
|
+
message: printable(warning.message),
|
|
255
|
+
})),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const text = decodeUtf8Source(bytes, "Soundtrack source")
|
|
259
|
+
if (format === "json") {
|
|
260
|
+
let value: unknown
|
|
261
|
+
try {
|
|
262
|
+
value = JSON.parse(text)
|
|
263
|
+
} catch {
|
|
264
|
+
invalidSource("Soundtrack JSON is not valid JSON.")
|
|
265
|
+
}
|
|
266
|
+
if (!isRecord(value)) invalidSource("Soundtrack JSON must be an object.")
|
|
267
|
+
return { format, document: value, warnings: [] }
|
|
268
|
+
}
|
|
269
|
+
if (format === "song") {
|
|
270
|
+
// A song source is template text supplied as the only template, so the
|
|
271
|
+
// bundled template set is never read.
|
|
272
|
+
const result = await guarded(
|
|
273
|
+
async () => await applyTemplate("songs/source", { sources: [{ kind: "songs", name: "source", text }] }),
|
|
274
|
+
"Song compose failed",
|
|
275
|
+
)
|
|
276
|
+
if (!result.ok) libraryFailure(result, "Song compose failed")
|
|
277
|
+
if (!("document" in result)) invalidSource("Song compose returned no document.")
|
|
278
|
+
return {
|
|
279
|
+
format,
|
|
280
|
+
document: result.document,
|
|
281
|
+
warnings: result.warnings.map((message) => ({ code: "song", message: printable(message) })),
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const result = await guarded(async () => await compose(text), "Compose failed")
|
|
285
|
+
if (!result.ok) libraryFailure(result, "Compose failed")
|
|
286
|
+
return { format, document: result.document, warnings: [] }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function readScore(
|
|
290
|
+
path: string,
|
|
291
|
+
format: SlopcameraSoundtrackFormat | undefined,
|
|
292
|
+
): Promise<{ readonly score: LoadedScore; readonly source: SlopcameraSoundtrackSourceRecord }> {
|
|
293
|
+
if (format !== undefined && !slopcameraSoundtrackFormats.includes(format)) {
|
|
294
|
+
invalidInput(`format must be one of ${slopcameraSoundtrackFormats.join(", ")}.`)
|
|
295
|
+
}
|
|
296
|
+
const bytes = await readBoundedFile(path, slopcameraSoundtrackLimits.sourceBytes, "Soundtrack source")
|
|
297
|
+
const score = await loadScore(bytes, format)
|
|
298
|
+
return {
|
|
299
|
+
score,
|
|
300
|
+
source: { path, sha256: sha256Hex(bytes), bytes: bytes.byteLength, format: score.format },
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function gridOf(document: Record<string, unknown>): Promise<BeatGrid> {
|
|
305
|
+
const grid = await guarded(async () => await beatGrid(document), "Beat grid failed")
|
|
306
|
+
if (!grid.ok) libraryFailure(grid, "Beat grid failed")
|
|
307
|
+
return grid
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function outOfRange(message: string): never {
|
|
311
|
+
throw new SlopcameraSoundtrackError("SOUNDTRACK_OUT_OF_RANGE", message)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Microseconds from beat zero to `beat` at a constant tempo. */
|
|
315
|
+
export function soundtrackBeatTimeUs(beat: number, bpm: number): number {
|
|
316
|
+
return Math.round((beat * 60_000_000) / bpm)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function checkedTiming(grid: BeatGrid): { readonly bpm: number; readonly beatsPerBar: number } {
|
|
320
|
+
const limits = slopcameraSoundtrackLimits
|
|
321
|
+
if (!Number.isFinite(grid.bpm) || grid.bpm < limits.minBpm || grid.bpm > limits.maxBpm) {
|
|
322
|
+
outOfRange(`Tempo ${String(grid.bpm)} BPM is outside ${String(limits.minBpm)}–${String(limits.maxBpm)} BPM.`)
|
|
323
|
+
}
|
|
324
|
+
if (
|
|
325
|
+
!Number.isSafeInteger(grid.beatsPerBar)
|
|
326
|
+
|| grid.beatsPerBar < limits.minBeatsPerBar
|
|
327
|
+
|| grid.beatsPerBar > limits.maxBeatsPerBar
|
|
328
|
+
) {
|
|
329
|
+
outOfRange(`Meter of ${String(grid.beatsPerBar)} beats per bar is outside ${String(limits.minBeatsPerBar)}–${String(limits.maxBeatsPerBar)}.`)
|
|
330
|
+
}
|
|
331
|
+
// The library rounds seconds per beat to microseconds; a larger gap means
|
|
332
|
+
// its beat is not the tempo's beat and the projection would drift.
|
|
333
|
+
if (!Number.isFinite(grid.secondsPerBeat) || Math.abs(grid.secondsPerBeat - 60 / grid.bpm) > 1e-6) {
|
|
334
|
+
outOfRange("The beat grid's beat length disagrees with its tempo.")
|
|
335
|
+
}
|
|
336
|
+
return { bpm: grid.bpm, beatsPerBar: grid.beatsPerBar }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function durationUsOf(grid: BeatGrid, bpm: number): number {
|
|
340
|
+
if (!Number.isSafeInteger(grid.beats) || grid.beats < 1) outOfRange("The score has no beats.")
|
|
341
|
+
const durationUs = soundtrackBeatTimeUs(grid.beats, bpm)
|
|
342
|
+
if (durationUs > slopcameraSoundtrackLimits.maxTimeUs) {
|
|
343
|
+
outOfRange("The score is longer than the one-hour timeline bound.")
|
|
344
|
+
}
|
|
345
|
+
return durationUs
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function publishJson(path: string, value: unknown): Promise<SlopcameraSoundtrackFileRecord> {
|
|
349
|
+
const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8")
|
|
350
|
+
if (bytes.byteLength > slopcameraSoundtrackLimits.outputBytes) {
|
|
351
|
+
outOfRange("Soundtrack output exceeds the output byte limit.")
|
|
352
|
+
}
|
|
353
|
+
await publishReplaceableFile(path, bytes)
|
|
354
|
+
return { path, sha256: sha256Hex(bytes), bytes: bytes.byteLength }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function checkOutputPath(sourcePath: string, outputPath: string | undefined): Promise<void> {
|
|
358
|
+
if (outputPath === undefined) return
|
|
359
|
+
if (!outputPath.toLowerCase().endsWith(".json")) invalidInput("outputPath must end in .json.")
|
|
360
|
+
if (await hasPathCollision([sourcePath, outputPath])) invalidInput("outputPath must differ from sourcePath.")
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Parse and verify one loop or song from compose text, song text, Soundfish
|
|
365
|
+
* JSON or a Standard MIDI file. With an output path, publish the verified
|
|
366
|
+
* lossless score document; without one, write nothing.
|
|
367
|
+
*/
|
|
368
|
+
export async function composeSlopcameraSoundtrack(
|
|
369
|
+
input: SlopcameraSoundtrackComposeInput,
|
|
370
|
+
): Promise<SlopcameraSoundtrackComposeReceipt> {
|
|
371
|
+
await checkOutputPath(input.sourcePath, input.outputPath)
|
|
372
|
+
const { score, source } = await readScore(input.sourcePath, input.format)
|
|
373
|
+
const verified = await guarded(async () => await verify(score.document), "Verification failed")
|
|
374
|
+
if (!verified.ok) libraryFailure(verified, "Verification failed")
|
|
375
|
+
const grid = await gridOf(score.document)
|
|
376
|
+
const { bpm, beatsPerBar } = checkedTiming(grid)
|
|
377
|
+
const durationUs = durationUsOf(grid, bpm)
|
|
378
|
+
const output = input.outputPath === undefined ? null : await publishJson(input.outputPath, score.document)
|
|
379
|
+
return {
|
|
380
|
+
receiptVersion: 1,
|
|
381
|
+
operation: "slopcamera.soundtrack.compose",
|
|
382
|
+
engine: slopcameraSoundtrackEngine,
|
|
383
|
+
source,
|
|
384
|
+
kind: verified.kind,
|
|
385
|
+
title: printable(grid.title, 256),
|
|
386
|
+
digest: verified.digest,
|
|
387
|
+
contentId: verified.kind === "loop" ? verified.musicCid : verified.arrangementCid,
|
|
388
|
+
bpm,
|
|
389
|
+
beatsPerBar,
|
|
390
|
+
bars: grid.bars,
|
|
391
|
+
durationUs,
|
|
392
|
+
tracks: verified.kind === "loop" ? verified.tracks : null,
|
|
393
|
+
sections: verified.kind === "song" ? verified.sections : null,
|
|
394
|
+
loops: verified.kind === "song" ? verified.loops : null,
|
|
395
|
+
warnings: score.warnings.slice(0, slopcameraSoundtrackLimits.warnings),
|
|
396
|
+
output,
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Project a score's tempo, meter and sections onto the video timeline. Beat
|
|
402
|
+
* zero is the soundtrack's first beat at `startUs`, so `music` drops straight
|
|
403
|
+
* into an HTML scene request and every cue lands on a beat of that clock.
|
|
404
|
+
*/
|
|
405
|
+
export function projectSlopcameraSoundtrackGrid(
|
|
406
|
+
grid: BeatGrid,
|
|
407
|
+
digest: string,
|
|
408
|
+
startUs = 0,
|
|
409
|
+
): SlopcameraSoundtrackGrid {
|
|
410
|
+
if (!Number.isSafeInteger(startUs) || startUs < 0 || startUs > slopcameraSoundtrackLimits.maxTimeUs) {
|
|
411
|
+
invalidInput(`startUs must be an integer from 0 through ${String(slopcameraSoundtrackLimits.maxTimeUs)}.`)
|
|
412
|
+
}
|
|
413
|
+
const { bpm, beatsPerBar } = checkedTiming(grid)
|
|
414
|
+
const durationUs = durationUsOf(grid, bpm)
|
|
415
|
+
if (startUs + durationUs > slopcameraSoundtrackLimits.maxTimeUs) {
|
|
416
|
+
outOfRange("startUs plus the score duration exceeds the one-hour timeline bound.")
|
|
417
|
+
}
|
|
418
|
+
if (grid.sections.length === 0 || grid.sections.length > slopcameraSoundtrackLimits.sections) {
|
|
419
|
+
outOfRange(`A score must have 1–${String(slopcameraSoundtrackLimits.sections)} sections in play order.`)
|
|
420
|
+
}
|
|
421
|
+
let previousEndBeat = 0
|
|
422
|
+
const sections = grid.sections.map((section) => {
|
|
423
|
+
if (
|
|
424
|
+
!Number.isSafeInteger(section.startBeat)
|
|
425
|
+
|| !Number.isSafeInteger(section.endBeat)
|
|
426
|
+
|| section.startBeat !== previousEndBeat
|
|
427
|
+
|| section.endBeat <= section.startBeat
|
|
428
|
+
|| section.endBeat > grid.beats
|
|
429
|
+
) {
|
|
430
|
+
outOfRange("Beat grid sections must tile the score in play order.")
|
|
431
|
+
}
|
|
432
|
+
previousEndBeat = section.endBeat
|
|
433
|
+
return {
|
|
434
|
+
index: section.index,
|
|
435
|
+
label: printable(section.label, 128),
|
|
436
|
+
repeat: section.repeat,
|
|
437
|
+
startBar: section.startBar,
|
|
438
|
+
endBar: section.endBar,
|
|
439
|
+
startBeat: section.startBeat,
|
|
440
|
+
endBeat: section.endBeat,
|
|
441
|
+
startUs: startUs + soundtrackBeatTimeUs(section.startBeat, bpm),
|
|
442
|
+
endUs: startUs + soundtrackBeatTimeUs(section.endBeat, bpm),
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
if (previousEndBeat !== grid.beats) outOfRange("Beat grid sections must cover the whole score.")
|
|
446
|
+
return {
|
|
447
|
+
schemaVersion: 1,
|
|
448
|
+
kind: "slopcamera.soundtrack-grid",
|
|
449
|
+
digest,
|
|
450
|
+
title: printable(grid.title, 256),
|
|
451
|
+
music: { bpm, beatOffsetUs: startUs, beatsPerBar },
|
|
452
|
+
beatUnit: grid.beatUnit,
|
|
453
|
+
swing: grid.swing,
|
|
454
|
+
bars: grid.bars,
|
|
455
|
+
beats: grid.beats,
|
|
456
|
+
startUs,
|
|
457
|
+
durationUs,
|
|
458
|
+
endUs: startUs + durationUs,
|
|
459
|
+
sections,
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Derive music timing and section cues from a score. With an output path,
|
|
465
|
+
* publish the grid document; without one, write nothing.
|
|
466
|
+
*/
|
|
467
|
+
export async function deriveSlopcameraSoundtrackGrid(
|
|
468
|
+
input: SlopcameraSoundtrackGridInput,
|
|
469
|
+
): Promise<SlopcameraSoundtrackGridReceipt> {
|
|
470
|
+
await checkOutputPath(input.sourcePath, input.outputPath)
|
|
471
|
+
const startUs = input.startUs ?? 0
|
|
472
|
+
const { score, source } = await readScore(input.sourcePath, input.format)
|
|
473
|
+
const verified = await guarded(async () => await verify(score.document), "Verification failed")
|
|
474
|
+
if (!verified.ok) libraryFailure(verified, "Verification failed")
|
|
475
|
+
const grid = await gridOf(score.document)
|
|
476
|
+
const projected = projectSlopcameraSoundtrackGrid(grid, verified.digest, startUs)
|
|
477
|
+
const output = input.outputPath === undefined ? null : await publishJson(input.outputPath, projected)
|
|
478
|
+
const { kind: _kind, schemaVersion: _schemaVersion, ...fields } = projected
|
|
479
|
+
return {
|
|
480
|
+
receiptVersion: 1,
|
|
481
|
+
operation: "slopcamera.soundtrack.grid",
|
|
482
|
+
engine: slopcameraSoundtrackEngine,
|
|
483
|
+
source,
|
|
484
|
+
scoreKind: verified.kind,
|
|
485
|
+
...fields,
|
|
486
|
+
warnings: score.warnings.slice(0, slopcameraSoundtrackLimits.warnings),
|
|
487
|
+
output,
|
|
488
|
+
}
|
|
489
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SLOPCAMERA_VERSION = "3.
|
|
1
|
+
export const SLOPCAMERA_VERSION = "3.9.1" as const
|