@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.
- package/dist/index.cjs +915 -603
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +251 -17
- package/dist/index.d.ts +251 -17
- package/dist/index.js +896 -604
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/fixtures/blended-cut-approved.json +712 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +33 -1
- package/src/__tests__/hint-join.test.ts +64 -1
- package/src/__tests__/motion-hint.test.ts +20 -0
- package/src/__tests__/parameter-registry-sync.test.ts +1 -0
- package/src/__tests__/person-mixed-heritage.test.ts +43 -0
- package/src/__tests__/resolve-prompt.test.ts +13 -1
- package/src/__tests__/transition-options.test.ts +223 -0
- package/src/__tests__/transition-styles.test.ts +202 -0
- package/src/__tests__/transition-timing-catalogs.test.ts +41 -0
- package/src/__tests__/transitions-blended-cut.test.ts +325 -0
- package/src/__tests__/transitions-instant.test.ts +9 -5
- package/src/__tests__/transitions-scope.test.ts +420 -4
- package/src/__tests__/video-own-motion.test.ts +26 -0
- package/src/hint-join.ts +33 -1
- package/src/index.ts +1 -0
- package/src/parameter-prompt-hint.ts +20 -3
- package/src/person.ts +24 -8
- package/src/picker-art/character-art-files.generated.ts +536 -536
- package/src/picker-catalogs.ts +83 -7
- package/src/prompt-builder.ts +2 -1
- package/src/prompt-hint-join.ts +8 -2
- package/src/prompt-style-section.ts +3 -5
- package/src/resolve-prompt.ts +22 -4
- package/src/transitions.ts +408 -52
- 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
|
|
24
|
-
"
|
|
25
|
-
"
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1246
|
-
|
|
1247
|
-
|
|
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
|
/**
|