@nodaro/prompts 1.28.0 → 1.30.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 +180 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +120 -11
- package/dist/index.d.ts +120 -11
- package/dist/index.js +174 -10
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/caption-treatments.test.ts +155 -0
- package/src/__tests__/creator-presets.test.ts +151 -0
- package/src/__tests__/factory-presets.test.ts +63 -0
- package/src/__tests__/fixtures/blended-cut-approved.json +712 -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-scope.test.ts +7 -3
- package/src/caption-treatments.ts +109 -0
- package/src/factory-presets/sfx.ts +21 -0
- package/src/factory-presets/text.ts +29 -0
- package/src/factory-presets/video-edit.ts +14 -0
- package/src/factory-presets/voice.ts +14 -0
- package/src/index.ts +1 -0
- package/src/picker-catalogs.ts +28 -5
- package/src/transitions.ts +104 -17
package/src/picker-catalogs.ts
CHANGED
|
@@ -124,13 +124,30 @@ export interface PickerOption {
|
|
|
124
124
|
/**
|
|
125
125
|
* Transitions only: present (`true`) on a row whose mechanism is a CUT, so
|
|
126
126
|
* it takes no duration and no intensity — see `Transition.instant` /
|
|
127
|
-
* `isInstantTransition`. A consumer that only reads this
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* it only when the pack's own option says so, and
|
|
131
|
-
* duration" — the safe reading, since the composer then
|
|
127
|
+
* `isInstantTransition`. A consumer that only reads this catalog (Studio
|
|
128
|
+
* builds the transition levers from `getPickerCatalog("transition")`) hides
|
|
129
|
+
* those levers for such a row. Carried from the base catalog; a row a
|
|
130
|
+
* catalog pack ADDS has it only when the pack's own option says so, and
|
|
131
|
+
* absent means "has a duration" — the safe reading, since the composer then
|
|
132
|
+
* behaves as before. On this in-memory catalog only: the wire projection
|
|
133
|
+
* (`projectPickerCatalog`) does not carry it.
|
|
132
134
|
*/
|
|
133
135
|
readonly instant?: true
|
|
136
|
+
/**
|
|
137
|
+
* Transitions only: present (`true`) on a CUT that a `blendsCut` duration
|
|
138
|
+
* turns into a BLENDED CUT — see `Transition.blendable` /
|
|
139
|
+
* `isBlendableTransition`. Always beside `instant`. A consumer offers such a
|
|
140
|
+
* row's Duration as a Blend lever (hard cut, or one of the `blendsCut`
|
|
141
|
+
* steps of the catalog's `duration` dimension). Carried like `instant`, and
|
|
142
|
+
* like it on this in-memory catalog only.
|
|
143
|
+
*/
|
|
144
|
+
readonly blendable?: true
|
|
145
|
+
/**
|
|
146
|
+
* Transition `duration` dimension only: present (`true`) on the step that
|
|
147
|
+
* blends a `blendable` cut (`short`) — see `TransitionTimingOption.blendsCut`
|
|
148
|
+
* / `blendedCutClause`. On this in-memory catalog only.
|
|
149
|
+
*/
|
|
150
|
+
readonly blendsCut?: true
|
|
134
151
|
/**
|
|
135
152
|
* PER-OPTION PARAMETERS — extra node-data fields that apply only while THIS
|
|
136
153
|
* option is picked, each with its own rows (Transitions: a wipe's
|
|
@@ -197,6 +214,10 @@ interface BaseCatalogEntry {
|
|
|
197
214
|
readonly adultOnly?: true
|
|
198
215
|
/** Transitions: see `Transition.instant`. Propagated into the flattened option as `instant: true`. */
|
|
199
216
|
readonly instant?: boolean
|
|
217
|
+
/** Transitions: see `Transition.blendable`. Propagated into the flattened option as `blendable: true`. */
|
|
218
|
+
readonly blendable?: boolean
|
|
219
|
+
/** Transition durations: see `TransitionTimingOption.blendsCut`. Propagated as `blendsCut: true`. */
|
|
220
|
+
readonly blendsCut?: boolean
|
|
200
221
|
/** Transitions: see `Transition.options`. Propagated as the option's `params`. */
|
|
201
222
|
readonly options?: ReadonlyArray<{
|
|
202
223
|
readonly field: string
|
|
@@ -238,6 +259,8 @@ function toOptions<T extends BaseCatalogEntry>(
|
|
|
238
259
|
if (categoryField) opt.category = e[categoryField] as unknown as string
|
|
239
260
|
if (e.adultOnly) opt.adultOnly = true
|
|
240
261
|
if (e.instant) opt.instant = true
|
|
262
|
+
if (e.blendable) opt.blendable = true
|
|
263
|
+
if (e.blendsCut) opt.blendsCut = true
|
|
241
264
|
if (e.options?.length) {
|
|
242
265
|
opt.params = e.options.map((o) => ({
|
|
243
266
|
field: o.field,
|
package/src/transitions.ts
CHANGED
|
@@ -47,8 +47,13 @@ export interface Transition {
|
|
|
47
47
|
* approximately 1 second") on such a row tells the video model to spend a
|
|
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
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* clause when every picked transition is instant (on a BLENDABLE cut, a
|
|
51
|
+
* `blendsCut` step changes the cut itself instead — see `blendable`), and
|
|
52
|
+
* consumers offer only the levers a cut takes — Position without `full`, no
|
|
53
|
+
* Intensity, and Duration only on a blendable cut, as a Blend lever of "hard
|
|
54
|
+
* cut" plus the `blendsCut` steps — reading `isInstantTransition` /
|
|
55
|
+
* `isBlendableTransition` (the canvas panel) or the picker catalog's
|
|
56
|
+
* `instant` / `blendable` / `blendsCut` flags (an id-only client).
|
|
52
57
|
*
|
|
53
58
|
* The same holds for INTENSITY: every intensity clause describes how the
|
|
54
59
|
* change PERFORMS over time ("natural unhurried timing", "wild flourishes and
|
|
@@ -65,6 +70,24 @@ export interface Transition {
|
|
|
65
70
|
* anti-blend instruction that made the model render a true single-frame cut.
|
|
66
71
|
*/
|
|
67
72
|
readonly instant?: boolean
|
|
73
|
+
/**
|
|
74
|
+
* `true` on a CUT that may become a BLENDED CUT: picked with a duration step
|
|
75
|
+
* that `blendsCut` (Short), the pick's anti-blend `INSTANT_CUT_CLAUSE` is
|
|
76
|
+
* REPLACED by `blendedCutClause` — the two shots blend into each other over
|
|
77
|
+
* that step's own `term` — and every other rule of a cut stands (position
|
|
78
|
+
* without `full`, no duration clause, no intensity clause). Any other
|
|
79
|
+
* duration leaves it a hard cut. See `isBlendableTransition`.
|
|
80
|
+
*
|
|
81
|
+
* Set on `seamless-match` and `jump-match` only. In the blended-cut test
|
|
82
|
+
* round (2026-10-04) their blended takes at Short kept what makes each row
|
|
83
|
+
* itself — the invisible cut still hid its join, the jump match still held
|
|
84
|
+
* the subject's place across the leap — and were approved. The blended takes
|
|
85
|
+
* of `match-cut`, `jump-cut` and `action-relay` lost their trait and rated
|
|
86
|
+
* below their hard cut, so those rows stay hard cuts at every duration, as
|
|
87
|
+
* do `none`, `snap-to-black` and `smash-cut`, whose descriptions a blend
|
|
88
|
+
* would contradict ("instantaneous", "on a single frame", "no fade").
|
|
89
|
+
*/
|
|
90
|
+
readonly blendable?: boolean
|
|
68
91
|
/**
|
|
69
92
|
* PER-ROW OPTIONS — extra choices that only mean something for THIS
|
|
70
93
|
* transition (a wipe's direction), each written into the row's own hint.
|
|
@@ -323,7 +346,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
323
346
|
{ id: "roll-transition", label: "Roll", category: "standard", description: "Frame rolls 90-180°, second shot upright on landing",
|
|
324
347
|
promptHint: "the picture rolls around its centre in one smooth, fast turn, blurred by the speed of the turn. The camera stays in the same spot, turning only around its lens axis. During the turn the second shot takes over, and the roll slows and stops with it level and upright. The shot ends on the second shot, level, upright and still. The roll turns one way only and stops once, with no swing back", term: "camera roll transition" },
|
|
325
348
|
{ id: "seamless-match", label: "Seamless Match", category: "standard", description: "Hidden cut disguised by matched motion and color",
|
|
326
|
-
promptHint: "hidden seamless transition: the camera motion, color palette, and on-screen motion at the end of the first shot continue exactly across the cut into the second shot, so the boundary is invisible and the two shots feel like one unbroken take", term: "invisible cut" , instant: true },
|
|
349
|
+
promptHint: "hidden seamless transition: the camera motion, color palette, and on-screen motion at the end of the first shot continue exactly across the cut into the second shot, so the boundary is invisible and the two shots feel like one unbroken take", term: "invisible cut" , instant: true, blendable: true },
|
|
327
350
|
{ id: "whip-pan", label: "Whip Pan", category: "standard", description: "Camera whips sideways into blur, next shot rides the same direction",
|
|
328
351
|
promptHint: "whip pan transition: the camera whips sideways at high speed, smearing the frame into heavy horizontal motion blur, and the second shot enters already travelling in the same direction before it settles into its framing", term: "whip pan" },
|
|
329
352
|
{ id: "jump-cut", label: "Jump Cut", category: "standard", description: "Same framing, time skips forward",
|
|
@@ -455,7 +478,7 @@ export const TRANSITIONS: ReadonlyArray<Transition> = [
|
|
|
455
478
|
{ id: "vehicle-explosion", label: "Vehicle Explosion", category: "physics", description: "Vehicle detonates in foreground, scene changes behind",
|
|
456
479
|
promptHint: "a vehicle in the frame bursts into a violent explosion of fire and twisted metal, and the fireball billows toward the lens until orange flame fills the whole picture. The camera stays where it is and the framing does not change. The flame gives way to thick smoke, and as the smoke parts the second shot is revealed. The shot ends on the second shot, clear and fully resolved, with no fire or smoke left. The explosion comes from that vehicle itself, and the second shot appears only as the smoke parts" },
|
|
457
480
|
{ id: "jump-match", label: "Jump Match", category: "physics", description: "Subject jumps, landing matches into new scene",
|
|
458
|
-
promptHint: "the subject launches into a jump, and at the height of the leap the picture cuts to a new place. The camera follows the arc of the jump at the same speed on both sides of the cut, and the subject stays at the same place in the frame. In the second shot the same jump carries on without a break, and the subject comes down and lands in the new place. The shot ends on the subject landed in the second shot, fully resolved", term: "match cut on a jump" , instant: true },
|
|
481
|
+
promptHint: "the subject launches into a jump, and at the height of the leap the picture cuts to a new place. The camera follows the arc of the jump at the same speed on both sides of the cut, and the subject stays at the same place in the frame. In the second shot the same jump carries on without a break, and the subject comes down and lands in the new place. The shot ends on the subject landed in the second shot, fully resolved", term: "match cut on a jump" , instant: true, blendable: true },
|
|
459
482
|
{ id: "hand-swipe", label: "Hand Swipe", category: "physics", description: "Hand swipes across lens, scene changes during occlusion",
|
|
460
483
|
promptHint: "a hand sweeps across the camera lens at close range, fully occluding the frame in motion blur for a single beat, and as the hand exits the opposite side the scene has changed to the new setting" },
|
|
461
484
|
{ id: "action-relay", label: "Action Match", category: "physics", description: "Subject exits on an action and lands in the new scene mid-move",
|
|
@@ -614,6 +637,25 @@ export function isInstantTransition(
|
|
|
614
637
|
return ids.every((one) => getTransition(one)?.instant === true)
|
|
615
638
|
}
|
|
616
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Whether a pick may become a BLENDED CUT (see `Transition.blendable`): every
|
|
642
|
+
* picked id is a blendable cut. Same lookup and same empty-pick answer as
|
|
643
|
+
* `isInstantTransition`, so a pick is blendable only where it is instant. Like
|
|
644
|
+
* that check, it reads the ids it is given: the composer passes only the ids
|
|
645
|
+
* that contribute a fragment, so a no-op "auto" beside a blendable cut does not
|
|
646
|
+
* stop the blend there (`["auto", "seamless-match"]` given whole is `false`).
|
|
647
|
+
*/
|
|
648
|
+
export function isBlendableTransition(
|
|
649
|
+
id: string | ReadonlyArray<string> | undefined | null,
|
|
650
|
+
): boolean {
|
|
651
|
+
const ids = typeof id === "string" ? [id] : id ? [...id] : []
|
|
652
|
+
if (ids.length === 0) return false
|
|
653
|
+
return ids.every((one) => {
|
|
654
|
+
const t = getTransition(one)
|
|
655
|
+
return t?.instant === true && t.blendable === true
|
|
656
|
+
})
|
|
657
|
+
}
|
|
658
|
+
|
|
617
659
|
/**
|
|
618
660
|
* The anti-blend instruction an all-instant transition pick carries — the ONE
|
|
619
661
|
* place this sentence lives (see `Transition.instant`). Each row keeps its own
|
|
@@ -658,14 +700,16 @@ function transitionHintBody(id: string, term: string, values?: TransitionOptionV
|
|
|
658
700
|
* One picked transition as a VIDEO prompt reads it: `<term> (<hint body>)`.
|
|
659
701
|
* The term names the move in the editor's own words; the parentheses carry the
|
|
660
702
|
* model-facing mechanism, which the term alone does not convey (a bare "match
|
|
661
|
-
* cut" rendered as a dissolve on prod). `
|
|
662
|
-
* `;
|
|
663
|
-
*
|
|
703
|
+
* cut" rendered as a dissolve on prod). A non-empty `cutClause` is appended
|
|
704
|
+
* as `; <cutClause>` inside the parentheses — the hard cut's
|
|
705
|
+
* `INSTANT_CUT_CLAUSE`, or a blended cut's `blendedCutClause`. A row whose hint
|
|
706
|
+
* is just its term (or empty) renders as the term alone; an unknown id or
|
|
707
|
+
* "auto" as "".
|
|
664
708
|
*/
|
|
665
|
-
function transitionFragment(id: string,
|
|
709
|
+
function transitionFragment(id: string, cutClause: string, values?: TransitionOptionValues): string {
|
|
666
710
|
const term = getTransitionTerm(id)
|
|
667
711
|
if (!term) return ""
|
|
668
|
-
const inner = [transitionHintBody(id, term, values),
|
|
712
|
+
const inner = [transitionHintBody(id, term, values), cutClause]
|
|
669
713
|
.filter((part) => part.length > 0)
|
|
670
714
|
.join("; ")
|
|
671
715
|
return inner.length > 0 && normalizePhrase(inner) !== normalizePhrase(term)
|
|
@@ -699,17 +743,44 @@ function transitionFragment(id: string, withCutClause: boolean, values?: Transit
|
|
|
699
743
|
* `values` are the per-row option choices (`TransitionOptionValues`) — a wipe's
|
|
700
744
|
* direction. The direction registry passes none, so a `direction.transition`
|
|
701
745
|
* wipe reads as `auto`.
|
|
746
|
+
*
|
|
747
|
+
* These bases are always the HARD cut's: a pick here has no duration, so it
|
|
748
|
+
* never becomes a blended cut. The blend exists only where a duration is
|
|
749
|
+
* picked, in `composeTransitionHintFromConnections`.
|
|
702
750
|
*/
|
|
703
751
|
export function renderTransitionBases(
|
|
704
752
|
ids: ReadonlyArray<string>,
|
|
705
753
|
_mode: PickerHintMode = "full",
|
|
706
754
|
values?: TransitionOptionValues,
|
|
755
|
+
): string[] {
|
|
756
|
+
return renderBasesWith(ids, INSTANT_CUT_CLAUSE, values)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** `renderTransitionBases` with the all-instant pick's clause chosen by the
|
|
760
|
+
* caller — the hard cut's, or a blended cut's. */
|
|
761
|
+
function renderBasesWith(
|
|
762
|
+
ids: ReadonlyArray<string>,
|
|
763
|
+
cutClause: string,
|
|
764
|
+
values?: TransitionOptionValues,
|
|
707
765
|
): string[] {
|
|
708
766
|
// Only ids that contribute a fragment count — a no-op "auto" beside a cut
|
|
709
767
|
// must not make the pick look non-instant.
|
|
710
768
|
const picked = ids.filter((id) => getTransitionTerm(id).length > 0)
|
|
711
769
|
const instant = isInstantTransition(picked)
|
|
712
|
-
return picked.map((id, i) => transitionFragment(id, instant && i === 0, values))
|
|
770
|
+
return picked.map((id, i) => transitionFragment(id, instant && i === 0 ? cutClause : "", values))
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* THE BLENDED CUT — what a blendable cut becomes when a duration that
|
|
775
|
+
* `blendsCut` (Short) is picked on it: instead of the anti-blend
|
|
776
|
+
* `INSTANT_CUT_CLAUSE`, the two shots blend into each other over that step's
|
|
777
|
+
* own `term` ("about 1 second"), so the time words are the catalog's and never
|
|
778
|
+
* typed here. "" for every other value (auto, instant, medium, long, an unknown
|
|
779
|
+
* id, none), which leaves the cut a hard cut.
|
|
780
|
+
*/
|
|
781
|
+
export function blendedCutClause(duration: string | undefined): string {
|
|
782
|
+
const row = TRANSITION_DURATIONS.find((d) => d.id === duration) as TransitionTimingOption | undefined
|
|
783
|
+
return row?.blendsCut ? `instead of a hard cut, the two shots blend into each other over ${row.term}` : ""
|
|
713
784
|
}
|
|
714
785
|
|
|
715
786
|
// ---------------------------------------------------------------------------
|
|
@@ -736,6 +807,12 @@ export interface TransitionTimingOption {
|
|
|
736
807
|
readonly description: string
|
|
737
808
|
readonly promptHint: string
|
|
738
809
|
readonly term?: string
|
|
810
|
+
/** Duration rows only: `true` on a step a blendable CUT may take — picked on
|
|
811
|
+
* such a pick it turns the cut into a BLENDED CUT over this row's `term`
|
|
812
|
+
* (see `blendedCutClause`). Absent = a cut ignores the step and stays a
|
|
813
|
+
* hard cut. Set on `short` only: the 2026-10-04 test round approved the
|
|
814
|
+
* blend at about 1 second, not at Medium's 2. */
|
|
815
|
+
readonly blendsCut?: boolean
|
|
739
816
|
}
|
|
740
817
|
|
|
741
818
|
export const TRANSITION_POSITIONS = [
|
|
@@ -749,7 +826,7 @@ export const TRANSITION_POSITIONS = [
|
|
|
749
826
|
export const TRANSITION_DURATIONS = [
|
|
750
827
|
{ id: "auto", label: "Auto", description: "Let the model time it", promptHint: "", term: "" },
|
|
751
828
|
{ id: "instant", label: "Instant", description: "No perceptible duration", promptHint: "occurring instantaneously", term: "instantaneous" },
|
|
752
|
-
{ id: "short", label: "Short (~1s)", description: "Approximately 1 second", promptHint: "lasting approximately 1 second", term: "about 1 second" },
|
|
829
|
+
{ id: "short", label: "Short (~1s)", description: "Approximately 1 second", promptHint: "lasting approximately 1 second", term: "about 1 second", blendsCut: true },
|
|
753
830
|
{ id: "medium", label: "Medium (~2s)", description: "Approximately 2 seconds", promptHint: "lasting approximately 2 seconds", term: "about 2 seconds" },
|
|
754
831
|
{ id: "long", label: "Long (~3s)", description: "Approximately 3 seconds", promptHint: "lasting approximately 3 seconds", term: "about 3 seconds" },
|
|
755
832
|
] as const satisfies ReadonlyArray<TransitionTimingOption>
|
|
@@ -830,9 +907,16 @@ const INTENSITY_CLAUSES = clausesOf(TRANSITION_INTENSITIES)
|
|
|
830
907
|
* first base's parentheses carry `INSTANT_CUT_CLAUSE` once, and the
|
|
831
908
|
* duration and intensity clauses are dropped; position still applies,
|
|
832
909
|
* except `full` — a single-frame cut spans nothing, so it adds no clause
|
|
910
|
+
* - A BLENDED CUT: when every picked id is a blendable cut
|
|
911
|
+
* (`isBlendableTransition`) and the duration `blendsCut` (Short), that
|
|
912
|
+
* clause is `blendedCutClause(duration)` INSTEAD of `INSTANT_CUT_CLAUSE`
|
|
913
|
+
* (replaced, not appended; still once, in the first parentheses); every
|
|
914
|
+
* other rule above stands as for a hard cut, so there is still no duration
|
|
915
|
+
* clause, no intensity clause and no `full`. Any other duration on such a
|
|
916
|
+
* pick, and any duration on any other cut, leaves the hard cut
|
|
833
917
|
* - null input is treated like undefined (falsy short-circuit → returns "")
|
|
834
918
|
*
|
|
835
|
-
* @param
|
|
919
|
+
* @param _mode Accepted for the picker-hint signature; a transition composes
|
|
836
920
|
* the same text in both modes — each pick as `<term> (<hint body>)`, see
|
|
837
921
|
* `renderTransitionBases`.
|
|
838
922
|
* @param options `scope: "shot"` when the hint is folded into one shot's time
|
|
@@ -847,16 +931,18 @@ export function composeTransitionHintFromConnections(
|
|
|
847
931
|
startHints: ReadonlyArray<string>,
|
|
848
932
|
endHints: ReadonlyArray<string>,
|
|
849
933
|
timing?: TransitionTiming,
|
|
850
|
-
|
|
934
|
+
_mode: PickerHintMode = "full",
|
|
851
935
|
options?: TransitionHintOptions,
|
|
852
936
|
): string {
|
|
853
937
|
const ids = Array.isArray(transitionId)
|
|
854
938
|
? Array.from(new Set(transitionId)).slice(0, 2)
|
|
855
939
|
: transitionId ? [transitionId] : []
|
|
856
|
-
const baseHints = renderTransitionBases(ids, mode, options?.optionValues)
|
|
857
|
-
if (baseHints.length === 0) return ""
|
|
858
940
|
// Same "contributes a fragment" filter `renderTransitionBases` applies.
|
|
859
|
-
const
|
|
941
|
+
const contributing = ids.filter((id) => getTransitionTerm(id).length > 0)
|
|
942
|
+
const instant = isInstantTransition(contributing)
|
|
943
|
+
const blend = instant && isBlendableTransition(contributing) ? blendedCutClause(timing?.duration) : ""
|
|
944
|
+
const baseHints = renderBasesWith(ids, blend || INSTANT_CUT_CLAUSE, options?.optionValues)
|
|
945
|
+
if (baseHints.length === 0) return ""
|
|
860
946
|
|
|
861
947
|
const combinedBase = baseHints.join(", and ")
|
|
862
948
|
const parts: string[] = [combinedBase]
|
|
@@ -870,7 +956,8 @@ export function composeTransitionHintFromConnections(
|
|
|
870
956
|
// A cut has no duration and no performance: "lasting approximately 1
|
|
871
957
|
// second" or "with natural timing" on a match cut makes the model
|
|
872
958
|
// render a dissolve. Skipped only when EVERY picked id is instant — a mixed
|
|
873
|
-
// pick still has a non-cut to time and shape.
|
|
959
|
+
// pick still has a non-cut to time and shape. (A blended cut's duration has
|
|
960
|
+
// already done its work above, inside the parentheses.)
|
|
874
961
|
if (timing?.duration && timing.duration !== "auto" && !instant) {
|
|
875
962
|
parts.push(DURATION_CLAUSES[timing.duration])
|
|
876
963
|
}
|