@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
|
@@ -368,7 +368,7 @@
|
|
|
368
368
|
}
|
|
369
369
|
},
|
|
370
370
|
"ctx": null,
|
|
371
|
-
"expected": "locked
|
|
371
|
+
"expected": "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."
|
|
372
372
|
},
|
|
373
373
|
{
|
|
374
374
|
"key": "camera-motion/id:arc-left",
|
|
@@ -407,7 +407,7 @@
|
|
|
407
407
|
}
|
|
408
408
|
},
|
|
409
409
|
"ctx": null,
|
|
410
|
-
"expected": "
|
|
410
|
+
"expected": "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."
|
|
411
411
|
},
|
|
412
412
|
{
|
|
413
413
|
"key": "camera-motion/pre-post",
|
|
@@ -422,7 +422,7 @@
|
|
|
422
422
|
}
|
|
423
423
|
},
|
|
424
424
|
"ctx": null,
|
|
425
|
-
"expected": "shot on location, locked
|
|
425
|
+
"expected": "shot on location, 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. no text overlays"
|
|
426
426
|
},
|
|
427
427
|
{
|
|
428
428
|
"key": "camera-motion/with-ctx",
|
|
@@ -466,7 +466,7 @@
|
|
|
466
466
|
}
|
|
467
467
|
]
|
|
468
468
|
},
|
|
469
|
-
"expected": "locked
|
|
469
|
+
"expected": "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. beginning with wide shot, full body of the subject with visible surroundings, ending with with a calm, centered expression"
|
|
470
470
|
},
|
|
471
471
|
{
|
|
472
472
|
"key": "camera-motion/with-ctx-pre-post",
|
|
@@ -512,7 +512,7 @@
|
|
|
512
512
|
}
|
|
513
513
|
]
|
|
514
514
|
},
|
|
515
|
-
"expected": "handheld, locked
|
|
515
|
+
"expected": "handheld, 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. beginning with wide shot, full body of the subject with visible surroundings, ending with with a calm, centered expression, 35mm"
|
|
516
516
|
},
|
|
517
517
|
{
|
|
518
518
|
"key": "character-fx/id:auto",
|
|
@@ -2896,7 +2896,7 @@
|
|
|
2896
2896
|
}
|
|
2897
2897
|
},
|
|
2898
2898
|
"ctx": null,
|
|
2899
|
-
"expected": "no transition,
|
|
2899
|
+
"expected": "hard cut (no transition, instantaneous switch from first shot to second shot; an abrupt single-frame hard cut, no dissolve, crossfade or superimposition; the two images never blend)"
|
|
2900
2900
|
},
|
|
2901
2901
|
{
|
|
2902
2902
|
"key": "transition/id:auto",
|
|
@@ -2922,7 +2922,7 @@
|
|
|
2922
2922
|
}
|
|
2923
2923
|
},
|
|
2924
2924
|
"ctx": null,
|
|
2925
|
-
"expected": "the first and second scenes blend as a translucent double exposure where both images coexist semi-transparently on the frame, the first image then gradually fades out leaving the second image fully resolved"
|
|
2925
|
+
"expected": "double exposure (the first and second scenes blend as a translucent double exposure where both images coexist semi-transparently on the frame, the first image then gradually fades out leaving the second image fully resolved)"
|
|
2926
2926
|
},
|
|
2927
2927
|
{
|
|
2928
2928
|
"key": "transition/id:dream-ripple",
|
|
@@ -2935,7 +2935,7 @@
|
|
|
2935
2935
|
}
|
|
2936
2936
|
},
|
|
2937
2937
|
"ctx": null,
|
|
2938
|
-
"expected": "a circular ripple radiates outward across the frame as if the image were the surface of water, and where the ripple passes the first scene is replaced by the second scene"
|
|
2938
|
+
"expected": "dream ripple (a circular ripple radiates outward across the frame as if the image were the surface of water, and where the ripple passes the first scene is replaced by the second scene)"
|
|
2939
2939
|
},
|
|
2940
2940
|
{
|
|
2941
2941
|
"key": "transition/multi-pick",
|
|
@@ -2951,7 +2951,7 @@
|
|
|
2951
2951
|
}
|
|
2952
2952
|
},
|
|
2953
2953
|
"ctx": null,
|
|
2954
|
-
"expected": "no transition,
|
|
2954
|
+
"expected": "hard cut (no transition, instantaneous switch from first shot to second shot), and cross-dissolve (smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in)"
|
|
2955
2955
|
},
|
|
2956
2956
|
{
|
|
2957
2957
|
"key": "transition/pre-post",
|
|
@@ -2966,7 +2966,7 @@
|
|
|
2966
2966
|
}
|
|
2967
2967
|
},
|
|
2968
2968
|
"ctx": null,
|
|
2969
|
-
"expected": "shot on location, no transition,
|
|
2969
|
+
"expected": "shot on location, hard cut (no transition, instantaneous switch from first shot to second shot; an abrupt single-frame hard cut, no dissolve, crossfade or superimposition; the two images never blend), no text overlays"
|
|
2970
2970
|
},
|
|
2971
2971
|
{
|
|
2972
2972
|
"key": "transition/timing",
|
|
@@ -2982,7 +2982,7 @@
|
|
|
2982
2982
|
}
|
|
2983
2983
|
},
|
|
2984
2984
|
"ctx": null,
|
|
2985
|
-
"expected": "smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in, the transition occurs in the middle of the clip, lasting approximately 1 second, with dynamic energy and assertive flourish"
|
|
2985
|
+
"expected": "cross-dissolve (smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in), the transition occurs in the middle of the clip, lasting approximately 1 second, with dynamic energy and assertive flourish"
|
|
2986
2986
|
},
|
|
2987
2987
|
{
|
|
2988
2988
|
"key": "transition/timing-auto",
|
|
@@ -2998,7 +2998,7 @@
|
|
|
2998
2998
|
}
|
|
2999
2999
|
},
|
|
3000
3000
|
"ctx": null,
|
|
3001
|
-
"expected": "no transition,
|
|
3001
|
+
"expected": "hard cut (no transition, instantaneous switch from first shot to second shot; an abrupt single-frame hard cut, no dissolve, crossfade or superimposition; the two images never blend)"
|
|
3002
3002
|
},
|
|
3003
3003
|
{
|
|
3004
3004
|
"key": "transition/timing-instant",
|
|
@@ -3014,7 +3014,7 @@
|
|
|
3014
3014
|
}
|
|
3015
3015
|
},
|
|
3016
3016
|
"ctx": null,
|
|
3017
|
-
"expected": "no transition,
|
|
3017
|
+
"expected": "hard cut (no transition, instantaneous switch from first shot to second shot; an abrupt single-frame hard cut, no dissolve, crossfade or superimposition; the two images never blend), the transition occurs in the middle of the clip"
|
|
3018
3018
|
},
|
|
3019
3019
|
{
|
|
3020
3020
|
"key": "transition/with-ctx",
|
|
@@ -3058,7 +3058,7 @@
|
|
|
3058
3058
|
}
|
|
3059
3059
|
]
|
|
3060
3060
|
},
|
|
3061
|
-
"expected": "no transition,
|
|
3061
|
+
"expected": "hard cut (no transition, instantaneous switch from first shot to second shot; an abrupt single-frame hard cut, no dissolve, crossfade or superimposition; the two images never blend), starting from wide shot, full body of the subject with visible surroundings, ending at with a calm, centered expression"
|
|
3062
3062
|
},
|
|
3063
3063
|
{
|
|
3064
3064
|
"key": "transition/with-ctx-timing-multi-pick",
|
|
@@ -3110,7 +3110,7 @@
|
|
|
3110
3110
|
}
|
|
3111
3111
|
]
|
|
3112
3112
|
},
|
|
3113
|
-
"expected": "on the beat, no transition,
|
|
3113
|
+
"expected": "on the beat, hard cut (no transition, instantaneous switch from first shot to second shot), and cross-dissolve (smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in), the transition occurs at the end of the clip, lasting approximately 3 seconds, with subtle restrained energy and minimal flourish, starting from wide shot, full body of the subject with visible surroundings, ending at with a calm, centered expression, hold the last frame"
|
|
3114
3114
|
},
|
|
3115
3115
|
{
|
|
3116
3116
|
"key": "unknown/type",
|
|
@@ -50,8 +50,9 @@ describe("sentence-style camera-motion hints compose cleanly", () => {
|
|
|
50
50
|
})
|
|
51
51
|
|
|
52
52
|
it("keeps a clause-style hint byte-identical to the pre-joiner output", () => {
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// roll-left kept its one-clause catalog wording through the Camera Motion Lab.
|
|
54
|
+
expect(composeCameraMotionHintFromConnections("roll-left", ["wide shot"], [])).toBe(
|
|
55
|
+
"camera rolls counterclockwise around the lens axis, beginning with wide shot",
|
|
55
56
|
)
|
|
56
57
|
})
|
|
57
58
|
})
|
|
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"
|
|
|
3
3
|
|
|
4
4
|
import { getParameterPromptHint } from "../parameter-prompt-hint.js"
|
|
5
5
|
import { PICKER_CATALOGS, type PickerCatalog, type PickerOption } from "../picker-catalogs.js"
|
|
6
|
+
import { renderTransitionBases } from "../transitions.js"
|
|
6
7
|
import type { HintGraphContext, HintNodeLike } from "@nodaro/shared"
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -145,6 +146,15 @@ describe("hint mode: compact injects the term instead of the promptHint", () =>
|
|
|
145
146
|
const compact = getParameterPromptHint(node)
|
|
146
147
|
const full = getParameterPromptHint(withMode(node, "full"))
|
|
147
148
|
|
|
149
|
+
// A transition only ever reaches a VIDEO prompt, where it renders as
|
|
150
|
+
// `term (hint)` in BOTH modes — the bare term did not steer the model
|
|
151
|
+
// (see `renderTransitionBases`). The mode is deliberately inert there.
|
|
152
|
+
if (probe.nodeType === "transition") {
|
|
153
|
+
expect(compact).toBe(full)
|
|
154
|
+
expect(compact.startsWith(`${probe.option.term} (`)).toBe(true)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
148
158
|
expect(compact).toContain(probe.option.term)
|
|
149
159
|
expect(full).toContain(probe.option.promptHint)
|
|
150
160
|
if (probe.shortens) {
|
|
@@ -170,7 +180,7 @@ describe("hint mode: compact preserves everything but the base fragment", () =>
|
|
|
170
180
|
expect(compact.length).toBeLessThan(full.length)
|
|
171
181
|
})
|
|
172
182
|
|
|
173
|
-
it("transition
|
|
183
|
+
it("transition clauses are emitted identically in compact mode", () => {
|
|
174
184
|
const data = {
|
|
175
185
|
transition: "cross-dissolve",
|
|
176
186
|
position: "middle",
|
|
@@ -181,8 +191,8 @@ describe("hint mode: compact preserves everything but the base fragment", () =>
|
|
|
181
191
|
expect(compact).toContain("the transition occurs in the middle of the clip")
|
|
182
192
|
expect(compact).toContain("lasting approximately 1 second")
|
|
183
193
|
expect(compact).toContain("with dynamic energy and assertive flourish")
|
|
184
|
-
expect(compact).toContain("cross-dissolve")
|
|
185
|
-
expect(compact
|
|
194
|
+
expect(compact).toContain("cross-dissolve (")
|
|
195
|
+
expect(compact).toBe(getParameterPromptHint({ id: "n1", type: "transition", data }))
|
|
186
196
|
})
|
|
187
197
|
|
|
188
198
|
it("character-fx timing clauses are emitted identically in compact mode", () => {
|
|
@@ -256,7 +266,7 @@ describe("hint mode: compact preserves everything but the base fragment", () =>
|
|
|
256
266
|
expect(compact).toBe(term)
|
|
257
267
|
})
|
|
258
268
|
|
|
259
|
-
it("multi-pick joins
|
|
269
|
+
it("transition multi-pick joins each `term (hint)` with the same ', and ' separator", () => {
|
|
260
270
|
const options = registeredOptions("transition").filter((o) => o.promptHint.length > 0)
|
|
261
271
|
const [a, b] = options
|
|
262
272
|
const compact = getParameterPromptHint({
|
|
@@ -264,7 +274,9 @@ describe("hint mode: compact preserves everything but the base fragment", () =>
|
|
|
264
274
|
type: "transition",
|
|
265
275
|
data: { transition: [a.id, b.id], hintMode: "compact" },
|
|
266
276
|
})
|
|
267
|
-
expect(compact).toBe(
|
|
277
|
+
expect(compact).toBe(renderTransitionBases([a.id, b.id]).join(", and "))
|
|
278
|
+
expect(compact.startsWith(`${a.term} (`)).toBe(true)
|
|
279
|
+
expect(compact).toContain(`, and ${b.term} (`)
|
|
268
280
|
})
|
|
269
281
|
|
|
270
282
|
it("multi-dimension composition still walks every dimension", () => {
|
|
@@ -27,7 +27,7 @@ import { getCameraFormatPromptHint } from "../camera-format.js"
|
|
|
27
27
|
import { getFramingPromptHint } from "../framing.js"
|
|
28
28
|
import { getLightingPromptHint } from "../lighting.js"
|
|
29
29
|
import { getCameraMotionTerm } from "../camera-motions.js"
|
|
30
|
-
import {
|
|
30
|
+
import { renderTransitionBases } from "../transitions.js"
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* THE `[style]` SECTION CONTRACT, at the level it is defined: clauses in, one
|
|
@@ -97,7 +97,7 @@ describe("partitionStyleClauses — which slot a clause lands in", () => {
|
|
|
97
97
|
).map((c) => [c.text, c.slot]),
|
|
98
98
|
)
|
|
99
99
|
expect(slots.get(getCameraMotionTerm(CAMERA_MOTION))).toBe("body")
|
|
100
|
-
expect(slots.get(
|
|
100
|
+
expect(slots.get(renderTransitionBases([TRANSITION])[0])).toBe("body")
|
|
101
101
|
expect(slots.get(getStylePromptHint(STYLE))).toBe("film")
|
|
102
102
|
expect(slots.get(getFramingPromptHint(SHOT_SIZE))).toBe("scene")
|
|
103
103
|
})
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import {
|
|
3
|
+
INSTANT_CUT_CLAUSE,
|
|
3
4
|
TRANSITIONS,
|
|
4
5
|
TRANSITION_DURATIONS,
|
|
6
|
+
TRANSITION_INTENSITIES,
|
|
5
7
|
composeTransitionHintFromConnections,
|
|
8
|
+
getTransitionPromptHint,
|
|
9
|
+
getTransitionTerm,
|
|
6
10
|
isInstantTransition,
|
|
11
|
+
renderTransitionBases,
|
|
7
12
|
} from "../transitions.js"
|
|
8
13
|
import { getPickerCatalog } from "../picker-catalogs.js"
|
|
14
|
+
import { VIDEO_HINT_MODE_DEFAULT, renderDirectionHints } from "../direction-registry.js"
|
|
15
|
+
import { composeVideoPromptText } from "../assemble-video-input.js"
|
|
9
16
|
|
|
10
17
|
/**
|
|
11
18
|
* F5 (transition QA, 2026-09-22): a duration clause on a cut ("match cut, …,
|
|
@@ -25,6 +32,7 @@ const INSTANT_IDS = [
|
|
|
25
32
|
]
|
|
26
33
|
|
|
27
34
|
const DURATION_HINTS = TRANSITION_DURATIONS.map((d) => d.promptHint).filter((h) => h.length > 0)
|
|
35
|
+
const INTENSITY_HINTS = TRANSITION_INTENSITIES.map((d) => d.promptHint).filter((h) => h.length > 0)
|
|
28
36
|
|
|
29
37
|
describe("instant transitions — the catalog marker", () => {
|
|
30
38
|
it("marks exactly the cut rows", () => {
|
|
@@ -61,22 +69,35 @@ describe("instant transitions — the composer drops the duration lever", () =>
|
|
|
61
69
|
}
|
|
62
70
|
})
|
|
63
71
|
|
|
64
|
-
it(`${mode}:
|
|
72
|
+
it(`${mode}: no intensity clause on any instant row, for every intensity`, () => {
|
|
73
|
+
for (const id of INSTANT_IDS) {
|
|
74
|
+
for (const i of TRANSITION_INTENSITIES) {
|
|
75
|
+
const r = composeTransitionHintFromConnections(id, [], [], { intensity: i.id }, mode)
|
|
76
|
+
for (const hint of INTENSITY_HINTS) expect(r).not.toContain(hint)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it(`${mode}: position still applies to a cut`, () => {
|
|
65
82
|
const r = composeTransitionHintFromConnections(
|
|
66
83
|
"match-cut", [], [], { position: "middle", duration: "short", intensity: "natural" }, mode,
|
|
67
84
|
)
|
|
68
85
|
expect(r).toContain("the transition occurs in the middle of the clip")
|
|
69
|
-
expect(r).toContain("
|
|
86
|
+
expect(r).not.toContain("unhurried")
|
|
70
87
|
expect(r).not.toContain("lasting approximately")
|
|
71
88
|
})
|
|
72
89
|
}
|
|
73
90
|
|
|
74
|
-
it("compact match cut reads term
|
|
91
|
+
it("compact match cut reads term (hint; anti-blend clause) + position only", () => {
|
|
75
92
|
expect(
|
|
76
93
|
composeTransitionHintFromConnections(
|
|
77
94
|
"match-cut", [], [], { position: "middle", duration: "short", intensity: "natural" }, "compact",
|
|
78
95
|
),
|
|
79
|
-
).toBe(
|
|
96
|
+
).toBe(
|
|
97
|
+
"match cut (the final composition of the first shot matches the opening composition of the second shot " +
|
|
98
|
+
`in shape, color, and motion, so the cut feels like a visual rhyme; ${INSTANT_CUT_CLAUSE}), ` +
|
|
99
|
+
"the transition occurs in the middle of the clip",
|
|
100
|
+
)
|
|
80
101
|
})
|
|
81
102
|
|
|
82
103
|
it("a non-instant row keeps its duration", () => {
|
|
@@ -92,21 +113,132 @@ describe("instant transitions — the composer drops the duration lever", () =>
|
|
|
92
113
|
it("a no-op 'auto' beside a cut does not make the pick non-instant", () => {
|
|
93
114
|
const r = composeTransitionHintFromConnections(["auto", "smash-cut"], [], [], { duration: "long" })
|
|
94
115
|
expect(r).not.toContain("lasting approximately")
|
|
116
|
+
expect(r).toContain(INSTANT_CUT_CLAUSE)
|
|
95
117
|
})
|
|
96
118
|
})
|
|
97
119
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Transition QA, 2026-09-24 (seedance-2-5, prod): the bare term "match cut,
|
|
122
|
+
* with natural unhurried timing" still rendered as a ~1 s dissolve. Every
|
|
123
|
+
* transition in a video prompt now reads `<term> (<hint body>)`, and an
|
|
124
|
+
* all-instant pick carries the anti-blend clause inside its parentheses.
|
|
125
|
+
*/
|
|
126
|
+
describe("transitions in a video prompt — `term (hint)`", () => {
|
|
127
|
+
const LEVERS = { duration: "short", intensity: "natural" } as const
|
|
128
|
+
|
|
129
|
+
it("the six A/B strings (compact, short + natural levers)", () => {
|
|
130
|
+
const c = (id: string) => composeTransitionHintFromConnections(id, [], [], LEVERS, "compact")
|
|
131
|
+
expect(c("match-cut")).toBe(
|
|
132
|
+
"match cut (the final composition of the first shot matches the opening composition of the second shot " +
|
|
133
|
+
`in shape, color, and motion, so the cut feels like a visual rhyme; ${INSTANT_CUT_CLAUSE})`,
|
|
134
|
+
)
|
|
135
|
+
expect(c("none")).toBe(
|
|
136
|
+
`hard cut (no transition, instantaneous switch from first shot to second shot; ${INSTANT_CUT_CLAUSE})`,
|
|
137
|
+
)
|
|
138
|
+
expect(c("whip-pan")).toBe(
|
|
139
|
+
"whip pan (the camera whips sideways at high speed, smearing the frame into heavy horizontal motion blur, " +
|
|
140
|
+
"and the second shot enters already travelling in the same direction before it settles into its framing), " +
|
|
141
|
+
"lasting approximately 1 second, with natural timing",
|
|
142
|
+
)
|
|
143
|
+
expect(c("roll-transition")).toBe(
|
|
144
|
+
`camera roll transition (${getTransitionPromptHint("roll-transition")}), ` +
|
|
145
|
+
"lasting approximately 1 second, with natural timing",
|
|
146
|
+
)
|
|
147
|
+
expect(c("freeze-frame-jump")).toBe(
|
|
148
|
+
"freeze-frame time jump (all motion stops mid-action and the picture holds still for a beat; only then does it " +
|
|
149
|
+
"jump to the same view hours or days later, everything in new positions, and motion resumes), " +
|
|
150
|
+
"lasting approximately 1 second, with natural timing",
|
|
151
|
+
)
|
|
152
|
+
expect(c("shatter-glass")).toBe(
|
|
153
|
+
`shatter like glass and reform (${getTransitionPromptHint("shatter-glass")}), ` +
|
|
154
|
+
"lasting approximately 1 second, with natural timing",
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
for (const mode of ["full", "compact"] as const) {
|
|
159
|
+
it(`${mode}: every row reads \`term (…)\` — the hint never restates its own "<label>:" heading`, () => {
|
|
160
|
+
for (const t of TRANSITIONS) {
|
|
161
|
+
const term = getTransitionTerm(t.id)
|
|
162
|
+
const r = composeTransitionHintFromConnections(t.id, [], [], undefined, mode)
|
|
163
|
+
if (!term) { expect(r).toBe(""); continue }
|
|
164
|
+
expect(r.startsWith(`${term} (`), t.id).toBe(true)
|
|
165
|
+
expect(r.endsWith(")"), t.id).toBe(true)
|
|
166
|
+
expect(r.slice(term.length + 2).toLowerCase().startsWith(`${term.toLowerCase()}:`), t.id).toBe(false)
|
|
167
|
+
// `rewind` opens with "reverse motion:" on purpose: that is the exact wording that won the A/B
|
|
168
|
+
// (descab4 C3), and it is a description lead-in, not the row's own "<label>:" heading.
|
|
169
|
+
if (t.id !== "rewind") expect(r, t.id).not.toMatch(/\(\s*[^,;()]{1,60}: /)
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it(`${mode}: every instant row carries the clause exactly once, inside its parentheses`, () => {
|
|
174
|
+
for (const id of INSTANT_IDS) {
|
|
175
|
+
const r = composeTransitionHintFromConnections(id, [], [], undefined, mode)
|
|
176
|
+
expect(r.endsWith(`; ${INSTANT_CUT_CLAUSE})`), id).toBe(true)
|
|
177
|
+
expect(r.split(INSTANT_CUT_CLAUSE).length, id).toBe(2)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it(`${mode}: an all-instant multi-pick carries it once, in the first parentheses`, () => {
|
|
182
|
+
const r = composeTransitionHintFromConnections(["match-cut", "smash-cut"], [], [], undefined, mode)
|
|
183
|
+
const [a, b] = r.split(", and smash cut (")
|
|
184
|
+
expect(a.startsWith("match cut (")).toBe(true)
|
|
185
|
+
expect(a.endsWith(`; ${INSTANT_CUT_CLAUSE})`)).toBe(true)
|
|
186
|
+
expect(b).not.toContain(INSTANT_CUT_CLAUSE)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it(`${mode}: a mixed pick carries no clause and keeps duration and intensity`, () => {
|
|
190
|
+
const r = composeTransitionHintFromConnections(
|
|
191
|
+
["match-cut", "cross-dissolve"], [], [], { position: "end", duration: "short", intensity: "natural" }, mode,
|
|
192
|
+
)
|
|
193
|
+
expect(r).not.toContain(INSTANT_CUT_CLAUSE)
|
|
194
|
+
expect(r).toBe(
|
|
195
|
+
renderTransitionBases(["match-cut", "cross-dissolve"]).join(", and ") +
|
|
196
|
+
", the transition occurs at the end of the clip, lasting approximately 1 second, with natural timing",
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it(`${mode}: a non-instant row never carries the clause`, () => {
|
|
201
|
+
for (const t of TRANSITIONS) {
|
|
202
|
+
if (t.instant) continue
|
|
203
|
+
expect(composeTransitionHintFromConnections(t.id, [], [], undefined, mode)).not.toContain(INSTANT_CUT_CLAUSE)
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
it("`none` does not say 'hard cut' three times", () => {
|
|
209
|
+
const r = composeTransitionHintFromConnections("none", [], [], undefined, "compact")
|
|
210
|
+
expect(r.split("hard cut").length - 1).toBe(2)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it("the direction-registry fold (server path) words a transition identically", () => {
|
|
214
|
+
for (const mode of [VIDEO_HINT_MODE_DEFAULT, "full", "compact"] as const) {
|
|
215
|
+
expect(renderDirectionHints({ transition: "match-cut" }, { surface: "video", mode }))
|
|
216
|
+
.toEqual([composeTransitionHintFromConnections("match-cut", [], [])])
|
|
217
|
+
expect(renderDirectionHints({ transition: "whip-pan" }, { surface: "video", mode }))
|
|
218
|
+
.toEqual([composeTransitionHintFromConnections("whip-pan", [], [])])
|
|
219
|
+
// Multi-pick: one fragment per id, the clause only in the first of an all-instant pick.
|
|
220
|
+
expect(renderDirectionHints({ transition: ["match-cut", "cross-dissolve"] }, { surface: "video", mode }))
|
|
221
|
+
.toEqual(renderTransitionBases(["match-cut", "cross-dissolve"]))
|
|
222
|
+
}
|
|
223
|
+
// A transition never reaches an image prompt.
|
|
224
|
+
expect(renderDirectionHints({ transition: "match-cut" }, { surface: "image" })).toEqual([])
|
|
105
225
|
})
|
|
106
226
|
|
|
107
|
-
it("
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
227
|
+
it("under a prompt cap, the tail-shed keeps the anti-blend clause on a two-cut pick", () => {
|
|
228
|
+
const direction = { transition: ["match-cut", "none"] }
|
|
229
|
+
const [first, second] = renderTransitionBases(["match-cut", "none"])
|
|
230
|
+
expect(first.endsWith(`; ${INSTANT_CUT_CLAUSE})`)).toBe(true)
|
|
231
|
+
expect(second).not.toContain(INSTANT_CUT_CLAUSE)
|
|
232
|
+
const full = composeVideoPromptText("a knight", direction)!
|
|
233
|
+
expect(full).toContain(second)
|
|
234
|
+
// A cap the whole fold does not fit, but the first fragment does: the second
|
|
235
|
+
// cut is shed and the clause survives with the first. (With the clause on
|
|
236
|
+
// the LAST fragment, this shed dropped it.)
|
|
237
|
+
for (const cap of [full.length - 1, `a knight. ${first}`.length]) {
|
|
238
|
+
const shed = composeVideoPromptText("a knight", direction, undefined, { cap })!
|
|
239
|
+
expect(shed.length).toBeLessThanOrEqual(cap)
|
|
240
|
+
expect(shed).not.toContain(second)
|
|
241
|
+
expect(shed).toContain(INSTANT_CUT_CLAUSE)
|
|
242
|
+
}
|
|
111
243
|
})
|
|
112
244
|
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
INSTANT_CUT_CLAUSE,
|
|
4
|
+
TRANSITIONS,
|
|
5
|
+
TRANSITION_POSITIONS,
|
|
6
|
+
composeTransitionHintFromConnections,
|
|
7
|
+
getTransitionPromptHint,
|
|
8
|
+
} from "../transitions.js"
|
|
9
|
+
import { CHARACTER_FX_INTENSITIES } from "../character-fx.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Transition wording round 2 (2026-09-24, from the transition description A/B):
|
|
13
|
+
* - `aging` and `zoom-into-mouth` carry new bodies;
|
|
14
|
+
* - L1: a cut with position `full` renders no position clause;
|
|
15
|
+
* - L5: a hint folded into one shot's time window says "of this shot"
|
|
16
|
+
* (and a non-cut's `full` "spans this entire shot").
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const INSTANT_IDS = TRANSITIONS.filter((t) => t.instant).map((t) => t.id)
|
|
20
|
+
const NON_INSTANT_IDS = TRANSITIONS.filter((t) => !t.instant && t.id !== "auto").map((t) => t.id)
|
|
21
|
+
const FULL_CLAUSE = "the transition spans the entire clip"
|
|
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 + ")"
|
|
26
|
+
|
|
27
|
+
describe("approved row bodies", () => {
|
|
28
|
+
it("aging", () => {
|
|
29
|
+
expect(getTransitionPromptHint("aging")).toBe(
|
|
30
|
+
"accelerated aging transition: the subject visibly ages forward - fine lines deepen into wrinkles, " +
|
|
31
|
+
"hair greys to silver, posture settles - while the framing stays unchanged",
|
|
32
|
+
)
|
|
33
|
+
expect(composeTransitionHintFromConnections("aging", [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
|
|
34
|
+
"accelerated aging (the subject visibly ages forward - fine lines deepen into wrinkles, hair greys to silver, " +
|
|
35
|
+
"posture settles - while the framing stays unchanged), the transition occurs in the middle of the clip, " +
|
|
36
|
+
"lasting approximately 1 second, with natural timing",
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("freeze-frame-jump (stays a timed transition, not a cut)", () => {
|
|
41
|
+
expect(getTransitionPromptHint("freeze-frame-jump")).toBe(
|
|
42
|
+
"freeze-frame transition: all motion stops mid-action and the picture holds still for a beat; only then does it " +
|
|
43
|
+
"jump to the same view hours or days later, everything in new positions, and motion resumes",
|
|
44
|
+
)
|
|
45
|
+
expect(composeTransitionHintFromConnections("freeze-frame-jump", [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
|
|
46
|
+
"freeze-frame time jump (all motion stops mid-action and the picture holds still for a beat; only then does it " +
|
|
47
|
+
"jump to the same view hours or days later, everything in new positions, and motion resumes), " +
|
|
48
|
+
"the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing",
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("rewind (the prompted in-shot rewind; timed, not a cut)", () => {
|
|
53
|
+
expect(composeTransitionHintFromConnections("rewind", [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
|
|
54
|
+
"reverse-motion rewind (reverse motion: the actions just seen are undone exactly as they happened, in reverse " +
|
|
55
|
+
"order and at the same pace, the subject retracing each step to where it began, ending on the end frame), " +
|
|
56
|
+
"the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing",
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("zoom-into-mouth drops only 'the throat'", () => {
|
|
61
|
+
expect(getTransitionPromptHint("zoom-into-mouth")).toBe(
|
|
62
|
+
"the camera pushes into the subject's open mouth, the dark interior fills the frame, and the camera passes " +
|
|
63
|
+
"through into the new scene which materialises as if emerging from inside the body",
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe("L1 — a cut spans nothing, so `full` adds no clause", () => {
|
|
69
|
+
it.each(INSTANT_IDS)("%s + full renders no position clause", (id) => {
|
|
70
|
+
const out = composeTransitionHintFromConnections(id, [], [], { position: "full", duration: "short", intensity: "natural" })
|
|
71
|
+
expect(out).not.toContain("spans")
|
|
72
|
+
expect(out).toBe(composeTransitionHintFromConnections(id, [], []))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("match cut + full is the fragment alone", () => {
|
|
76
|
+
expect(composeTransitionHintFromConnections("match-cut", [], [], { position: "full" })).toBe(MATCH_CUT_BASE)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it.each(["start", "middle", "end"] as const)("a cut keeps its %s clause", (position) => {
|
|
80
|
+
const clause = TRANSITION_POSITIONS.find((p) => p.id === position)!.promptHint
|
|
81
|
+
expect(composeTransitionHintFromConnections("match-cut", [], [], { position })).toBe(`${MATCH_CUT_BASE}, ${clause}`)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it.each(NON_INSTANT_IDS)("non-cut %s keeps the full clause", (id) => {
|
|
85
|
+
expect(composeTransitionHintFromConnections(id, [], [], { position: "full" })).toContain(`, ${FULL_CLAUSE}`)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("a mixed pick (cut + non-cut) keeps the full clause", () => {
|
|
89
|
+
expect(
|
|
90
|
+
composeTransitionHintFromConnections(["match-cut", "cross-dissolve"], [], [], { position: "full" }),
|
|
91
|
+
).toMatch(new RegExp(`, ${FULL_CLAUSE}$`))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("two cuts together still drop it", () => {
|
|
95
|
+
expect(
|
|
96
|
+
composeTransitionHintFromConnections(["match-cut", "smash-cut"], [], [], { position: "full" }),
|
|
97
|
+
).not.toContain("spans")
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe("L5 — scope: shot says 'of this shot'", () => {
|
|
102
|
+
const window = { scope: "shot" } as const
|
|
103
|
+
|
|
104
|
+
it.each([
|
|
105
|
+
["start", "the transition occurs at the opening of this shot"],
|
|
106
|
+
["middle", "the transition occurs in the middle of this shot"],
|
|
107
|
+
["end", "the transition occurs at the end of this shot"],
|
|
108
|
+
] as const)("%s in a shot window", (position, clause) => {
|
|
109
|
+
const out = composeTransitionHintFromConnections("whip-pan", [], [], { position }, "full", window)
|
|
110
|
+
expect(out).toMatch(new RegExp(`, ${clause}$`))
|
|
111
|
+
expect(out).not.toContain("of the clip")
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("every non-auto position clause actually changes in a shot window (guard against a reword)", () => {
|
|
115
|
+
for (const p of TRANSITION_POSITIONS.filter((p) => ["start", "middle", "end"].includes(p.id))) {
|
|
116
|
+
expect(p.promptHint).toContain(" of the clip")
|
|
117
|
+
}
|
|
118
|
+
expect(FULL_CLAUSE).toContain(" the entire clip")
|
|
119
|
+
for (const p of TRANSITION_POSITIONS.filter((p) => p.id !== "auto")) {
|
|
120
|
+
const out = composeTransitionHintFromConnections("whip-pan", [], [], { position: p.id }, "full", window)
|
|
121
|
+
expect(out).not.toContain("clip")
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("full in a shot window spans this entire shot (non-cut) and is dropped (cut)", () => {
|
|
126
|
+
expect(composeTransitionHintFromConnections("whip-pan", [], [], { position: "full" }, "full", window)).toMatch(
|
|
127
|
+
new RegExp(`, ${FULL_SHOT_CLAUSE}$`),
|
|
128
|
+
)
|
|
129
|
+
expect(composeTransitionHintFromConnections("match-cut", [], [], { position: "full" }, "full", window)).toBe(MATCH_CUT_BASE)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it.each(NON_INSTANT_IDS)("non-cut %s + full: shot scope spans this entire shot, default scope the entire clip", (id) => {
|
|
133
|
+
const shot = composeTransitionHintFromConnections(id, [], [], { position: "full" }, "full", window)
|
|
134
|
+
const clip = composeTransitionHintFromConnections(id, [], [], { position: "full" })
|
|
135
|
+
expect(shot).toBe(clip.replace(FULL_CLAUSE, FULL_SHOT_CLAUSE))
|
|
136
|
+
expect(clip).toContain(`, ${FULL_CLAUSE}`)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("a cut in a shot window", () => {
|
|
140
|
+
expect(composeTransitionHintFromConnections("match-cut", [], [], { position: "middle" }, "full", window)).toBe(
|
|
141
|
+
`${MATCH_CUT_BASE}, the transition occurs in the middle of this shot`,
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it.each(TRANSITIONS.map((t) => t.id))("%s: no scope and scope clip read 'of the clip', unchanged", (id) => {
|
|
146
|
+
const timing = { position: "middle", duration: "short", intensity: "natural" } as const
|
|
147
|
+
const plain = composeTransitionHintFromConnections(id, [], [], timing)
|
|
148
|
+
expect(composeTransitionHintFromConnections(id, [], [], timing, "full", { scope: "clip" })).toBe(plain)
|
|
149
|
+
if (plain) expect(plain).toContain("in the middle of the clip")
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe("intensity natural reads 'with natural timing' (transitions only)", () => {
|
|
154
|
+
it("renders on a non-cut, never 'unhurried'", () => {
|
|
155
|
+
expect(composeTransitionHintFromConnections("whip-pan", [], [], { intensity: "natural" })).toMatch(/, with natural timing$/)
|
|
156
|
+
for (const id of NON_INSTANT_IDS) {
|
|
157
|
+
expect(composeTransitionHintFromConnections(id, [], [], { intensity: "natural" })).not.toContain("unhurried")
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("is still dropped on a cut", () => {
|
|
162
|
+
expect(composeTransitionHintFromConnections("match-cut", [], [], { intensity: "natural" })).toBe(MATCH_CUT_BASE)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("character-fx keeps its own 'with natural unhurried timing'", () => {
|
|
166
|
+
expect(CHARACTER_FX_INTENSITIES.find((o) => o.id === "natural")!.promptHint).toBe("with natural unhurried timing")
|
|
167
|
+
})
|
|
168
|
+
})
|