@nodaro/prompts 1.23.0 → 1.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +63 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +297 -235
- package/dist/index.d.ts +297 -235
- package/dist/index.js +62 -37
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/assemble-video-input.test.ts +20 -12
- package/src/__tests__/camera-motion-approved.test.ts +46 -0
- package/src/__tests__/camera-motions-from-connections.test.ts +3 -2
- package/src/__tests__/character-fx-timing-catalogs.test.ts +3 -2
- package/src/__tests__/fixtures/camera-motion-approved.json +330 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +15 -15
- package/src/__tests__/hint-join.test.ts +3 -2
- package/src/__tests__/parameter-hint-mode.test.ts +17 -5
- package/src/__tests__/prompt-style-section.test.ts +2 -2
- package/src/__tests__/transitions-instant.test.ts +147 -15
- package/src/__tests__/transitions-scope.test.ts +168 -0
- package/src/__tests__/transitions.test.ts +9 -9
- package/src/camera-motions.ts +21 -21
- package/src/direction-registry.ts +2 -2
- package/src/picker-catalogs.ts +4 -4
- package/src/transitions.ts +171 -31
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { TRANSITIONS, TRANSITION_IDS, TRANSITION_CATEGORY_ORDER, TRANSITION_CATEGORY_LABELS, composeTransitionHintFromConnections, getTransition, getTransitionLabel, getTransitionPromptHint } from "../transitions.js"
|
|
2
|
+
import { TRANSITIONS, TRANSITION_IDS, TRANSITION_CATEGORY_ORDER, TRANSITION_CATEGORY_LABELS, composeTransitionHintFromConnections, getTransition, getTransitionLabel, getTransitionPromptHint, renderTransitionBases } from "../transitions.js"
|
|
3
3
|
|
|
4
4
|
describe("transitions catalog", () => {
|
|
5
5
|
it("ships 82 unique entries", () => {
|
|
@@ -74,9 +74,9 @@ describe("getTransition / getTransitionLabel / getTransitionPromptHint", () => {
|
|
|
74
74
|
})
|
|
75
75
|
|
|
76
76
|
describe("composeTransitionHintFromConnections — single-pick", () => {
|
|
77
|
-
it("returns
|
|
77
|
+
it("returns `term (hint)` when no connections + no timing", () => {
|
|
78
78
|
const r = composeTransitionHintFromConnections("cross-dissolve", [], [])
|
|
79
|
-
expect(r).toBe(getTransitionPromptHint("cross-dissolve"))
|
|
79
|
+
expect(r).toBe(`cross-dissolve (${getTransitionPromptHint("cross-dissolve")})`)
|
|
80
80
|
})
|
|
81
81
|
|
|
82
82
|
it("returns empty when id is undefined / 'auto' / unknown", () => {
|
|
@@ -168,11 +168,12 @@ describe("composeTransitionHintFromConnections — multi-pick", () => {
|
|
|
168
168
|
expect(scalar).toBe(array)
|
|
169
169
|
})
|
|
170
170
|
|
|
171
|
-
it("joins two
|
|
171
|
+
it("joins two `term (hint)` fragments with ', and '", () => {
|
|
172
172
|
const r = composeTransitionHintFromConnections(["smash-cut", "white-flash"], [], [])
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
173
|
+
expect(r).toBe(
|
|
174
|
+
"smash cut (an abrupt jarring transition between two visually or tonally contrasting shots with no fade, on a beat)" +
|
|
175
|
+
", and white flash (" + getTransitionPromptHint("white-flash") + ")",
|
|
176
|
+
)
|
|
176
177
|
})
|
|
177
178
|
|
|
178
179
|
it("dedupes duplicate ids", () => {
|
|
@@ -182,8 +183,7 @@ describe("composeTransitionHintFromConnections — multi-pick", () => {
|
|
|
182
183
|
})
|
|
183
184
|
|
|
184
185
|
it("caps at 2 ids — extra ids dropped", () => {
|
|
185
|
-
const a =
|
|
186
|
-
const b = getTransitionPromptHint("white-flash")
|
|
186
|
+
const [a, b] = renderTransitionBases(["smash-cut", "white-flash"])
|
|
187
187
|
const r = composeTransitionHintFromConnections(
|
|
188
188
|
["smash-cut", "white-flash", "fade-to-black", "wipe"],
|
|
189
189
|
[],
|
package/src/camera-motions.ts
CHANGED
|
@@ -53,7 +53,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
53
53
|
label: "Static",
|
|
54
54
|
category: "default",
|
|
55
55
|
description: "Fixed camera, no movement",
|
|
56
|
-
promptHint: "locked
|
|
56
|
+
promptHint: "The camera is locked on a solid tripod and does not move at all for the entire shot: it never travels, never rotates, never tilts, never changes focal length. The viewpoint is completely fixed and the framing stays exactly the same from the first frame to the last.\nLife within the scene carries on naturally, but the camera holds perfectly still throughout.\nNo camera movement of any kind, no drift, no sway, no zoom.",
|
|
57
57
|
term: "locked-off static camera",
|
|
58
58
|
},
|
|
59
59
|
{
|
|
@@ -61,7 +61,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
61
61
|
label: "Handheld",
|
|
62
62
|
category: "default",
|
|
63
63
|
description: "Natural handheld shake",
|
|
64
|
-
promptHint: "
|
|
64
|
+
promptHint: "The camera is HANDHELD, held in an operator's hands rather than on any support, so it carries the constant small live motion of a real person holding it: a continuous gentle shake and wobble, tiny irregular drifts and corrections in every direction, a slight bob in time with breathing, never settling completely still even for a moment.\nThe motion is small and organic, never a large or deliberate move: the camera stays roughly where it is and keeps roughly the same framing, it simply never locks off. It does not travel toward anything, away from anything, or off to any side; it holds its position and only jitters around it. The unsteadiness is human and slightly random, not a smooth mechanical sway and not a rhythmic loop.\nThe lens does not change and the camera does not travel anywhere, orbit, or turn to a new direction; it just floats and jitters in place as if held.\nThe framing stays broadly the same across the shot, loosely on the subject, wandering by only small amounts.\nNo zoom, no deliberate camera move, no forward or backward travel, no rotation to a new angle, no smooth glide.",
|
|
65
65
|
term: "handheld camera",
|
|
66
66
|
},
|
|
67
67
|
{
|
|
@@ -69,7 +69,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
69
69
|
label: "Steadicam",
|
|
70
70
|
category: "default",
|
|
71
71
|
description: "Smooth stabilized walking shot",
|
|
72
|
-
promptHint: "smooth
|
|
72
|
+
promptHint: "The camera moves with the perfectly smooth, floating quality of a Steadicam or gimbal: it glides gently and continuously, with none of the shake, jitter or bob of a handheld rig. Every bit of movement is buttery, damped and stabilised, as if the camera were floating on air.\nThe motion is a slow, graceful drift through the scene — easing a little forward and around with fluid, gliding ease — always even and controlled, never abrupt, never trembling, never correcting.\nThe lens does not change. The framing stays loosely on the subject throughout, the glide being gentle enough that the composition holds.\nThe single defining quality is SMOOTHNESS: this is the opposite of handheld — no shake whatsoever, only silky stabilised floating motion.\nNo zoom, no jitter, no handheld shake, no abrupt moves.",
|
|
73
73
|
term: "smooth steadicam shot",
|
|
74
74
|
},
|
|
75
75
|
|
|
@@ -271,14 +271,14 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
271
271
|
label: "Dutch Angle",
|
|
272
272
|
category: "roll",
|
|
273
273
|
description: "Static tilted frame for tension",
|
|
274
|
-
promptHint: "
|
|
274
|
+
promptHint: "The shot begins level, then the camera rolls a little way around its own lens axis and STOPS at a fixed cant, holding that tilted Dutch-angle framing for the rest of the shot. It is a single small settling roll into a tilt, not a continuous spin: the frame tips perhaps fifteen or twenty degrees off level and then stays there, canted and still, for an uneasy, tense feeling.\nOnly the roll axis is involved: the camera stays in the same position, does not travel, does not pan or tilt its aim up or down, and the lens does not change. The horizon starts level and ends clearly canted, then holds.\nBecause it is a pure roll, there is no parallax: the whole frame turns as one flat picture, near and far together.\nLife in the scene carries on gently while the framing holds its tilt.\nNo zoom, no travel, no continuous spinning, no returning to level once canted.",
|
|
275
275
|
},
|
|
276
276
|
{
|
|
277
277
|
id: "spin-360",
|
|
278
278
|
label: "Full 360 Spin",
|
|
279
279
|
category: "roll",
|
|
280
280
|
description: "Camera rotates a full 360° on its axis",
|
|
281
|
-
promptHint: "
|
|
281
|
+
promptHint: "The camera rotates a FULL 360 degrees around its own lens axis in one continuous even spin, and keeps turning the same way without pausing until the picture has come all the way back to level exactly as it started. The horizon sweeps from level, round through fully sideways on one side, through completely upside-down, through fully sideways on the other side, and back to level — one complete unbroken revolution.\nOnly the roll axis is involved: the camera stays in exactly the same position, does not travel, does not pan or tilt its aim, and the lens does not change. It spins about the axis running straight out through the lens.\nBecause it is a pure roll, there is no parallax: the whole frame turns as one flat picture, near and far rotating together by the same amount.\nThe spin is smooth and continuous at an even speed, going round only once and ending back at level.\nNo zoom, no travel, no pan, no tilt of the aim, no reversal of the spin direction.",
|
|
282
282
|
term: "full 360 degree camera roll",
|
|
283
283
|
},
|
|
284
284
|
|
|
@@ -403,7 +403,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
403
403
|
label: "Ronin Glide",
|
|
404
404
|
category: "tracking",
|
|
405
405
|
description: "Slow gliding move on a Ronin/Movi gimbal",
|
|
406
|
-
promptHint: "ronin glide,
|
|
406
|
+
promptHint: "ronin gimbal glide, ultra-smooth floating tracking movement following the subject",
|
|
407
407
|
term: "slow gliding gimbal move",
|
|
408
408
|
},
|
|
409
409
|
{
|
|
@@ -411,7 +411,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
411
411
|
label: "Serpentine Track",
|
|
412
412
|
category: "tracking",
|
|
413
413
|
description: "Camera weaves through obstacles in S-curves",
|
|
414
|
-
promptHint: "serpentine
|
|
414
|
+
promptHint: "serpentine tracking shot, camera weaves smoothly side to side while following the subject",
|
|
415
415
|
term: "serpentine tracking shot",
|
|
416
416
|
},
|
|
417
417
|
|
|
@@ -421,7 +421,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
421
421
|
label: "POV",
|
|
422
422
|
category: "special",
|
|
423
423
|
description: "First person point of view",
|
|
424
|
-
promptHint: "POV shot, first person perspective as seen through the subject's eyes",
|
|
424
|
+
promptHint: "POV shot, first person perspective as seen through the subject's eyes. The viewpoint moves forward at a calm, unhurried walking pace — steady and gentle, never a fast rush or a vehicle-like travel speed. No body parts, hands or feet ever enter the frame; only the point of view itself drifts smoothly forward.",
|
|
425
425
|
term: "first person pov shot",
|
|
426
426
|
},
|
|
427
427
|
{
|
|
@@ -429,7 +429,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
429
429
|
label: "Over The Shoulder",
|
|
430
430
|
category: "special",
|
|
431
431
|
description: "Frame past a character's shoulder",
|
|
432
|
-
promptHint: "
|
|
432
|
+
promptHint: "Over the shoulder shot: the camera is already framed past the near character's shoulder onto the second character from the very first frame, with no push-in or approach - the framing and distance stay constant throughout, at most a very small handheld-style settle. No camera travel toward or away from either character. No dialogue, no spoken words, no lip movement suggesting speech - completely silent, ambient sound only if any.",
|
|
433
433
|
term: "over-the-shoulder shot",
|
|
434
434
|
},
|
|
435
435
|
{
|
|
@@ -453,7 +453,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
453
453
|
label: "Aerial",
|
|
454
454
|
category: "special",
|
|
455
455
|
description: "High altitude drone-style shot",
|
|
456
|
-
promptHint: "
|
|
456
|
+
promptHint: "The camera is airborne and sinks straight downward through the air, losing altitude like an aircraft lowering itself vertically. Its aim is completely fixed: the horizon line stays at exactly the same height across the frame from the first moment to the last, and the camera never dips or tips its view downward at all — what it points at does not change, only where it is in space.\nAs the camera drops in height, everything in view slides UPWARD through the frame together because the viewpoint is lower: whatever stands tall rises up the frame and grows, and more of what lies far below climbs into view from the bottom. This vertical sliding of the whole scene is the ONLY change; the angle of view is frozen.\nNo tilting, no dipping the aim down, no looking further down, no rotation, no forward travel — the horizon must remain pinned at its starting height while the camera simply loses altitude.",
|
|
457
457
|
term: "high altitude aerial drone shot",
|
|
458
458
|
},
|
|
459
459
|
{
|
|
@@ -461,7 +461,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
461
461
|
label: "Helicopter",
|
|
462
462
|
category: "special",
|
|
463
463
|
description: "Wide high-altitude sweeping aerial",
|
|
464
|
-
promptHint: "helicopter shot,
|
|
464
|
+
promptHint: "helicopter shot, sweeping aerial movement banking around the scene",
|
|
465
465
|
term: "sweeping helicopter aerial shot",
|
|
466
466
|
},
|
|
467
467
|
{
|
|
@@ -469,7 +469,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
469
469
|
label: "Fly Over",
|
|
470
470
|
category: "special",
|
|
471
471
|
description: "Low fast aerial pass over the scene",
|
|
472
|
-
promptHint: "
|
|
472
|
+
promptHint: "The camera flies steadily FORWARD through the air at a constant height, cruising ahead toward what lies in front of it without ever changing altitude. Its height stays exactly the same from start to finish — it does not descend, does not climb, does not dip its aim; it simply travels forward at cruising speed.\nBecause it moves forward, whatever lies ahead grows closer and sweeps toward the camera and past it on both sides, near things rushing by faster than far things. The horizon holds at exactly the same height in the frame the whole time.\nNo descending, no climbing, no tilting the aim, no rotation — only steady level forward flight at a fixed altitude.",
|
|
473
473
|
term: "low aerial fly-over pass",
|
|
474
474
|
},
|
|
475
475
|
{
|
|
@@ -501,7 +501,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
501
501
|
label: "Rack Focus",
|
|
502
502
|
category: "special",
|
|
503
503
|
description: "Pull focus between foreground and background",
|
|
504
|
-
promptHint: "
|
|
504
|
+
promptHint: "The camera itself never moves, never zooms and never changes its framing — it is completely locked in place for the whole shot. Only the lens's focus plane shifts. It starts sharply focused on the nearest element in the frame, with everything further back a soft out-of-focus blur. Then the focus pulls smoothly across to the far background, which becomes crisp and sharp, while the near foreground that was sharp a moment ago now falls out of focus and blurs instead. The two states — near-sharp/far-blurred, then near-blurred/far-sharp — must read as clearly opposite. No camera travel, no zoom, no change in composition — only which plane is sharp changes.",
|
|
505
505
|
},
|
|
506
506
|
|
|
507
507
|
// Modern / social-video vocabulary
|
|
@@ -510,7 +510,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
510
510
|
label: "Handheld Vlog",
|
|
511
511
|
category: "default",
|
|
512
512
|
description: "Casual vlog-style handheld",
|
|
513
|
-
promptHint: "casual handheld vlog
|
|
513
|
+
promptHint: "The camera is a casual handheld vlog camera, held at arm's length by the subject or a companion, with the loose, informal quality of someone filming themselves. It has clear handheld shake and small bobbing motion, but looser and more wandering than a careful operator: it drifts around a bit, reframes casually, bobs as if the holder is shifting their weight or gesturing.\nThe framing sits fairly close and personal, roughly on the subject, wandering by moderate amounts as a vlogger's arm naturally would — informal and lively, not locked or composed.\nThe motion has personality and looseness: more movement than a careful handheld, unpolished and spontaneous, but it stays broadly in place rather than travelling off anywhere.\nThe lens does not change; the camera does not glide smoothly (that would be a steadicam) and does not travel deliberately in any direction.\nNo zoom, no smooth stabilised glide, no deliberate directional move, no tripod stillness.",
|
|
514
514
|
term: "handheld vlog-style camera",
|
|
515
515
|
},
|
|
516
516
|
{
|
|
@@ -518,7 +518,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
518
518
|
label: "POV Walk",
|
|
519
519
|
category: "tracking",
|
|
520
520
|
description: "First-person walking POV",
|
|
521
|
-
promptHint: "first-person POV walking
|
|
521
|
+
promptHint: "first-person POV walking shot, camera as the subject's eyes with natural head-bob as they walk forward",
|
|
522
522
|
term: "first person walking pov",
|
|
523
523
|
},
|
|
524
524
|
{
|
|
@@ -541,23 +541,23 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
541
541
|
label: "Screen Tap",
|
|
542
542
|
category: "special",
|
|
543
543
|
description: "On-screen finger-tap transition",
|
|
544
|
-
promptHint: "
|
|
545
|
-
term: "
|
|
544
|
+
promptHint: "An abrupt hard snap-cut transition: the camera instantly jumps to a new framing of the scene with no gradual movement in between, a fast decisive jolt rather than any smooth pan, push or travel. No object, hand, finger, or UI element ever appears in the frame. TikTok-native snap pacing.",
|
|
545
|
+
term: "abrupt snap-cut transition",
|
|
546
546
|
},
|
|
547
547
|
{
|
|
548
548
|
id: "phone-flip",
|
|
549
549
|
label: "Phone Flip",
|
|
550
550
|
category: "special",
|
|
551
551
|
description: "Front/rear camera flip",
|
|
552
|
-
promptHint: "
|
|
553
|
-
term: "
|
|
552
|
+
promptHint: "A rapid flip-transition, functioning as a hard cut rather than a smooth continuous rotation: the entire frame snaps through one quick full rotation in a fraction of a second, with strong motion blur through the spin, then lands upright again immediately on a new framing of the scene. This is abrupt and near-instant, never a slow, smooth, or continuously spinning carousel-like rotation. No object, hand, phone, or UI element ever appears in the frame.",
|
|
553
|
+
term: "fast flip-cut transition",
|
|
554
554
|
},
|
|
555
555
|
{
|
|
556
556
|
id: "gentle-drift",
|
|
557
557
|
label: "Gentle Drift",
|
|
558
558
|
category: "default",
|
|
559
559
|
description: "Slow ambient floating motion",
|
|
560
|
-
promptHint: "
|
|
560
|
+
promptHint: "The camera drifts with an extremely slow, gentle, ambient floating motion, as if suspended and barely stirring in still air. There is no shake and no deliberate direction: it simply floats, drifting a tiny amount one way and then easing another, aimless and dreamlike.\nThe movement is minimal and continuous — the camera never sits perfectly still, but it never travels anywhere either; it hovers almost in place, wandering by only the smallest amounts, smooth and weightless throughout.\nThe pace is contemplative and calm, the drift so slow it is only just perceptible. The picture stays sharp; there is no motion blur.\nThe lens does not change and the framing stays broadly the same, resting loosely on the subject.\nNo zoom, no shake, no jitter, no deliberate directional move, no travel across the scene.",
|
|
561
561
|
term: "slow gentle camera drift",
|
|
562
562
|
},
|
|
563
563
|
{
|
|
@@ -565,7 +565,7 @@ export const CAMERA_MOTIONS: ReadonlyArray<CameraMotion> = [
|
|
|
565
565
|
label: "Parallax",
|
|
566
566
|
category: "default",
|
|
567
567
|
description: "Lateral motion with foreground/background depth separation",
|
|
568
|
-
promptHint: "
|
|
568
|
+
promptHint: "The camera makes a small, smooth sideways move that shows off the depth of the scene through parallax. It eases gently a short way across, staying at the same distance and keeping its aim forward.\nThe defining quality is a STRONG parallax gradient: the nearest foreground elements slide across noticeably and quickly, the mid-distance elements shift more gently, and the most distant elements barely move at all — one smooth continuous gradient of speed from near to far that makes the separation between the layers obvious. Nothing is frozen; everything shifts, just by very different amounts according to its distance.\nThe move is modest and ambient rather than a full travelling shot: a gentle reveal of depth, not a journey across the scene.\nThe lens does not change; the camera does not push in, pull back, rotate, or shake.\nNo zoom, no forward or backward travel, no rotation, no handheld shake.",
|
|
569
569
|
term: "lateral parallax camera move",
|
|
570
570
|
},
|
|
571
571
|
]
|
|
@@ -67,7 +67,7 @@ import { getEraPromptHint, getEraTerm } from "./era.js"
|
|
|
67
67
|
import { getBackdropPromptHint, getBackdropTerm } from "./backdrop.js"
|
|
68
68
|
import { buildActionFxHints } from "./action-fx.js"
|
|
69
69
|
import { getTemporalPromptHint, getTemporalTerm } from "./temporal.js"
|
|
70
|
-
import {
|
|
70
|
+
import { renderTransitionBases } from "./transitions.js"
|
|
71
71
|
import { getLoopSubjectPromptHint, getLoopSubjectTerm } from "./loop-subject.js"
|
|
72
72
|
|
|
73
73
|
/** Which generation stages fold a dimension. */
|
|
@@ -251,7 +251,7 @@ export const DIRECTION_FIELDS = [
|
|
|
251
251
|
{ key: "temporalFreeze", surface: "video", family: "motion", maxPicks: 1, render: temporal },
|
|
252
252
|
{ key: "temporalDirection", surface: "video", family: "motion", maxPicks: 1, render: temporal },
|
|
253
253
|
{ key: "temporalShutter", surface: "video", family: "motion", maxPicks: 1, render: temporal },
|
|
254
|
-
{ key: "transition", surface: "video", family: "motion", maxPicks: 2, render:
|
|
254
|
+
{ key: "transition", surface: "video", family: "motion", maxPicks: 2, render: renderTransitionBases },
|
|
255
255
|
{ key: "loopSubject", surface: "video", family: "motion", maxPicks: 1, render: perId(getLoopSubjectPromptHint, getLoopSubjectTerm) },
|
|
256
256
|
|
|
257
257
|
// ── LEGACY BLOCK — see the table doc above. Placed LAST, in today's exact
|
package/src/picker-catalogs.ts
CHANGED
|
@@ -121,10 +121,10 @@ export interface PickerOption {
|
|
|
121
121
|
readonly term: string
|
|
122
122
|
/**
|
|
123
123
|
* Transitions only: present (`true`) on a row whose mechanism is a CUT, so
|
|
124
|
-
* it takes no duration — see `Transition.instant` /
|
|
125
|
-
* A consumer that only reads this wire catalog
|
|
126
|
-
*
|
|
127
|
-
* such a row. Carried from the base catalog; a row a catalog pack ADDS has
|
|
124
|
+
* it takes no duration and no intensity — see `Transition.instant` /
|
|
125
|
+
* `isInstantTransition`. A consumer that only reads this wire catalog
|
|
126
|
+
* (Studio builds the transition Duration lever from
|
|
127
|
+
* `getPickerCatalog("transition")`) hides those levers for such a row. Carried from the base catalog; a row a catalog pack ADDS has
|
|
128
128
|
* it only when the pack's own option says so, and absent means "has a
|
|
129
129
|
* duration" — the safe reading, since the composer then behaves as before.
|
|
130
130
|
*/
|
package/src/transitions.ts
CHANGED
|
@@ -48,9 +48,21 @@ export interface Transition {
|
|
|
48
48
|
* second on the change, and it obliges with a dissolve: a match cut rendered
|
|
49
49
|
* as a 1.75 s cross-dissolve in QA. The composer therefore skips the duration
|
|
50
50
|
* lever when every picked transition is instant, and consumers (the picker
|
|
51
|
-
* UI, Studio) read `isInstantTransition` to hide that lever.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
51
|
+
* UI, Studio) read `isInstantTransition` to hide that lever.
|
|
52
|
+
*
|
|
53
|
+
* The same holds for INTENSITY: every intensity clause describes how the
|
|
54
|
+
* change PERFORMS over time ("natural unhurried timing", "wild flourishes and
|
|
55
|
+
* dramatic distortion"), and a cut has no performance to shape — "unhurried"
|
|
56
|
+
* on a match cut is another invitation to blend. So the composer drops the
|
|
57
|
+
* intensity clause too when every pick is instant. Position still applies —
|
|
58
|
+
* WHERE the cut lands is a real choice — except `full`: a single-frame cut
|
|
59
|
+
* cannot "span the entire clip", so that clause is dropped on an all-instant
|
|
60
|
+
* pick (start / middle / end still place the cut).
|
|
61
|
+
*
|
|
62
|
+
* The bare term is not enough on its own either: "match cut" still came back
|
|
63
|
+
* as a ~1 s superimposition in prod QA (seedance-2-5). The composer therefore
|
|
64
|
+
* puts `INSTANT_CUT_CLAUSE` inside an all-instant pick's parentheses, once — the explicit
|
|
65
|
+
* anti-blend instruction that made the model render a true single-frame cut.
|
|
54
66
|
*/
|
|
55
67
|
readonly instant?: boolean
|
|
56
68
|
}
|
|
@@ -121,11 +133,11 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
121
133
|
{ id: "seasonal-shift", label: "Seasonal Shift", category: "time", description: "Same scene through changing seasons",
|
|
122
134
|
promptHint: "accelerated seasonal time-lapse: foliage transitions from spring green to summer lushness to autumn red-gold to winter bare, leaves fall and regrow, snow accumulates and melts, all within the same locked framing", term: "seasonal time-lapse" },
|
|
123
135
|
{ id: "aging", label: "Aging", category: "time", description: "Subject visibly ages forward in time",
|
|
124
|
-
promptHint: "accelerated aging transition: the subject visibly ages forward
|
|
136
|
+
promptHint: "accelerated aging transition: the subject visibly ages forward - fine lines deepen into wrinkles, hair greys to silver, posture settles - while the framing stays unchanged", term: "accelerated aging" },
|
|
125
137
|
{ id: "rewind", label: "Rewind", category: "time", description: "Time reverses, motion plays backward",
|
|
126
|
-
promptHint: "rewind transition:
|
|
138
|
+
promptHint: "rewind transition: reverse motion: the actions just seen are undone exactly as they happened, in reverse order and at the same pace, the subject retracing each step to where it began, ending on the end frame", term: "reverse-motion rewind" },
|
|
127
139
|
{ id: "freeze-frame-jump", label: "Freeze-Frame Jump", category: "time", description: "Action freezes, jumps forward in time",
|
|
128
|
-
promptHint: "freeze-frame transition: motion
|
|
140
|
+
promptHint: "freeze-frame transition: all motion stops mid-action and the picture holds still for a beat; only then does it jump to the same view hours or days later, everything in new positions, and motion resumes", term: "freeze-frame time jump" },
|
|
129
141
|
{ id: "weather-shift", label: "Weather Shift", category: "time", description: "Same scene through changing weather",
|
|
130
142
|
promptHint: "accelerated weather transition: same scene, framing locked — clear sky darkens to storm clouds, rain begins and intensifies then clears, sun returns through breaking clouds", term: "weather time-lapse" },
|
|
131
143
|
{ id: "flashback", label: "Flashback", category: "time", description: "Memory-flashback into a past moment of the subject",
|
|
@@ -193,7 +205,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
193
205
|
{ id: "zoom-into-mirror", label: "Zoom Into Mirror", category: "portal", description: "Push into mirror, scene inside reflection",
|
|
194
206
|
promptHint: "the camera pushes toward a mirror in the scene, the mirror's reflection fills the frame, and the camera passes through the mirror surface into the reflected world which becomes the new scene" },
|
|
195
207
|
{ id: "zoom-into-screen", label: "Zoom Into Screen", category: "portal", description: "Push into TV/phone screen",
|
|
196
|
-
promptHint: "the camera pushes toward a screen visible in the scene
|
|
208
|
+
promptHint: "the camera pushes toward a screen visible in the scene, such as a TV, phone or monitor, the screen's image fills the frame, and the camera passes through into that image which becomes the new scene" },
|
|
197
209
|
{ id: "zoom-into-book", label: "Zoom Into Book", category: "portal", description: "Push into book page illustration",
|
|
198
210
|
promptHint: "the camera pushes down into an illustrated page in a book, the illustration grows to fill the frame, and the illustration comes alive as the new scene" },
|
|
199
211
|
{ id: "walk-through-door", label: "Walk Through Doorway", category: "portal", description: "Through doorway into new scene",
|
|
@@ -203,7 +215,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
203
215
|
{ id: "pull-out-reveal", label: "Pull-Out Reveal", category: "portal", description: "Reveals scene was a picture in larger context",
|
|
204
216
|
promptHint: "the camera pulls back rapidly and reveals that the entire first scene was actually contained within a picture, painting, screen, or window in a larger second scene", term: "pull-back reveal" },
|
|
205
217
|
{ id: "zoom-into-mouth", label: "Zoom Into Mouth", category: "portal", description: "Push into open mouth, emerges in new world inside",
|
|
206
|
-
promptHint: "the camera pushes into the subject's open mouth, the dark interior fills the frame, and the camera passes through
|
|
218
|
+
promptHint: "the camera pushes into the subject's open mouth, the dark interior fills the frame, and the camera passes through into the new scene which materialises as if emerging from inside the body" },
|
|
207
219
|
{ id: "push-through-glass", label: "Push Through Glass", category: "portal", description: "Camera pushes through pane of glass into new world",
|
|
208
220
|
promptHint: "the camera pushes toward a pane of glass in the scene, the surface ripples like liquid as the camera passes through with a faint refraction, and the space on the other side resolves as the new scene" },
|
|
209
221
|
{ id: "soul-jump", label: "Soul Jump", category: "portal", description: "Translucent soul leaves body, enters new body",
|
|
@@ -343,6 +355,99 @@ export function isInstantTransition(
|
|
|
343
355
|
return ids.every((one) => getTransition(one)?.instant === true)
|
|
344
356
|
}
|
|
345
357
|
|
|
358
|
+
/**
|
|
359
|
+
* The anti-blend instruction an all-instant transition pick carries — the ONE
|
|
360
|
+
* place this sentence lives (see `Transition.instant`). Each row keeps its own
|
|
361
|
+
* meaning in its own hint (a match cut still matches shapes, snap to black
|
|
362
|
+
* still holds black for a beat); this clause only forbids the blend a video
|
|
363
|
+
* model otherwise puts between the two images. Measured on prod with
|
|
364
|
+
* seedance-2-5: with it, a match cut renders as a true single-frame hard cut,
|
|
365
|
+
* with and without an end frame; without it, as a ~1 s dissolve.
|
|
366
|
+
*/
|
|
367
|
+
export const INSTANT_CUT_CLAUSE =
|
|
368
|
+
"an abrupt single-frame hard cut, no dissolve, crossfade or superimposition; the two images never blend"
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* A short "<label>: " heading at the head of a hint ("match cut: …", "whip pan
|
|
372
|
+
* transition: …", "fast-forward time-lapse transition: …"). At most five words
|
|
373
|
+
* with no clause punctuation, so a colon deep inside a sentence never matches.
|
|
374
|
+
* ANY such heading is stripped — including one on a catalog-pack row — because
|
|
375
|
+
* the term already names the move (documented in
|
|
376
|
+
* `docs/design/catalog-pack-seam.md` and `docs/nodes/parameters/transition.md`).
|
|
377
|
+
*/
|
|
378
|
+
const LEADING_LABEL = /^\s*((?:[^\s:,;.()]+\s+){0,4}[^\s:,;.()]+)\s*:\s+/
|
|
379
|
+
|
|
380
|
+
const normalizePhrase = (s: string): string => s.trim().toLowerCase().replace(/\s+/g, " ")
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The hint BODY that goes inside a transition's parentheses: the promptHint
|
|
384
|
+
* with its leading "<label>:" heading removed (the term already names it) and
|
|
385
|
+
* any comma-separated item that merely restates the term dropped (`none`'s
|
|
386
|
+
* "no transition, hard cut, instantaneous switch …" loses its "hard cut").
|
|
387
|
+
*/
|
|
388
|
+
function transitionHintBody(id: string, term: string): string {
|
|
389
|
+
const hint = getTransitionPromptHint(id).replace(LEADING_LABEL, "").trim()
|
|
390
|
+
const t = normalizePhrase(term)
|
|
391
|
+
return hint
|
|
392
|
+
.split(/,\s*/)
|
|
393
|
+
.filter((item) => normalizePhrase(item) !== t)
|
|
394
|
+
.join(", ")
|
|
395
|
+
.trim()
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* One picked transition as a VIDEO prompt reads it: `<term> (<hint body>)`.
|
|
400
|
+
* The term names the move in the editor's own words; the parentheses carry the
|
|
401
|
+
* model-facing mechanism, which the term alone does not convey (a bare "match
|
|
402
|
+
* cut" rendered as a dissolve on prod). `withCutClause` appends
|
|
403
|
+
* `; INSTANT_CUT_CLAUSE` inside the parentheses. A row whose hint is just its
|
|
404
|
+
* term (or empty) renders as the term alone; an unknown id or "auto" as "".
|
|
405
|
+
*/
|
|
406
|
+
function transitionFragment(id: string, withCutClause: boolean): string {
|
|
407
|
+
const term = getTransitionTerm(id)
|
|
408
|
+
if (!term) return ""
|
|
409
|
+
const inner = [transitionHintBody(id, term), withCutClause ? INSTANT_CUT_CLAUSE : ""]
|
|
410
|
+
.filter((part) => part.length > 0)
|
|
411
|
+
.join("; ")
|
|
412
|
+
return inner.length > 0 && normalizePhrase(inner) !== normalizePhrase(term)
|
|
413
|
+
? `${term} (${inner})`
|
|
414
|
+
: term
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The transition BASE fragments for a pick — one `<term> (<hint body>)` per id
|
|
419
|
+
* that resolves to a term, in pick order. When EVERY contributing id is
|
|
420
|
+
* instant, the FIRST fragment's parentheses also carry `; INSTANT_CUT_CLAUSE`
|
|
421
|
+
* (once per pick — two cuts picked together are still one cut). FIRST, not
|
|
422
|
+
* last: the direction fold sheds fragments from the TAIL under a provider cap,
|
|
423
|
+
* so the clause rides the fragment that survives longest. A mixed pick carries
|
|
424
|
+
* no clause: its non-cut is meant to blend.
|
|
425
|
+
*
|
|
426
|
+
* The same text in both hint modes. A transition only ever reaches a VIDEO
|
|
427
|
+
* prompt (the registry row is `surface: "video"`; the canvas node is in
|
|
428
|
+
* `VIDEO_ONLY_PARAMETER_NODE_TYPES`), and there the bare compact term was not
|
|
429
|
+
* enough to steer the model, so the mode no longer changes a transition.
|
|
430
|
+
*
|
|
431
|
+
* Each fragment is ONE string, parentheses included: the cap-aware assemblers
|
|
432
|
+
* shed whole fragments from the tail, so a fragment is kept or dropped whole
|
|
433
|
+
* and can never lose its hint or its clause on its own.
|
|
434
|
+
*
|
|
435
|
+
* Shared by `composeTransitionHintFromConnections` (the canvas transition node,
|
|
436
|
+
* Studio's transition clauses) and the direction registry's `transition` row
|
|
437
|
+
* (the server fold of `direction.transition`), so both paths word a transition
|
|
438
|
+
* identically.
|
|
439
|
+
*/
|
|
440
|
+
export function renderTransitionBases(
|
|
441
|
+
ids: ReadonlyArray<string>,
|
|
442
|
+
_mode: PickerHintMode = "full",
|
|
443
|
+
): string[] {
|
|
444
|
+
// Only ids that contribute a fragment count — a no-op "auto" beside a cut
|
|
445
|
+
// must not make the pick look non-instant.
|
|
446
|
+
const picked = ids.filter((id) => getTransitionTerm(id).length > 0)
|
|
447
|
+
const instant = isInstantTransition(picked)
|
|
448
|
+
return picked.map((id, i) => transitionFragment(id, instant && i === 0))
|
|
449
|
+
}
|
|
450
|
+
|
|
346
451
|
// ---------------------------------------------------------------------------
|
|
347
452
|
// Graph-aware composer — start/end input handles + timing fields + multi-pick
|
|
348
453
|
// ---------------------------------------------------------------------------
|
|
@@ -388,7 +493,7 @@ export const TRANSITION_DURATIONS = [
|
|
|
388
493
|
export const TRANSITION_INTENSITIES = [
|
|
389
494
|
{ id: "auto", label: "Auto", description: "Let the model judge it", promptHint: "", term: "" },
|
|
390
495
|
{ id: "subtle", label: "Subtle", description: "Restrained, minimal flourish", promptHint: "with subtle restrained energy and minimal flourish", term: "subtly" },
|
|
391
|
-
{ id: "natural", label: "Natural", description: "Unhurried, unforced timing", promptHint: "with natural
|
|
496
|
+
{ id: "natural", label: "Natural", description: "Unhurried, unforced timing", promptHint: "with natural timing", term: "at a natural pace" },
|
|
392
497
|
{ id: "dynamic", label: "Dynamic", description: "Assertive, energetic", promptHint: "with dynamic energy and assertive flourish", term: "energetically" },
|
|
393
498
|
{ id: "crazy", label: "Crazy", description: "Extreme, wild, distorted", promptHint: "with extreme exaggerated energy, wild flourishes, and dramatic distortion", term: "wildly exaggerated" },
|
|
394
499
|
] as const satisfies ReadonlyArray<TransitionTimingOption>
|
|
@@ -411,6 +516,35 @@ function clausesOf<T extends TransitionTimingOption>(
|
|
|
411
516
|
}
|
|
412
517
|
|
|
413
518
|
const POSITION_CLAUSES = clausesOf(TRANSITION_POSITIONS)
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Where the composed hint lands. `"clip"` (the default) is a whole video's
|
|
522
|
+
* prompt, so a position is placed within "the clip". `"shot"` is one shot's
|
|
523
|
+
* time window inside a multi-shot prompt (`0-2s — …`, `2-4s — …`): there "the
|
|
524
|
+
* middle of the clip" points the model at the wrong span, so the position
|
|
525
|
+
* clause says "of this shot" instead.
|
|
526
|
+
*/
|
|
527
|
+
export type TransitionHintScope = "clip" | "shot"
|
|
528
|
+
|
|
529
|
+
export interface TransitionHintOptions {
|
|
530
|
+
readonly scope?: TransitionHintScope
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The position clauses for a shot window — DERIVED from `POSITION_CLAUSES`, so
|
|
535
|
+
* the catalog stays the one source of the wording: every " of the clip" reads
|
|
536
|
+
* " of this shot", and `full`'s "the entire clip" reads "this entire shot".
|
|
537
|
+
* `transitions-scope.test.ts` pins that every non-auto row changes, so a
|
|
538
|
+
* reword that drops either phrase fails loudly instead of quietly saying
|
|
539
|
+
* "clip" inside a shot window.
|
|
540
|
+
*/
|
|
541
|
+
const SHOT_POSITION_CLAUSES: typeof POSITION_CLAUSES = Object.fromEntries(
|
|
542
|
+
Object.entries(POSITION_CLAUSES).map(([id, clause]) => [
|
|
543
|
+
id,
|
|
544
|
+
clause.replace(/ of the clip\b/g, " of this shot").replace(/ the entire clip\b/g, " this entire shot"),
|
|
545
|
+
]),
|
|
546
|
+
) as typeof POSITION_CLAUSES
|
|
547
|
+
|
|
414
548
|
const DURATION_CLAUSES = clausesOf(TRANSITION_DURATIONS)
|
|
415
549
|
const INTENSITY_CLAUSES = clausesOf(TRANSITION_INTENSITIES)
|
|
416
550
|
|
|
@@ -422,17 +556,22 @@ const INTENSITY_CLAUSES = clausesOf(TRANSITION_INTENSITIES)
|
|
|
422
556
|
*
|
|
423
557
|
* Behavior:
|
|
424
558
|
* - 0 hints (no transition, empty array, or all-empty hints) → ""
|
|
425
|
-
* -
|
|
559
|
+
* - each pick rendered `<term> (<hint body>)` (`renderTransitionBases`),
|
|
560
|
+
* n picks joined with ", and "
|
|
426
561
|
* - Timing/start/end clauses apply ONCE at the outer layer, not per-id
|
|
427
|
-
* -
|
|
428
|
-
*
|
|
562
|
+
* - When every picked id is instant (a cut — see `isInstantTransition`) the
|
|
563
|
+
* first base's parentheses carry `INSTANT_CUT_CLAUSE` once, and the
|
|
564
|
+
* duration and intensity clauses are dropped; position still applies,
|
|
565
|
+
* except `full` — a single-frame cut spans nothing, so it adds no clause
|
|
429
566
|
* - null input is treated like undefined (falsy short-circuit → returns "")
|
|
430
567
|
*
|
|
431
|
-
* @param mode
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
568
|
+
* @param mode Accepted for the picker-hint signature; a transition composes
|
|
569
|
+
* the same text in both modes — each pick as `<term> (<hint body>)`, see
|
|
570
|
+
* `renderTransitionBases`.
|
|
571
|
+
* @param options `scope: "shot"` when the hint is folded into one shot's time
|
|
572
|
+
* window of a multi-shot prompt — the position clause then says "of this
|
|
573
|
+
* shot" instead of "of the clip" (and `full`, on a non-cut, "spans this
|
|
574
|
+
* entire shot"). Omitted, the wording is the clip's.
|
|
436
575
|
*/
|
|
437
576
|
export function composeTransitionHintFromConnections(
|
|
438
577
|
transitionId: string | ReadonlyArray<string> | undefined,
|
|
@@ -440,32 +579,33 @@ export function composeTransitionHintFromConnections(
|
|
|
440
579
|
endHints: ReadonlyArray<string>,
|
|
441
580
|
timing?: TransitionTiming,
|
|
442
581
|
mode: PickerHintMode = "full",
|
|
582
|
+
options?: TransitionHintOptions,
|
|
443
583
|
): string {
|
|
444
584
|
const ids = Array.isArray(transitionId)
|
|
445
585
|
? Array.from(new Set(transitionId)).slice(0, 2)
|
|
446
586
|
: transitionId ? [transitionId] : []
|
|
447
|
-
|
|
448
|
-
// timing clauses and the start/end clauses below are identical either way.
|
|
449
|
-
const resolveBase = mode === "compact" ? getTransitionTerm : getTransitionPromptHint
|
|
450
|
-
// Only ids that contribute a base hint count below — a no-op "auto" beside a
|
|
451
|
-
// cut must not make the pick look non-instant.
|
|
452
|
-
const picked = ids.filter((id) => resolveBase(id).length > 0)
|
|
453
|
-
const baseHints = picked.map(resolveBase)
|
|
587
|
+
const baseHints = renderTransitionBases(ids, mode)
|
|
454
588
|
if (baseHints.length === 0) return ""
|
|
589
|
+
// Same "contributes a fragment" filter `renderTransitionBases` applies.
|
|
590
|
+
const instant = isInstantTransition(ids.filter((id) => getTransitionTerm(id).length > 0))
|
|
455
591
|
|
|
456
592
|
const combinedBase = baseHints.join(", and ")
|
|
457
593
|
const parts: string[] = [combinedBase]
|
|
458
594
|
|
|
459
|
-
|
|
460
|
-
|
|
595
|
+
// "Spans the entire clip" beside a single-frame cut contradicts it, so an
|
|
596
|
+
// all-instant pick drops `full`; start / middle / end still place the cut.
|
|
597
|
+
if (timing?.position && timing.position !== "auto" && !(instant && timing.position === "full")) {
|
|
598
|
+
const clauses = options?.scope === "shot" ? SHOT_POSITION_CLAUSES : POSITION_CLAUSES
|
|
599
|
+
parts.push(clauses[timing.position])
|
|
461
600
|
}
|
|
462
|
-
// A cut has no duration: "lasting approximately 1
|
|
463
|
-
//
|
|
464
|
-
// picked id is instant — a mixed
|
|
465
|
-
|
|
601
|
+
// A cut has no duration and no performance: "lasting approximately 1
|
|
602
|
+
// second" or "with natural timing" on a match cut makes the model
|
|
603
|
+
// render a dissolve. Skipped only when EVERY picked id is instant — a mixed
|
|
604
|
+
// pick still has a non-cut to time and shape.
|
|
605
|
+
if (timing?.duration && timing.duration !== "auto" && !instant) {
|
|
466
606
|
parts.push(DURATION_CLAUSES[timing.duration])
|
|
467
607
|
}
|
|
468
|
-
if (timing?.intensity && timing.intensity !== "auto") {
|
|
608
|
+
if (timing?.intensity && timing.intensity !== "auto" && !instant) {
|
|
469
609
|
parts.push(INTENSITY_CLAUSES[timing.intensity])
|
|
470
610
|
}
|
|
471
611
|
|