@nodaro/prompts 1.26.1 → 1.28.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.
Files changed (40) hide show
  1. package/dist/index.cjs +3127 -92
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1776 -10
  4. package/dist/index.d.ts +1776 -10
  5. package/dist/index.js +3094 -93
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/fixtures/parameter-hint-golden.json +33 -1
  9. package/src/__tests__/hint-join.test.ts +64 -1
  10. package/src/__tests__/motion-hint.test.ts +20 -0
  11. package/src/__tests__/parameter-registry-sync.test.ts +1 -0
  12. package/src/__tests__/person-mixed-heritage.test.ts +43 -0
  13. package/src/__tests__/picker-art-images.test.ts +191 -0
  14. package/src/__tests__/resolve-prompt.test.ts +13 -1
  15. package/src/__tests__/transition-options.test.ts +223 -0
  16. package/src/__tests__/transition-styles.test.ts +202 -0
  17. package/src/__tests__/transitions-instant.test.ts +9 -5
  18. package/src/__tests__/transitions-scope.test.ts +593 -8
  19. package/src/__tests__/video-own-motion.test.ts +26 -0
  20. package/src/catalog-sections.ts +24 -0
  21. package/src/hint-join.ts +33 -1
  22. package/src/index.ts +4 -0
  23. package/src/parameter-prompt-hint.ts +20 -3
  24. package/src/person.ts +24 -8
  25. package/src/picker-art/character-art-files.generated.ts +1118 -0
  26. package/src/picker-art/images.ts +83 -0
  27. package/src/picker-art/index.ts +13 -0
  28. package/src/picker-art/look-preview-sets.ts +489 -0
  29. package/src/picker-art/paths.ts +53 -0
  30. package/src/picker-art/sound-art-files.generated.ts +319 -0
  31. package/src/picker-art/sound-art-map.ts +722 -0
  32. package/src/picker-art/sound-art-types.ts +19 -0
  33. package/src/picker-catalogs.ts +125 -24
  34. package/src/prompt-builder.ts +2 -1
  35. package/src/prompt-hint-join.ts +8 -2
  36. package/src/prompt-style-section.ts +3 -5
  37. package/src/resolve-prompt.ts +22 -4
  38. package/src/styling.ts +18 -0
  39. package/src/transitions.ts +320 -51
  40. package/src/video-own-motion.ts +29 -0
@@ -20,9 +20,12 @@ const INSTANT_IDS = TRANSITIONS.filter((t) => t.instant).map((t) => t.id)
20
20
  const NON_INSTANT_IDS = TRANSITIONS.filter((t) => !t.instant && t.id !== "auto").map((t) => t.id)
21
21
  const FULL_CLAUSE = "the transition spans the entire clip"
22
22
  const FULL_SHOT_CLAUSE = "the transition spans this entire shot"
23
- const MATCH_CUT_BASE =
24
- "match cut (the final composition of the first shot matches the opening composition of the second shot " +
25
- "in shape, color, and motion, so the cut feels like a visual rhyme; " + INSTANT_CUT_CLAUSE + ")"
23
+ const MATCH_CUT_BODY =
24
+ "the last picture of the first shot and the first picture of the second share one shape, at the same " +
25
+ "place and the same size in the frame. The camera holds that shape in place across the cut. On the ne" +
26
+ "xt frame everything around the shape has changed while the shape itself stays put. The shot ends on " +
27
+ "the second shot, fully resolved, with no flash frame or zoom between the two"
28
+ const MATCH_CUT_BASE = `match cut (${MATCH_CUT_BODY}; ${INSTANT_CUT_CLAUSE})`
26
29
 
27
30
  describe("approved row bodies", () => {
28
31
  it("aging", () => {
@@ -57,11 +60,8 @@ describe("approved row bodies", () => {
57
60
  )
58
61
  })
59
62
 
60
- it("zoom-into-mouth drops only 'the throat'", () => {
61
- expect(getTransitionPromptHint("zoom-into-mouth")).toBe(
62
- "the camera pushes into the subject's open mouth, the dark interior fills the frame, and the camera passes " +
63
- "through into the new scene which materialises as if emerging from inside the body",
64
- )
63
+ it("zoom-into-mouth never mentions 'the throat' (its body is pinned in the F5 block below)", () => {
64
+ expect(getTransitionPromptHint("zoom-into-mouth")).not.toContain("throat")
65
65
  })
66
66
  })
67
67
 
@@ -184,6 +184,591 @@ describe("F2 + vortex bodies (2026-09-25 A/B: F2 arm B, vortex rework D2)", () =
184
184
  })
185
185
  })
186
186
 
187
+ describe("F3 bodies (2026-09-25 A/B: F3 arm B)", () => {
188
+ // sun-glare, lens-crack and lightning-flash: the F3 drafts, tidied (first letter lower-cased, final
189
+ // full stop dropped). Each rendered string below is byte-identical to the clause the winning take was
190
+ // generated from.
191
+ const F3_BODIES: Record<string, { term: string; body: string }> = {
192
+ "sun-glare": {
193
+ term: "sun glare",
194
+ body:
195
+ "intense warm glare floods the lens from one corner of the frame, blooming and scattering flares unti" +
196
+ "l the whole picture is washed out to a bright haze. The camera stays where it is and the framing doe" +
197
+ "s not change. As the glare fades, the second shot emerges through the haze. The shot ends on the sec" +
198
+ "ond shot, clear and fully resolved, with no glare left. The glare comes from the lens itself, and th" +
199
+ "e second shot appears only as the haze clears",
200
+ },
201
+ "lens-crack": {
202
+ term: "lens crack",
203
+ body:
204
+ "a hairline crack snaps diagonally across the lens and branches into a web of fracture lines that spr" +
205
+ "eads over the whole picture. The camera stays where it is and the framing does not change. Seen thro" +
206
+ "ugh the cracks, the first shot gives way to the second shot, and then the fracture lines fade away. " +
207
+ "The shot ends on the second shot, clear and fully resolved, with no cracks left. The cracks form on " +
208
+ "the lens itself, while everything behind them stays whole",
209
+ },
210
+ "lightning-flash": {
211
+ term: "lightning strike",
212
+ body:
213
+ "a brilliant jagged bolt of lightning cracks across the frame and its flash turns the whole picture w" +
214
+ "hite. The camera stays where it is and the framing does not change. As the flash dies away, the seco" +
215
+ "nd shot is revealed in its place. The shot ends on the second shot, fully resolved, with no bolt or " +
216
+ "flash left. The change happens inside the flash, and the second shot appears only as the white fades",
217
+ },
218
+ }
219
+
220
+ it.each(Object.keys(F3_BODIES))("%s renders `term (body)` at the tile-default levers", (id) => {
221
+ const { term, body } = F3_BODIES[id]!
222
+ expect(getTransitionPromptHint(id)).toBe(body)
223
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(`${term} (${body})`)
224
+ })
225
+
226
+ it.each(Object.keys(F3_BODIES))("%s at middle / short / natural", (id) => {
227
+ const { term, body } = F3_BODIES[id]!
228
+ expect(composeTransitionHintFromConnections(id, [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
229
+ `${term} (${body}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
230
+ )
231
+ })
232
+ })
233
+
234
+ describe("F4 bodies (2026-09-25 A/B: F4 arm B)", () => {
235
+ // building-explosion and vehicle-explosion: the F4 drafts, tidied (first letter lower-cased, final
236
+ // full stop dropped). Each rendered string below is byte-identical to the clause the winning take was
237
+ // generated from.
238
+ const F4_BODIES: Record<string, { term: string; body: string }> = {
239
+ "building-explosion": {
240
+ term: "building explosion",
241
+ body:
242
+ "the largest structure in the frame detonates in a massive fireball, and debris and dust plume outwar" +
243
+ "d until they fill the whole picture. The camera stays where it is and the framing does not change. A" +
244
+ "s the dust cloud clears, the second shot is revealed in its place. The shot ends on the second shot," +
245
+ " clear and fully resolved, with no dust or debris left. The blast comes from that structure itself, " +
246
+ "and the second shot appears only as the dust clears",
247
+ },
248
+ "vehicle-explosion": {
249
+ term: "vehicle explosion",
250
+ body:
251
+ "a vehicle in the frame bursts into a violent explosion of fire and twisted metal, and the fireball b" +
252
+ "illows toward the lens until orange flame fills the whole picture. The camera stays where it is and " +
253
+ "the framing does not change. The flame gives way to thick smoke, and as the smoke parts the second s" +
254
+ "hot is revealed. The shot ends on the second shot, clear and fully resolved, with no fire or smoke l" +
255
+ "eft. The explosion comes from that vehicle itself, and the second shot appears only as the smoke par" +
256
+ "ts",
257
+ },
258
+ }
259
+
260
+ it.each(Object.keys(F4_BODIES))("%s renders `term (body)` at the tile-default levers", (id) => {
261
+ const { term, body } = F4_BODIES[id]!
262
+ expect(getTransitionPromptHint(id)).toBe(body)
263
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(`${term} (${body})`)
264
+ })
265
+
266
+ it.each(Object.keys(F4_BODIES))("%s at middle / short / natural", (id) => {
267
+ const { term, body } = F4_BODIES[id]!
268
+ expect(composeTransitionHintFromConnections(id, [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
269
+ `${term} (${body}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
270
+ )
271
+ })
272
+ })
273
+
274
+ describe("F5 bodies (2026-09-25 A/B: F5 arm B)", () => {
275
+ // zoom-into-book, pull-out-reveal, zoom-into-mouth, walk-through-door and mask-transition: the F5
276
+ // drafts, tidied (first letter lower-cased, final full stop dropped). Each rendered string below is
277
+ // byte-identical to the clause the winning take was generated from.
278
+ const F5_BODIES: Record<string, { term: string; body: string }> = {
279
+ "zoom-into-book": {
280
+ term: "zoom into book",
281
+ body:
282
+ "the camera pushes down toward the illustrated page of an open book in the frame until the illustrati" +
283
+ "on fills the whole picture. The camera travels in one smooth line, without turning or rolling. The d" +
284
+ "rawn picture comes alive, gaining depth, light and movement, and becomes the second shot. The shot e" +
285
+ "nds inside the second shot, real and fully resolved, with no paper, ink lines or page edges left. Th" +
286
+ "e camera heads for the page from the start and goes into its illustration",
287
+ },
288
+ "pull-out-reveal": {
289
+ term: "pull-back reveal",
290
+ body:
291
+ "the camera pulls straight back fast, and the whole first shot shrinks until its edges show as the bo" +
292
+ "rder of a framed picture inside a larger space. The camera travels back in one smooth line, without " +
293
+ "turning, tilting or rolling. The larger space around the picture is the second shot, and the first s" +
294
+ "hot stays inside the picture. The shot ends on the second shot, with the first shot visible as a pic" +
295
+ "ture within it. The first shot's image stays exactly the same as it shrinks, so it reads as the same" +
296
+ " picture all along",
297
+ },
298
+ "zoom-into-mouth": {
299
+ term: "zoom into mouth",
300
+ body:
301
+ "the camera pushes straight into the first subject's open mouth until the dark interior fills the who" +
302
+ "le picture. The camera travels forward in one smooth line, without turning, tilting or rolling. It p" +
303
+ "asses through the darkness, and the second shot emerges out of it. The shot ends inside the second s" +
304
+ "hot, still and fully resolved, with no mouth left in view. The dark interior stays a plain darkness " +
305
+ "the camera passes through",
306
+ },
307
+ "walk-through-door": {
308
+ term: "walk through doorway",
309
+ body:
310
+ "the camera follows the first subject through a doorway in the frame, moving forward at the subject's" +
311
+ " pace. The camera travels straight forward, without turning, tilting or rolling. On the far side of " +
312
+ "the doorway the space is the second shot, a different place in different light. The shot ends in the" +
313
+ " second shot, fully resolved, with the doorway behind the camera. The change of place happens at the" +
314
+ " doorway itself, the moment the camera passes through it",
315
+ },
316
+ "mask-transition": {
317
+ term: "mask transition",
318
+ body:
319
+ "a dark foreground shape sweeps across the lens and fills the frame with black. The camera keeps trav" +
320
+ "elling forward through the black in one smooth line. It emerges from the darkness into the second sh" +
321
+ "ot. The shot ends in the second shot, fully resolved, with no dark shape left. The dark shape passes" +
322
+ " close to the lens and is gone once the second shot appears",
323
+ },
324
+ }
325
+
326
+ it.each(Object.keys(F5_BODIES))("%s renders `term (body)` at the tile-default levers", (id) => {
327
+ const { term, body } = F5_BODIES[id]!
328
+ expect(getTransitionPromptHint(id)).toBe(body)
329
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(`${term} (${body})`)
330
+ })
331
+
332
+ it.each(Object.keys(F5_BODIES))("%s at middle / short / natural", (id) => {
333
+ const { term, body } = F5_BODIES[id]!
334
+ expect(composeTransitionHintFromConnections(id, [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
335
+ `${term} (${body}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
336
+ )
337
+ })
338
+ })
339
+
340
+ describe("shockwave body (2026-09-25 rework: shockwave3 E1)", () => {
341
+ // The E1 draft as tested: a flash bursts into a bright opaque ring that races past every edge, the ring
342
+ // the one hard border between the shots. The rendered string at the tile default is byte-identical to
343
+ // the clause the winning take was generated from.
344
+ const TERM = "shockwave"
345
+ const BODY =
346
+ "a flash at the exact centre of the frame bursts into a sharp, bright ring that races past every edge" +
347
+ " in a moment, trailing a smear of motion blur behind its rim and warping the picture as it goes. The" +
348
+ " camera stays where it is and the picture stays level. The second shot shows only inside the ring an" +
349
+ "d the first only outside it, with the bright ring as the one hard border and no blending anywhere"
350
+
351
+ it("renders `term (body)` at the tile-default levers", () => {
352
+ expect(getTransitionPromptHint("shockwave")).toBe(BODY)
353
+ expect(composeTransitionHintFromConnections("shockwave", [], [], {}, "full", { scope: "shot" })).toBe(`${TERM} (${BODY})`)
354
+ })
355
+
356
+ it("at middle / short / natural", () => {
357
+ expect(composeTransitionHintFromConnections("shockwave", [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
358
+ `${TERM} (${BODY}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
359
+ )
360
+ })
361
+ })
362
+
363
+ describe("roll-transition body (2026-09-25 A/B: F6 arm B)", () => {
364
+ // The F6 roll draft, tidied (first letter lower-cased, final full stop dropped): one full turn one way
365
+ // that stops level, with no swing back. The rendered string at the tile default is byte-identical to
366
+ // the clause the winning take was generated from. The other F6 rows keep today's text.
367
+ const TERM = "camera roll transition"
368
+ const BODY =
369
+ "the picture rolls around its centre in one smooth, fast turn, blurred by the speed of the turn. The " +
370
+ "camera stays in the same spot, turning only around its lens axis. During the turn the second shot ta" +
371
+ "kes over, and the roll slows and stops with it level and upright. The shot ends on the second shot, " +
372
+ "level, upright and still. The roll turns one way only and stops once, with no swing back"
373
+
374
+ it("renders `term (body)` at the tile-default levers", () => {
375
+ expect(getTransitionPromptHint("roll-transition")).toBe(BODY)
376
+ expect(composeTransitionHintFromConnections("roll-transition", [], [], {}, "full", { scope: "shot" })).toBe(`${TERM} (${BODY})`)
377
+ })
378
+
379
+ it("at middle / short / natural", () => {
380
+ expect(composeTransitionHintFromConnections("roll-transition", [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
381
+ `${TERM} (${BODY}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
382
+ )
383
+ })
384
+ })
385
+
386
+ describe("time-lapse bodies (2026-09-24 A/B at full / long: descab3 row 26)", () => {
387
+ // Day → night ships arm B, the audit body (B 5 > A 4). Night → day was a tie (A 4 · B 4), so the shorter
388
+ // body stays: today's (179 characters against B's 220). Both rows keep their "fast-forward time-lapse
389
+ // transition:" heading, which the renderer strips. Day → night's body has a colon of its own after a
390
+ // 61-character lead-in; it is a description lead-in, not a heading, and the heading guard in
391
+ // transitions-instant.test.ts still passes for it. The term + body is byte-identical to the clause the
392
+ // winning take (d4546594) was generated from.
393
+ const DN_TERM = "day-to-night time-lapse"
394
+ const DN_BODY =
395
+ "the same view with framing locked while hours pass in seconds: light and shadows sweep across the sc" +
396
+ "ene, daylight warms to dusk and fades to night, lights come on, until the picture matches the end frame"
397
+ const ND_TERM = "night-to-day time-lapse"
398
+ const ND_BODY =
399
+ "stars fade, the sky shifts from deep night through pre-dawn blue to golden sunrise, shadows sweep in r" +
400
+ "everse, all while framing and camera position remain locked on the same scene"
401
+ const HEADING = "fast-forward time-lapse transition: "
402
+
403
+ it("day → night: the catalog hint keeps its heading and carries the B body", () => {
404
+ expect(getTransitionPromptHint("fast-forward-day-night")).toBe(HEADING + DN_BODY)
405
+ })
406
+
407
+ it("day → night renders `term (body)` at the tile-default levers", () => {
408
+ expect(composeTransitionHintFromConnections("fast-forward-day-night", [], [], {}, "full", { scope: "shot" })).toBe(
409
+ `${DN_TERM} (${DN_BODY})`,
410
+ )
411
+ })
412
+
413
+ it("day → night at full / long / natural (the levers the take was rendered at)", () => {
414
+ expect(composeTransitionHintFromConnections("fast-forward-day-night", [], [], { position: "full", duration: "long", intensity: "natural" })).toBe(
415
+ `${DN_TERM} (${DN_BODY}), ${FULL_CLAUSE}, lasting approximately 3 seconds, with natural timing`,
416
+ )
417
+ })
418
+
419
+ it("day → night at middle / short / natural", () => {
420
+ expect(composeTransitionHintFromConnections("fast-forward-day-night", [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
421
+ `${DN_TERM} (${DN_BODY}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
422
+ )
423
+ })
424
+
425
+ it("night → day keeps today's body", () => {
426
+ expect(getTransitionPromptHint("fast-forward-night-day")).toBe(HEADING + ND_BODY)
427
+ expect(composeTransitionHintFromConnections("fast-forward-night-day", [], [], {}, "full", { scope: "shot" })).toBe(
428
+ `${ND_TERM} (${ND_BODY})`,
429
+ )
430
+ })
431
+ })
432
+
433
+ describe("zoom-into-mirror body (2026-10-03 redraft: mirror3 D2, liquid ripple)", () => {
434
+ // The mirror3 D2 draft ("liquid ripple"), rated 4/5 against D1 3/5 and D3 2/5. It was tested already
435
+ // tidied (lower-case start, no final full stop), so it ships exactly as the take was generated (job
436
+ // 5821fa22): the rendered string at the tile default is byte-identical to the clause in that prompt.
437
+ const TERM = "zoom into mirror"
438
+ const BODY =
439
+ "the camera pushes straight at a mirror in the frame. As the lens meets the glass, the mirror's " +
440
+ "surface turns liquid and rings ripple out across the whole picture, and the camera keeps moving " +
441
+ "forward through the rippling surface, out the far side into the second shot. Until the lens touches " +
442
+ "the glass, the mirror keeps its own reflection. The shot ends in the second shot, still and fully " +
443
+ "resolved, with no ripples left"
444
+
445
+ it("renders `term (body)` at the tile-default levers", () => {
446
+ expect(getTransitionPromptHint("zoom-into-mirror")).toBe(BODY)
447
+ expect(composeTransitionHintFromConnections("zoom-into-mirror", [], [], {}, "full", { scope: "shot" })).toBe(`${TERM} (${BODY})`)
448
+ })
449
+
450
+ it("at middle / short / natural", () => {
451
+ expect(composeTransitionHintFromConnections("zoom-into-mirror", [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
452
+ `${TERM} (${BODY}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
453
+ )
454
+ })
455
+ })
456
+
457
+ describe("F8 cut bodies (2026-10-03 A/B: F8 arm B on snap-to-black, jump-match and match-cut)", () => {
458
+ // Arm B, the F8 draft with the ship tidy (first letter lower-cased; the drafts carry no final full stop, so
459
+ // the anti-blend sentence reads on after the body). Each is byte-identical to the body inside the prompt of
460
+ // the take it was rated on: snap-to-black 03-B-t2 (2bcc8b81), jump-match 04-B-t1 (b9027a0a), match-cut
461
+ // 07-B (4c6e3ecd). snap-to-black and match-cut keep their "<label>:" heading, which the renderer strips;
462
+ // jump-match never had one. None of the three bodies contains a colon, so the heading guard in
463
+ // transitions-instant.test.ts cannot match them, and no comma item equals its row's term. All three are
464
+ // cuts: the cut sentence rides inside the parentheses, and duration and intensity are dropped.
465
+ const ROWS = [
466
+ {
467
+ id: "snap-to-black",
468
+ term: "snap to black",
469
+ heading: "snap to black: ",
470
+ body:
471
+ "the first shot cuts straight to full black on a single frame. The camera holds its framing right up " +
472
+ "to the cut. The frame stays pure black for a single beat, then the second shot cuts in at full brigh" +
473
+ "tness on a single frame. The shot ends on the second shot, fully resolved",
474
+ },
475
+ {
476
+ id: "jump-match",
477
+ term: "match cut on a jump",
478
+ heading: "",
479
+ body:
480
+ "the subject launches into a jump, and at the height of the leap the picture cuts to a new place. The" +
481
+ " camera follows the arc of the jump at the same speed on both sides of the cut, and the subject stay" +
482
+ "s at the same place in the frame. In the second shot the same jump carries on without a break, and t" +
483
+ "he subject comes down and lands in the new place. The shot ends on the subject landed in the second " +
484
+ "shot, fully resolved",
485
+ },
486
+ {
487
+ id: "match-cut",
488
+ term: "match cut",
489
+ heading: "match cut: ",
490
+ body:
491
+ "the last picture of the first shot and the first picture of the second share one shape, at the same " +
492
+ "place and the same size in the frame. The camera holds that shape in place across the cut. On the ne" +
493
+ "xt frame everything around the shape has changed while the shape itself stays put. The shot ends on " +
494
+ "the second shot, fully resolved, with no flash frame or zoom between the two",
495
+ },
496
+ ] as const
497
+
498
+ it.each(ROWS)("$id: the catalog hint carries the new body (heading kept where the row had one)", ({ id, heading, body }) => {
499
+ expect(getTransitionPromptHint(id)).toBe(heading + body)
500
+ })
501
+
502
+ it.each(ROWS)("$id renders `term (body; cut sentence)` at the tile-default levers", ({ id, term, body }) => {
503
+ const clause = `${term} (${body}; ${INSTANT_CUT_CLAUSE})`
504
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(clause)
505
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "compact", { scope: "shot" })).toBe(clause)
506
+ expect(composeTransitionHintFromConnections(id, [], [])).toBe(clause)
507
+ })
508
+
509
+ it.each(ROWS)("$id at middle / short / natural keeps only the position", ({ id, term, body }) => {
510
+ const levers = { position: "middle", duration: "short", intensity: "natural" } as const
511
+ expect(composeTransitionHintFromConnections(id, [], [], levers)).toBe(
512
+ `${term} (${body}; ${INSTANT_CUT_CLAUSE}), the transition occurs in the middle of the clip`,
513
+ )
514
+ expect(composeTransitionHintFromConnections(id, [], [], levers, "full", { scope: "shot" })).toBe(
515
+ `${term} (${body}; ${INSTANT_CUT_CLAUSE}), the transition occurs in the middle of this shot`,
516
+ )
517
+ })
518
+
519
+ // The other F8 rows keep today's text (jump-cut's body changes in the 2026-10-04 block below).
520
+ const KEPT: Record<string, string> = {
521
+ "none":
522
+ "no transition, hard cut, instantaneous switch from first shot to second shot",
523
+ "smash-cut":
524
+ "smash cut: an abrupt jarring transition between two visually or tonally contrasting shots with no fa" +
525
+ "de, on a beat",
526
+ "seamless-match":
527
+ "hidden seamless transition: the camera motion, color palette, and on-screen motion at the end of the" +
528
+ " first shot continue exactly across the cut into the second shot, so the boundary is invisible and t" +
529
+ "he two shots feel like one unbroken take",
530
+ "action-relay":
531
+ "match cut on action: the subject exits the frame on a committed action — a stride, a throw, a turn —" +
532
+ " and enters the new scene on the same beat continuing that movement at matched speed and direction, " +
533
+ "so the action carries unbroken across the cut",
534
+ }
535
+
536
+ it.each(Object.entries(KEPT))("%s keeps today's text", (id, hint) => {
537
+ expect(getTransitionPromptHint(id)).toBe(hint)
538
+ })
539
+ })
540
+
541
+ describe("F9 time bodies (2026-10-03 A/B at full / long: F9 arm B on seasonal-shift and flashback)", () => {
542
+ // Arm B, the F9 draft with the ship tidy (first letter lower-cased, no final full stop). Each is byte-identical to
543
+ // the body inside the prompt of the take it was rated on: seasonal-shift 01-B (428e79c6) and flashback 02-B
544
+ // (bc236b0c), both rendered at position full / duration long (the time-row rule), no intensity, scope shot. Both
545
+ // rows keep their heading, which the renderer strips. Neither body contains a colon, so the heading guard in
546
+ // transitions-instant.test.ts has nothing to match, and no comma item equals its row's term. weather-shift was a
547
+ // tie (A 4 · B 4), so the shorter body stays: today's.
548
+ const ROWS = [
549
+ {
550
+ id: "seasonal-shift",
551
+ term: "seasonal time-lapse",
552
+ heading: "accelerated seasonal time-lapse: ",
553
+ body:
554
+ "the same view races through the seasons in fast motion, from the season of the first shot to the sea" +
555
+ "son of the second, as growing things bud, turn and fall and snow comes or goes. The camera stays whe" +
556
+ "re it is and the framing does not change. The change flows continuously across the whole picture, ev" +
557
+ "ery part moving on together, until the view matches the second shot. The shot ends on the second sho" +
558
+ "t's season, still and fully resolved. Only the season changes, and the layout of the view stays exac" +
559
+ "tly the same",
560
+ },
561
+ {
562
+ id: "flashback",
563
+ term: "flashback",
564
+ heading: "brief flashback transition: ",
565
+ body:
566
+ "a soft warm wash spreads over the whole picture and a faint ripple drifts across it as the present m" +
567
+ "oment fades. The camera stays where it is and the framing does not change. Through the ripple an ear" +
568
+ "lier moment of the same subject comes into focus, like a memory. The shot ends on the remembered mom" +
569
+ "ent, steady and fully resolved, with the ripple gone. The subject stays at the same place in the fra" +
570
+ "me while the moment around it changes",
571
+ },
572
+ ] as const
573
+
574
+ it.each(ROWS)("$id: the catalog hint keeps its heading and carries the B body", ({ id, heading, body }) => {
575
+ expect(getTransitionPromptHint(id)).toBe(heading + body)
576
+ })
577
+
578
+ it.each(ROWS)("$id renders `term (body)` at the tile-default levers", ({ id, term, body }) => {
579
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(`${term} (${body})`)
580
+ })
581
+
582
+ it.each(ROWS)("$id at full / long in a shot window (the levers the take was rendered at)", ({ id, term, body }) => {
583
+ expect(composeTransitionHintFromConnections(id, [], [], { position: "full", duration: "long" }, "full", { scope: "shot" })).toBe(
584
+ `${term} (${body}), ${FULL_SHOT_CLAUSE}, lasting approximately 3 seconds`,
585
+ )
586
+ })
587
+
588
+ it.each(ROWS)("$id at middle / short / natural", ({ id, term, body }) => {
589
+ expect(composeTransitionHintFromConnections(id, [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
590
+ `${term} (${body}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
591
+ )
592
+ })
593
+
594
+ it("weather-shift keeps today's body", () => {
595
+ const body =
596
+ "same scene, framing locked — clear sky darkens to storm clouds, rain begins and intensifies then cle" +
597
+ "ars, sun returns through breaking clouds"
598
+ expect(getTransitionPromptHint("weather-shift")).toBe(`accelerated weather transition: ${body}`)
599
+ expect(composeTransitionHintFromConnections("weather-shift", [], [], {}, "full", { scope: "shot" })).toBe(`weather time-lapse (${body})`)
600
+ })
601
+ })
602
+
603
+ describe("F7 glitch and light bodies (2026-10-03 A/B: F7 arm B on channel-flip, display-wipe and color-invert)", () => {
604
+ // Arm B, the F7 draft with the ship tidy (first letter lower-cased, final full stop dropped). Each is
605
+ // byte-identical to the body inside the prompt of the take it was rated on: channel-flip 01-B (7f7829ec),
606
+ // display-wipe 03-B (c66d59bc) and color-invert 04-B (18d66f83), all at the tile-default levers. None of the
607
+ // three rows has a heading, none of the bodies contains a colon (so the heading guard in
608
+ // transitions-instant.test.ts has nothing to match), and no comma item equals its row's term. The other F7
609
+ // rows keep today's text.
610
+ const ROWS = [
611
+ {
612
+ id: "channel-flip",
613
+ term: "tv channel flip with static",
614
+ body:
615
+ "the whole picture breaks into a brief burst of black-and-white static, the image jumping and tearing" +
616
+ " as if the channel were being changed. The camera stays where it is and the framing does not change." +
617
+ " The static clears as quickly as it came, and the second shot snaps in, steady, like the next channe" +
618
+ "l. The shot ends on the second shot, clean and fully resolved, with no static left. The static cover" +
619
+ "s the whole frame, so the picture itself is what changes channel",
620
+ },
621
+ {
622
+ id: "display-wipe",
623
+ term: "compress into a screen and expand out",
624
+ body:
625
+ "the whole first shot shrinks toward the centre of the frame into a small glowing rectangle, then col" +
626
+ "lapses to a bright line and a dot as if its power were cut. The camera stays where it is and the fra" +
627
+ "ming does not change. From the dot a line snaps open and widens into a rectangle showing the second " +
628
+ "shot, which grows until it fills the frame. The shot ends on the second shot, full frame and fully r" +
629
+ "esolved, with no border or scanlines left. The picture itself shrinks and grows on a black field, wi" +
630
+ "th the camera holding still throughout",
631
+ },
632
+ {
633
+ id: "color-invert",
634
+ term: "color invert flash",
635
+ body:
636
+ "the colours of the whole picture turn to their photographic negative in an instant. The camera stays" +
637
+ " where it is and the framing does not change. The negative holds for a single beat, and when the col" +
638
+ "ours snap back to normal the second shot is in place. The shot ends on the second shot in natural co" +
639
+ "lour, fully resolved. The change of shot happens while the picture is in negative, and the picture s" +
640
+ "tays upright and in place throughout",
641
+ },
642
+ ] as const
643
+
644
+ it.each(ROWS)("$id: the catalog hint is the B body", ({ id, body }) => {
645
+ expect(getTransitionPromptHint(id)).toBe(body)
646
+ })
647
+
648
+ it.each(ROWS)("$id renders `term (body)` at the tile-default levers", ({ id, term, body }) => {
649
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(`${term} (${body})`)
650
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "compact", { scope: "shot" })).toBe(`${term} (${body})`)
651
+ expect(composeTransitionHintFromConnections(id, [], [])).toBe(`${term} (${body})`)
652
+ })
653
+
654
+ it.each(ROWS)("$id at middle / short / natural", ({ id, term, body }) => {
655
+ expect(composeTransitionHintFromConnections(id, [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
656
+ `${term} (${body}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
657
+ )
658
+ })
659
+ })
660
+
661
+ describe("Redrafted glitch bodies and the jump-cut negative sentence (2026-10-04: hologram-flicker D2, datamosh D1, jump-cut C)", () => {
662
+ // Each body is byte-identical to the body inside the prompt of the take its tile shows, at the tile-default
663
+ // levers: hologram-flicker from the F7 redraft's D2, a scan-line rebuild (17245f6b); datamosh from its D1,
664
+ // blocks that tear loose and pile up into the second shot (a16f906b); jump-cut from the jump-cut retest's arm
665
+ // C, today's body plus one negative sentence (2bfc4380). datamosh and jump-cut keep their "<label>:" heading,
666
+ // which the renderer strips; hologram-flicker never had one. No body contains a colon, so the heading guard in
667
+ // transitions-instant.test.ts cannot match them, and no comma item equals its row's term.
668
+ const ROWS = [
669
+ {
670
+ id: "hologram-flicker",
671
+ term: "hologram flicker",
672
+ heading: "",
673
+ body:
674
+ "the first shot breaks into thin lines of cyan light that flicker out from the top down, leaving the " +
675
+ "frame black. The camera stays where it is and the framing does not change. A bright scan line sweeps" +
676
+ " down and draws the second shot behind it in flickering cyan lines that steady into natural colour. " +
677
+ "The shot ends on the second shot, solid and fully resolved, with no scan lines left. The second shot" +
678
+ " is drawn only on black, never over the first shot",
679
+ },
680
+ {
681
+ id: "datamosh",
682
+ term: "datamosh",
683
+ heading: "datamosh transition: ",
684
+ body:
685
+ "the first shot's pixels tear loose in square compression blocks and slide sideways across the frame " +
686
+ "in long smeared streaks. The camera stays where it is and the framing does not change. The blocks pi" +
687
+ "le up into the shapes of the second shot, still in the first shot's colours, until the true colours " +
688
+ "snap in block by block. The shot ends on the second shot, sharp and fully resolved, with no smears o" +
689
+ "r blocks left. The smear covers the whole frame",
690
+ },
691
+ ] as const
692
+
693
+ it.each(ROWS)("$id: the catalog hint carries the new body (heading kept where the row had one)", ({ id, heading, body }) => {
694
+ expect(getTransitionPromptHint(id)).toBe(heading + body)
695
+ })
696
+
697
+ it.each(ROWS)("$id renders `term (body)` at the tile-default levers", ({ id, term, body }) => {
698
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(`${term} (${body})`)
699
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "compact", { scope: "shot" })).toBe(`${term} (${body})`)
700
+ expect(composeTransitionHintFromConnections(id, [], [])).toBe(`${term} (${body})`)
701
+ })
702
+
703
+ it.each(ROWS)("$id at middle / short / natural", ({ id, term, body }) => {
704
+ expect(composeTransitionHintFromConnections(id, [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
705
+ `${term} (${body}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
706
+ )
707
+ })
708
+
709
+ // jump-cut is a cut: the anti-blend sentence rides inside the parentheses after the body, and duration and
710
+ // intensity are dropped. The new body has a semicolon of its own; the restatement dedupe splits on commas
711
+ // only, so it is untouched, and the cut sentence still appears exactly once.
712
+ const JUMP_CUT_BODY =
713
+ "the framing, lens, and camera position stay identical across the cut while time skips abruptly forwa" +
714
+ "rd, so the subject snaps to a new position inside what still reads as one continuous shot. No leap, " +
715
+ "no run, no lunge and no motion blur between the two positions; the subject is in the old place on on" +
716
+ "e frame and already in the new place on the next"
717
+ const JUMP_CUT_CLAUSE = `jump cut (${JUMP_CUT_BODY}; ${INSTANT_CUT_CLAUSE})`
718
+
719
+ it("jump-cut: the catalog hint carries the new body after its heading", () => {
720
+ expect(getTransitionPromptHint("jump-cut")).toBe(`jump cut: ${JUMP_CUT_BODY}`)
721
+ })
722
+
723
+ it("jump-cut renders `term (body; cut sentence)` at the tile-default levers, the cut sentence once", () => {
724
+ expect(composeTransitionHintFromConnections("jump-cut", [], [], {}, "full", { scope: "shot" })).toBe(JUMP_CUT_CLAUSE)
725
+ expect(composeTransitionHintFromConnections("jump-cut", [], [], {}, "compact", { scope: "shot" })).toBe(JUMP_CUT_CLAUSE)
726
+ expect(composeTransitionHintFromConnections("jump-cut", [], [])).toBe(JUMP_CUT_CLAUSE)
727
+ expect(JUMP_CUT_CLAUSE.split(INSTANT_CUT_CLAUSE).length).toBe(2)
728
+ })
729
+
730
+ it("jump-cut at middle / short / natural keeps only the position", () => {
731
+ const levers = { position: "middle", duration: "short", intensity: "natural" } as const
732
+ expect(composeTransitionHintFromConnections("jump-cut", [], [], levers)).toBe(
733
+ `${JUMP_CUT_CLAUSE}, the transition occurs in the middle of the clip`,
734
+ )
735
+ expect(composeTransitionHintFromConnections("jump-cut", [], [], levers, "full", { scope: "shot" })).toBe(
736
+ `${JUMP_CUT_CLAUSE}, the transition occurs in the middle of this shot`,
737
+ )
738
+ })
739
+ })
740
+
741
+ describe("double-exposure body (2026-10-04: dblexp3 D1, the silhouette wording)", () => {
742
+ // The D1 draft (first written for dblexp2), byte-identical to the body inside the prompt of the take the
743
+ // row's tile shows: dblexp3 D1 (c55f1176), rendered at the tile-default levers on a dark profile
744
+ // silhouette over a near-white ground, cutting to a mountain lake. The row has no heading, the body has no
745
+ // colon (so the heading guard in transitions-instant.test.ts cannot match it), and no comma item equals the
746
+ // term. No other row changes.
747
+ const TERM = "double exposure"
748
+ const BODY =
749
+ "the first subject becomes a crisp silhouette filled solid with the second shot, while the rest of th" +
750
+ "e first shot stays untouched around it. The camera stays where it is and the framing does not change" +
751
+ ". The silhouette holds for a clear beat, both pictures plainly seen at once, then the first shot aro" +
752
+ "und it gives way to the second shot. The shot ends on the second shot alone, fully resolved"
753
+ const CLAUSE = `${TERM} (${BODY})`
754
+
755
+ it("the catalog hint is the D1 body", () => {
756
+ expect(getTransitionPromptHint("double-exposure")).toBe(BODY)
757
+ })
758
+
759
+ it("renders `term (body)` at the tile-default levers", () => {
760
+ expect(composeTransitionHintFromConnections("double-exposure", [], [], {}, "full", { scope: "shot" })).toBe(CLAUSE)
761
+ expect(composeTransitionHintFromConnections("double-exposure", [], [], {}, "compact", { scope: "shot" })).toBe(CLAUSE)
762
+ expect(composeTransitionHintFromConnections("double-exposure", [], [])).toBe(CLAUSE)
763
+ })
764
+
765
+ it("at middle / short / natural", () => {
766
+ expect(composeTransitionHintFromConnections("double-exposure", [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
767
+ `${CLAUSE}, the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
768
+ )
769
+ })
770
+ })
771
+
187
772
  describe("L1 — a cut spans nothing, so `full` adds no clause", () => {
188
773
  it.each(INSTANT_IDS)("%s + full renders no position clause", (id) => {
189
774
  const out = composeTransitionHintFromConnections(id, [], [], { position: "full", duration: "short", intensity: "natural" })
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { DEFAULT_OWN_MOTION, ownMotionHint } from "../video-own-motion.js"
3
+
4
+ describe("ownMotionHint", () => {
5
+ const on = { motionEnabled: true, motion: "dynamic" }
6
+
7
+ it("gives legacy Image to Video and Generate Video their own Motion clause", () => {
8
+ expect(ownMotionHint("image-to-video", on)).toBe("dynamic motion")
9
+ expect(ownMotionHint("generate-video", on)).toBe("dynamic motion")
10
+ })
11
+
12
+ it("gives none to a node type without the setting", () => {
13
+ expect(ownMotionHint("text-to-video", on)).toBeUndefined()
14
+ expect(ownMotionHint("video-to-video", on)).toBeUndefined()
15
+ })
16
+
17
+ it("gives none while the setting is off", () => {
18
+ expect(ownMotionHint("generate-video", { motionEnabled: false, motion: "dynamic" })).toBeUndefined()
19
+ expect(ownMotionHint("generate-video", { motion: "dynamic" })).toBeUndefined()
20
+ })
21
+
22
+ it("runs an enabled setting with no step stored as the step the panel shows", () => {
23
+ expect(ownMotionHint("generate-video", { motionEnabled: true })).toBe(`${DEFAULT_OWN_MOTION} motion`)
24
+ expect(ownMotionHint("image-to-video", { motionEnabled: true, motion: "" })).toBe(`${DEFAULT_OWN_MOTION} motion`)
25
+ })
26
+ })