@nodaro/prompts 1.27.0 → 1.29.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 (33) hide show
  1. package/dist/index.cjs +915 -603
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +251 -17
  4. package/dist/index.d.ts +251 -17
  5. package/dist/index.js +896 -604
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/fixtures/blended-cut-approved.json +712 -0
  9. package/src/__tests__/fixtures/parameter-hint-golden.json +33 -1
  10. package/src/__tests__/hint-join.test.ts +64 -1
  11. package/src/__tests__/motion-hint.test.ts +20 -0
  12. package/src/__tests__/parameter-registry-sync.test.ts +1 -0
  13. package/src/__tests__/person-mixed-heritage.test.ts +43 -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__/transition-timing-catalogs.test.ts +41 -0
  18. package/src/__tests__/transitions-blended-cut.test.ts +325 -0
  19. package/src/__tests__/transitions-instant.test.ts +9 -5
  20. package/src/__tests__/transitions-scope.test.ts +420 -4
  21. package/src/__tests__/video-own-motion.test.ts +26 -0
  22. package/src/hint-join.ts +33 -1
  23. package/src/index.ts +1 -0
  24. package/src/parameter-prompt-hint.ts +20 -3
  25. package/src/person.ts +24 -8
  26. package/src/picker-art/character-art-files.generated.ts +536 -536
  27. package/src/picker-catalogs.ts +83 -7
  28. package/src/prompt-builder.ts +2 -1
  29. package/src/prompt-hint-join.ts +8 -2
  30. package/src/prompt-style-section.ts +3 -5
  31. package/src/resolve-prompt.ts +22 -4
  32. package/src/transitions.ts +408 -52
  33. 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", () => {
@@ -357,9 +360,422 @@ describe("shockwave body (2026-09-25 rework: shockwave3 E1)", () => {
357
360
  })
358
361
  })
359
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
+ // `long`: a duration every cut ignores. Short on `jump-match` is a blended cut, pinned in
510
+ // transitions-blended-cut.test.ts.
511
+ it.each(ROWS)("$id at middle / long / natural keeps only the position", ({ id, term, body }) => {
512
+ const levers = { position: "middle", duration: "long", intensity: "natural" } as const
513
+ expect(composeTransitionHintFromConnections(id, [], [], levers)).toBe(
514
+ `${term} (${body}; ${INSTANT_CUT_CLAUSE}), the transition occurs in the middle of the clip`,
515
+ )
516
+ expect(composeTransitionHintFromConnections(id, [], [], levers, "full", { scope: "shot" })).toBe(
517
+ `${term} (${body}; ${INSTANT_CUT_CLAUSE}), the transition occurs in the middle of this shot`,
518
+ )
519
+ })
520
+
521
+ // The other F8 rows keep today's text (jump-cut's body changes in the 2026-10-04 block below).
522
+ const KEPT: Record<string, string> = {
523
+ "none":
524
+ "no transition, hard cut, instantaneous switch from first shot to second shot",
525
+ "smash-cut":
526
+ "smash cut: an abrupt jarring transition between two visually or tonally contrasting shots with no fa" +
527
+ "de, on a beat",
528
+ "seamless-match":
529
+ "hidden seamless transition: the camera motion, color palette, and on-screen motion at the end of the" +
530
+ " first shot continue exactly across the cut into the second shot, so the boundary is invisible and t" +
531
+ "he two shots feel like one unbroken take",
532
+ "action-relay":
533
+ "match cut on action: the subject exits the frame on a committed action — a stride, a throw, a turn —" +
534
+ " and enters the new scene on the same beat continuing that movement at matched speed and direction, " +
535
+ "so the action carries unbroken across the cut",
536
+ }
537
+
538
+ it.each(Object.entries(KEPT))("%s keeps today's text", (id, hint) => {
539
+ expect(getTransitionPromptHint(id)).toBe(hint)
540
+ })
541
+ })
542
+
543
+ describe("F9 time bodies (2026-10-03 A/B at full / long: F9 arm B on seasonal-shift and flashback)", () => {
544
+ // Arm B, the F9 draft with the ship tidy (first letter lower-cased, no final full stop). Each is byte-identical to
545
+ // the body inside the prompt of the take it was rated on: seasonal-shift 01-B (428e79c6) and flashback 02-B
546
+ // (bc236b0c), both rendered at position full / duration long (the time-row rule), no intensity, scope shot. Both
547
+ // rows keep their heading, which the renderer strips. Neither body contains a colon, so the heading guard in
548
+ // transitions-instant.test.ts has nothing to match, and no comma item equals its row's term. weather-shift was a
549
+ // tie (A 4 · B 4), so the shorter body stays: today's.
550
+ const ROWS = [
551
+ {
552
+ id: "seasonal-shift",
553
+ term: "seasonal time-lapse",
554
+ heading: "accelerated seasonal time-lapse: ",
555
+ body:
556
+ "the same view races through the seasons in fast motion, from the season of the first shot to the sea" +
557
+ "son of the second, as growing things bud, turn and fall and snow comes or goes. The camera stays whe" +
558
+ "re it is and the framing does not change. The change flows continuously across the whole picture, ev" +
559
+ "ery part moving on together, until the view matches the second shot. The shot ends on the second sho" +
560
+ "t's season, still and fully resolved. Only the season changes, and the layout of the view stays exac" +
561
+ "tly the same",
562
+ },
563
+ {
564
+ id: "flashback",
565
+ term: "flashback",
566
+ heading: "brief flashback transition: ",
567
+ body:
568
+ "a soft warm wash spreads over the whole picture and a faint ripple drifts across it as the present m" +
569
+ "oment fades. The camera stays where it is and the framing does not change. Through the ripple an ear" +
570
+ "lier moment of the same subject comes into focus, like a memory. The shot ends on the remembered mom" +
571
+ "ent, steady and fully resolved, with the ripple gone. The subject stays at the same place in the fra" +
572
+ "me while the moment around it changes",
573
+ },
574
+ ] as const
575
+
576
+ it.each(ROWS)("$id: the catalog hint keeps its heading and carries the B body", ({ id, heading, body }) => {
577
+ expect(getTransitionPromptHint(id)).toBe(heading + body)
578
+ })
579
+
580
+ it.each(ROWS)("$id renders `term (body)` at the tile-default levers", ({ id, term, body }) => {
581
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(`${term} (${body})`)
582
+ })
583
+
584
+ it.each(ROWS)("$id at full / long in a shot window (the levers the take was rendered at)", ({ id, term, body }) => {
585
+ expect(composeTransitionHintFromConnections(id, [], [], { position: "full", duration: "long" }, "full", { scope: "shot" })).toBe(
586
+ `${term} (${body}), ${FULL_SHOT_CLAUSE}, lasting approximately 3 seconds`,
587
+ )
588
+ })
589
+
590
+ it.each(ROWS)("$id at middle / short / natural", ({ id, term, body }) => {
591
+ expect(composeTransitionHintFromConnections(id, [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
592
+ `${term} (${body}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
593
+ )
594
+ })
595
+
596
+ it("weather-shift keeps today's body", () => {
597
+ const body =
598
+ "same scene, framing locked — clear sky darkens to storm clouds, rain begins and intensifies then cle" +
599
+ "ars, sun returns through breaking clouds"
600
+ expect(getTransitionPromptHint("weather-shift")).toBe(`accelerated weather transition: ${body}`)
601
+ expect(composeTransitionHintFromConnections("weather-shift", [], [], {}, "full", { scope: "shot" })).toBe(`weather time-lapse (${body})`)
602
+ })
603
+ })
604
+
605
+ describe("F7 glitch and light bodies (2026-10-03 A/B: F7 arm B on channel-flip, display-wipe and color-invert)", () => {
606
+ // Arm B, the F7 draft with the ship tidy (first letter lower-cased, final full stop dropped). Each is
607
+ // byte-identical to the body inside the prompt of the take it was rated on: channel-flip 01-B (7f7829ec),
608
+ // display-wipe 03-B (c66d59bc) and color-invert 04-B (18d66f83), all at the tile-default levers. None of the
609
+ // three rows has a heading, none of the bodies contains a colon (so the heading guard in
610
+ // transitions-instant.test.ts has nothing to match), and no comma item equals its row's term. The other F7
611
+ // rows keep today's text.
612
+ const ROWS = [
613
+ {
614
+ id: "channel-flip",
615
+ term: "tv channel flip with static",
616
+ body:
617
+ "the whole picture breaks into a brief burst of black-and-white static, the image jumping and tearing" +
618
+ " as if the channel were being changed. The camera stays where it is and the framing does not change." +
619
+ " The static clears as quickly as it came, and the second shot snaps in, steady, like the next channe" +
620
+ "l. The shot ends on the second shot, clean and fully resolved, with no static left. The static cover" +
621
+ "s the whole frame, so the picture itself is what changes channel",
622
+ },
623
+ {
624
+ id: "display-wipe",
625
+ term: "compress into a screen and expand out",
626
+ body:
627
+ "the whole first shot shrinks toward the centre of the frame into a small glowing rectangle, then col" +
628
+ "lapses to a bright line and a dot as if its power were cut. The camera stays where it is and the fra" +
629
+ "ming does not change. From the dot a line snaps open and widens into a rectangle showing the second " +
630
+ "shot, which grows until it fills the frame. The shot ends on the second shot, full frame and fully r" +
631
+ "esolved, with no border or scanlines left. The picture itself shrinks and grows on a black field, wi" +
632
+ "th the camera holding still throughout",
633
+ },
634
+ {
635
+ id: "color-invert",
636
+ term: "color invert flash",
637
+ body:
638
+ "the colours of the whole picture turn to their photographic negative in an instant. The camera stays" +
639
+ " where it is and the framing does not change. The negative holds for a single beat, and when the col" +
640
+ "ours snap back to normal the second shot is in place. The shot ends on the second shot in natural co" +
641
+ "lour, fully resolved. The change of shot happens while the picture is in negative, and the picture s" +
642
+ "tays upright and in place throughout",
643
+ },
644
+ ] as const
645
+
646
+ it.each(ROWS)("$id: the catalog hint is the B body", ({ id, body }) => {
647
+ expect(getTransitionPromptHint(id)).toBe(body)
648
+ })
649
+
650
+ it.each(ROWS)("$id renders `term (body)` at the tile-default levers", ({ id, term, body }) => {
651
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(`${term} (${body})`)
652
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "compact", { scope: "shot" })).toBe(`${term} (${body})`)
653
+ expect(composeTransitionHintFromConnections(id, [], [])).toBe(`${term} (${body})`)
654
+ })
655
+
656
+ it.each(ROWS)("$id at middle / short / natural", ({ id, term, body }) => {
657
+ expect(composeTransitionHintFromConnections(id, [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
658
+ `${term} (${body}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
659
+ )
660
+ })
661
+ })
662
+
663
+ describe("Redrafted glitch bodies and the jump-cut negative sentence (2026-10-04: hologram-flicker D2, datamosh D1, jump-cut C)", () => {
664
+ // Each body is byte-identical to the body inside the prompt of the take its tile shows, at the tile-default
665
+ // levers: hologram-flicker from the F7 redraft's D2, a scan-line rebuild (17245f6b); datamosh from its D1,
666
+ // blocks that tear loose and pile up into the second shot (a16f906b); jump-cut from the jump-cut retest's arm
667
+ // C, today's body plus one negative sentence (2bfc4380). datamosh and jump-cut keep their "<label>:" heading,
668
+ // which the renderer strips; hologram-flicker never had one. No body contains a colon, so the heading guard in
669
+ // transitions-instant.test.ts cannot match them, and no comma item equals its row's term.
670
+ const ROWS = [
671
+ {
672
+ id: "hologram-flicker",
673
+ term: "hologram flicker",
674
+ heading: "",
675
+ body:
676
+ "the first shot breaks into thin lines of cyan light that flicker out from the top down, leaving the " +
677
+ "frame black. The camera stays where it is and the framing does not change. A bright scan line sweeps" +
678
+ " down and draws the second shot behind it in flickering cyan lines that steady into natural colour. " +
679
+ "The shot ends on the second shot, solid and fully resolved, with no scan lines left. The second shot" +
680
+ " is drawn only on black, never over the first shot",
681
+ },
682
+ {
683
+ id: "datamosh",
684
+ term: "datamosh",
685
+ heading: "datamosh transition: ",
686
+ body:
687
+ "the first shot's pixels tear loose in square compression blocks and slide sideways across the frame " +
688
+ "in long smeared streaks. The camera stays where it is and the framing does not change. The blocks pi" +
689
+ "le up into the shapes of the second shot, still in the first shot's colours, until the true colours " +
690
+ "snap in block by block. The shot ends on the second shot, sharp and fully resolved, with no smears o" +
691
+ "r blocks left. The smear covers the whole frame",
692
+ },
693
+ ] as const
694
+
695
+ it.each(ROWS)("$id: the catalog hint carries the new body (heading kept where the row had one)", ({ id, heading, body }) => {
696
+ expect(getTransitionPromptHint(id)).toBe(heading + body)
697
+ })
698
+
699
+ it.each(ROWS)("$id renders `term (body)` at the tile-default levers", ({ id, term, body }) => {
700
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(`${term} (${body})`)
701
+ expect(composeTransitionHintFromConnections(id, [], [], {}, "compact", { scope: "shot" })).toBe(`${term} (${body})`)
702
+ expect(composeTransitionHintFromConnections(id, [], [])).toBe(`${term} (${body})`)
703
+ })
704
+
705
+ it.each(ROWS)("$id at middle / short / natural", ({ id, term, body }) => {
706
+ expect(composeTransitionHintFromConnections(id, [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
707
+ `${term} (${body}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
708
+ )
709
+ })
710
+
711
+ // jump-cut is a cut: the anti-blend sentence rides inside the parentheses after the body, and duration and
712
+ // intensity are dropped. The new body has a semicolon of its own; the restatement dedupe splits on commas
713
+ // only, so it is untouched, and the cut sentence still appears exactly once.
714
+ const JUMP_CUT_BODY =
715
+ "the framing, lens, and camera position stay identical across the cut while time skips abruptly forwa" +
716
+ "rd, so the subject snaps to a new position inside what still reads as one continuous shot. No leap, " +
717
+ "no run, no lunge and no motion blur between the two positions; the subject is in the old place on on" +
718
+ "e frame and already in the new place on the next"
719
+ const JUMP_CUT_CLAUSE = `jump cut (${JUMP_CUT_BODY}; ${INSTANT_CUT_CLAUSE})`
720
+
721
+ it("jump-cut: the catalog hint carries the new body after its heading", () => {
722
+ expect(getTransitionPromptHint("jump-cut")).toBe(`jump cut: ${JUMP_CUT_BODY}`)
723
+ })
724
+
725
+ it("jump-cut renders `term (body; cut sentence)` at the tile-default levers, the cut sentence once", () => {
726
+ expect(composeTransitionHintFromConnections("jump-cut", [], [], {}, "full", { scope: "shot" })).toBe(JUMP_CUT_CLAUSE)
727
+ expect(composeTransitionHintFromConnections("jump-cut", [], [], {}, "compact", { scope: "shot" })).toBe(JUMP_CUT_CLAUSE)
728
+ expect(composeTransitionHintFromConnections("jump-cut", [], [])).toBe(JUMP_CUT_CLAUSE)
729
+ expect(JUMP_CUT_CLAUSE.split(INSTANT_CUT_CLAUSE).length).toBe(2)
730
+ })
731
+
732
+ it("jump-cut at middle / short / natural keeps only the position", () => {
733
+ const levers = { position: "middle", duration: "short", intensity: "natural" } as const
734
+ expect(composeTransitionHintFromConnections("jump-cut", [], [], levers)).toBe(
735
+ `${JUMP_CUT_CLAUSE}, the transition occurs in the middle of the clip`,
736
+ )
737
+ expect(composeTransitionHintFromConnections("jump-cut", [], [], levers, "full", { scope: "shot" })).toBe(
738
+ `${JUMP_CUT_CLAUSE}, the transition occurs in the middle of this shot`,
739
+ )
740
+ })
741
+ })
742
+
743
+ describe("double-exposure body (2026-10-04: dblexp3 D1, the silhouette wording)", () => {
744
+ // The D1 draft (first written for dblexp2), byte-identical to the body inside the prompt of the take the
745
+ // row's tile shows: dblexp3 D1 (c55f1176), rendered at the tile-default levers on a dark profile
746
+ // silhouette over a near-white ground, cutting to a mountain lake. The row has no heading, the body has no
747
+ // colon (so the heading guard in transitions-instant.test.ts cannot match it), and no comma item equals the
748
+ // term. No other row changes.
749
+ const TERM = "double exposure"
750
+ const BODY =
751
+ "the first subject becomes a crisp silhouette filled solid with the second shot, while the rest of th" +
752
+ "e first shot stays untouched around it. The camera stays where it is and the framing does not change" +
753
+ ". The silhouette holds for a clear beat, both pictures plainly seen at once, then the first shot aro" +
754
+ "und it gives way to the second shot. The shot ends on the second shot alone, fully resolved"
755
+ const CLAUSE = `${TERM} (${BODY})`
756
+
757
+ it("the catalog hint is the D1 body", () => {
758
+ expect(getTransitionPromptHint("double-exposure")).toBe(BODY)
759
+ })
760
+
761
+ it("renders `term (body)` at the tile-default levers", () => {
762
+ expect(composeTransitionHintFromConnections("double-exposure", [], [], {}, "full", { scope: "shot" })).toBe(CLAUSE)
763
+ expect(composeTransitionHintFromConnections("double-exposure", [], [], {}, "compact", { scope: "shot" })).toBe(CLAUSE)
764
+ expect(composeTransitionHintFromConnections("double-exposure", [], [])).toBe(CLAUSE)
765
+ })
766
+
767
+ it("at middle / short / natural", () => {
768
+ expect(composeTransitionHintFromConnections("double-exposure", [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
769
+ `${CLAUSE}, the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
770
+ )
771
+ })
772
+ })
773
+
360
774
  describe("L1 — a cut spans nothing, so `full` adds no clause", () => {
775
+ // `long`: a duration every cut ignores. Short on `seamless-match` / `jump-match` is a blended cut, and
776
+ // its `full` (also dropped) is pinned in transitions-blended-cut.test.ts.
361
777
  it.each(INSTANT_IDS)("%s + full renders no position clause", (id) => {
362
- const out = composeTransitionHintFromConnections(id, [], [], { position: "full", duration: "short", intensity: "natural" })
778
+ const out = composeTransitionHintFromConnections(id, [], [], { position: "full", duration: "long", intensity: "natural" })
363
779
  expect(out).not.toContain("spans")
364
780
  expect(out).toBe(composeTransitionHintFromConnections(id, [], []))
365
781
  })
@@ -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
+ })
package/src/hint-join.ts CHANGED
@@ -13,7 +13,9 @@
13
13
  * approved, and a user's preText/postText is never rewritten.
14
14
  */
15
15
 
16
- const ENDS_SENTENCE = /[.!?]$/
16
+ /** A piece that already closes a sentence: a Latin or CJK full stop, question
17
+ * or exclamation mark, or an ellipsis. */
18
+ const ENDS_SENTENCE = /[.!?…。!?]$/
17
19
 
18
20
  export function joinHintFragments(fragments: ReadonlyArray<string>): string {
19
21
  let out = ""
@@ -24,3 +26,33 @@ export function joinHintFragments(fragments: ReadonlyArray<string>): string {
24
26
  }
25
27
  return out
26
28
  }
29
+
30
+ /**
31
+ * Join prompt pieces as sentences: `". "` between two pieces, or a single
32
+ * space when the piece before already ends a sentence. Without that check a
33
+ * prompt typed with a closing period came out as
34
+ * "no watermark.. butterfly portrait lighting". Blank pieces are dropped. The
35
+ * pieces are otherwise kept verbatim, except that trailing space before a join
36
+ * is not carried into it ("cat " + "hint" gives "cat. hint").
37
+ */
38
+ export function joinSentences(pieces: ReadonlyArray<string | undefined>): string {
39
+ let out = ""
40
+ for (const piece of pieces) {
41
+ if (!piece || !piece.trim()) continue
42
+ if (!out) { out = piece; continue }
43
+ const head = out.trimEnd()
44
+ out = ENDS_SENTENCE.test(head) ? `${head} ${piece}` : `${head}. ${piece}`
45
+ }
46
+ return out
47
+ }
48
+
49
+ /**
50
+ * Append wired picker hints to a prompt, the way both DAG engines fold them:
51
+ * the hints join as clauses (`joinHintFragments`), then follow the prompt as a
52
+ * new sentence (`joinSentences`). No hints (or only blank ones) gives the
53
+ * prompt back unchanged.
54
+ */
55
+ export function appendPromptHints(prompt: string | undefined, hints: ReadonlyArray<string>): string {
56
+ const joined = joinHintFragments(hints)
57
+ return joined.trim() ? joinSentences([prompt, joined]) : (prompt ?? "")
58
+ }
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export * from "./identity-lock.js"
14
14
  export * from "./reference-rules.js"
15
15
  export * from "./term.js"
16
16
  export * from "./parameter-prompt-hint.js"
17
+ export * from "./video-own-motion.js"
17
18
  export * from "./entity-prompts.js"
18
19
  export * from "./brand-tokens.js"
19
20
  export * from "./described-references.js"
@@ -36,7 +36,7 @@ import { buildStylingHints } from "./styling.js"
36
36
  import { buildTemporalHints } from "./temporal.js"
37
37
  import { composeCameraMotionHintFromConnections } from "./camera-motions.js"
38
38
  import { joinHintFragments } from "./hint-join.js"
39
- import { composeTransitionHintFromConnections, type TransitionDuration, type TransitionIntensity, type TransitionPosition, type TransitionTiming } from "./transitions.js"
39
+ import { composeTransitionHintFromConnections, readTransitionOptionValues, type TransitionDuration, type TransitionIntensity, type TransitionPosition, type TransitionTiming } from "./transitions.js"
40
40
  import { composeCharacterFxHintFromConnections, type CharacterFxDuration, type CharacterFxIntensity, type CharacterFxPosition, type CharacterFxTiming } from "./character-fx.js"
41
41
  import { composeCharacterMotionHintFromConnections, type CharacterMotionPace, type CharacterMotionPosition, type CharacterMotionTiming } from "./character-motion.js"
42
42
  import { containsMinorAgeHint, isMinorAge } from "./age-floor.js"
@@ -158,6 +158,14 @@ function byMode<T>(mode: PickerHintMode, full: T, compact: T): T {
158
158
  return mode === "compact" ? compact : full
159
159
  }
160
160
 
161
+ /** How much a shot moves (the Motion node's three steps), as the clause it
162
+ * adds to a video prompt; the compact form is the bare term. */
163
+ const MOTION_INTENSITY_HINTS: Readonly<Record<string, { readonly full: string; readonly compact: string }>> = {
164
+ subtle: { full: "subtle, gentle motion with slow, minimal movement", compact: "subtle motion" },
165
+ moderate: { full: "moderate, natural motion at an even pace", compact: "moderate motion" },
166
+ dynamic: { full: "dynamic, energetic motion with fast, pronounced movement", compact: "dynamic motion" },
167
+ }
168
+
161
169
  /**
162
170
  * Dispatch by parameter-node type to its prompt-hint string. For camera-motion,
163
171
  * pass `ctx` to include the composed start/end clauses; otherwise only the
@@ -227,8 +235,11 @@ function resolveParameterHint(
227
235
  duration: asStr(data.duration) as TransitionDuration | undefined,
228
236
  intensity: asStr(data.intensity) as TransitionIntensity | undefined,
229
237
  }
238
+ // A picked row's own options (a wipe's `wipeDirection`), off the same node data.
239
+ const optionValues = readTransitionOptionValues(data)
240
+ const hintOptions = optionValues ? { optionValues } : undefined
230
241
  if (!ctx) {
231
- return withCustomText(data, composeTransitionHintFromConnections(transitionId, [], [], timing, mode))
242
+ return withCustomText(data, composeTransitionHintFromConnections(transitionId, [], [], timing, mode, hintOptions))
232
243
  }
233
244
  const startHints: string[] = []
234
245
  const endHints: string[] = []
@@ -241,7 +252,7 @@ function resolveParameterHint(
241
252
  if (edge.targetHandle === "startState") startHints.push(hint)
242
253
  else if (edge.targetHandle === "endState") endHints.push(hint)
243
254
  }
244
- return withCustomText(data, composeTransitionHintFromConnections(transitionId, startHints, endHints, timing, mode))
255
+ return withCustomText(data, composeTransitionHintFromConnections(transitionId, startHints, endHints, timing, mode, hintOptions))
245
256
  }
246
257
 
247
258
  if (node.type === "character-fx") {
@@ -409,6 +420,12 @@ function resolveBaseHint(
409
420
  return withCustomText(data, byMode(mode, getCompositionEffectPromptHint, getCompositionEffectTerm)(asStr(data.compositionEffect)))
410
421
  case "post-process-effects":
411
422
  return withCustomText(data, buildPostProcessHints(data.postProcess, mode).join(", "))
423
+ // Motion intensity: a Generation Settings node that a video node's
424
+ // Settings input takes as a prompt fragment (video only).
425
+ case "motion": {
426
+ const hint = MOTION_INTENSITY_HINTS[asStr(data.motion)]
427
+ return hint ? byMode(mode, hint.full, hint.compact) : ""
428
+ }
412
429
 
413
430
  // Free text authored by the user, not catalog copy — there is no shorter
414
431
  // professional form to swap in, so these are identical in both modes.
package/src/person.ts CHANGED
@@ -1060,7 +1060,8 @@ export interface PersonValue {
1060
1060
  /** Specific age in years. Only consulted when `age === "age-custom"`. */
1061
1061
  customAge?: number
1062
1062
  /** Single id, or an array of up to 2 ids for mixed heritage (e.g.
1063
- * ["slavic","mediterranean"] → "of mixed Slavic and Mediterranean heritage"). */
1063
+ * ["slavic","mediterranean"] → "of mixed Slavic Eastern European and
1064
+ * Mediterranean heritage"). */
1064
1065
  ethnicity?: string | ReadonlyArray<string>
1065
1066
  /** Regional / cultural aesthetic vibe (e.g. `"cali-beach"`, `"parisienne"`,
1066
1067
  * `"kinshasa-sape"`). Composes with ethnicity, skin tone, hair, and
@@ -1238,16 +1239,31 @@ function normalizePickIds(value: unknown): string[] {
1238
1239
  */
1239
1240
  type PersonFragmentFor = (id: string | undefined | null) => string
1240
1241
 
1242
+ /**
1243
+ * The heritage an ethnicity entry names, as a prompt-safe noun: its promptHint
1244
+ * without the "of … descent" / "… heritage" wrapper ("of Mediterranean descent"
1245
+ * → "Mediterranean"). NOT the short label — that is display copy for the
1246
+ * picker grid ("Mediter.", "Pacific Isl.", "any", a bare "East"), which is what
1247
+ * two picks used to put in the prompt ("of mixed Slavic and Mediter. heritage").
1248
+ */
1249
+ function heritageNoun(id: string): string | undefined {
1250
+ const noun = (getPerson(id)?.promptHint ?? "")
1251
+ .trim()
1252
+ .replace(/^of\s+/i, "")
1253
+ .replace(/\s+(descent|heritage)$/i, "")
1254
+ .trim()
1255
+ return noun.length > 0 ? noun : undefined
1256
+ }
1257
+
1241
1258
  function buildEthnicityFragment(value: unknown, fragmentFor: PersonFragmentFor): string {
1242
- const ids = normalizePickIds(value)
1259
+ // "mixed" is itself the mixed-heritage pick; paired with a specific one it
1260
+ // adds nothing, so the pair reads as that one.
1261
+ const ids = normalizePickIds(value).filter((id, _, all) => all.length < 2 || id !== "mixed")
1243
1262
  if (ids.length === 0) return ""
1244
1263
  if (ids.length === 1) return fragmentFor(ids[0])
1245
- const labels = ids
1246
- .slice(0, 2)
1247
- .map((id) => getPerson(id)?.shortLabel ?? getPerson(id)?.label ?? "")
1248
- .filter((s): s is string => Boolean(s))
1249
- if (labels.length < 2) return fragmentFor(ids[0])
1250
- return `of mixed ${labels[0]} and ${labels[1]} heritage`
1264
+ const nouns = ids.slice(0, 2).map(heritageNoun).filter((s): s is string => Boolean(s))
1265
+ if (nouns.length < 2) return fragmentFor(ids[0])
1266
+ return `of mixed ${nouns[0]} and ${nouns[1]} heritage`
1251
1267
  }
1252
1268
 
1253
1269
  /**