@twick/visualizer 0.0.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.turbo/turbo-build.log +19 -0
  2. package/.turbo/turbo-docs.log +7 -0
  3. package/LICENSE +197 -0
  4. package/README.md +1 -1
  5. package/dist/mp4.wasm +0 -0
  6. package/dist/project.css +1 -0
  7. package/dist/project.js +145 -0
  8. package/docs/.nojekyll +1 -0
  9. package/docs/README.md +13 -0
  10. package/docs/interfaces/Animation.md +47 -0
  11. package/docs/interfaces/Element.md +47 -0
  12. package/docs/interfaces/FrameEffectPlugin.md +47 -0
  13. package/docs/interfaces/TextEffect.md +47 -0
  14. package/docs/modules.md +535 -0
  15. package/package.json +34 -31
  16. package/src/animations/blur.tsx +60 -0
  17. package/src/animations/breathe.tsx +60 -0
  18. package/src/animations/fade.tsx +60 -0
  19. package/src/animations/photo-rise.tsx +66 -0
  20. package/src/animations/photo-zoom.tsx +73 -0
  21. package/src/animations/rise.tsx +118 -0
  22. package/src/animations/succession.tsx +77 -0
  23. package/src/components/frame-effects.tsx +2 -4
  24. package/src/components/track.tsx +232 -0
  25. package/src/controllers/animation.controller.ts +39 -0
  26. package/src/controllers/element.controller.ts +43 -0
  27. package/src/controllers/frame-effect.controller.tsx +30 -0
  28. package/src/controllers/text-effect.controller.ts +33 -0
  29. package/src/elements/audio.element.tsx +17 -0
  30. package/src/elements/caption.element.tsx +87 -0
  31. package/src/elements/circle.element.tsx +20 -0
  32. package/src/elements/icon.element.tsx +20 -0
  33. package/src/elements/image.element.tsx +55 -0
  34. package/src/elements/rect.element.tsx +22 -0
  35. package/src/elements/scene.element.tsx +29 -0
  36. package/src/elements/text.element.tsx +28 -0
  37. package/src/elements/video.element.tsx +56 -0
  38. package/src/frame-effects/circle.frame.tsx +103 -0
  39. package/src/frame-effects/rect.frame.tsx +103 -0
  40. package/src/helpers/caption.utils.ts +4 -14
  41. package/src/helpers/constants.ts +1 -1
  42. package/src/helpers/element.utils.ts +222 -68
  43. package/src/helpers/filters.ts +1 -1
  44. package/src/helpers/log.utils.ts +0 -3
  45. package/src/helpers/timing.utils.ts +2 -21
  46. package/src/helpers/types.ts +103 -8
  47. package/src/helpers/utils.ts +20 -0
  48. package/src/index.ts +4 -2
  49. package/src/live.tsx +1 -1
  50. package/src/project.ts +1 -1
  51. package/src/sample.ts +16 -218
  52. package/src/text-effects/elastic.tsx +39 -0
  53. package/src/text-effects/erase.tsx +58 -0
  54. package/src/text-effects/stream-word.tsx +60 -0
  55. package/src/text-effects/typewriter.tsx +59 -0
  56. package/src/visualizer.tsx +27 -27
  57. package/tsconfig.json +3 -2
  58. package/vite.config.ts +1 -1
  59. package/src/components/animation.tsx +0 -7
  60. package/src/components/element.tsx +0 -344
  61. package/src/components/timeline.tsx +0 -225
@@ -1,10 +1,13 @@
1
+ import { View2D } from "@twick/2d";
2
+ import { Reference, ThreadGenerator, Vector2 } from "@twick/core";
3
+
1
4
  export type VideoInput = {
2
5
  backgroundColor: string;
3
6
  properties: {
4
7
  width: number;
5
8
  height: number;
6
9
  };
7
- timeline: VisualizerTimeline[];
10
+ tracks: VisualizerTrack[];
8
11
  };
9
12
 
10
13
  export type MediaType = "video" | "image";
@@ -29,12 +32,12 @@ export type Position = {
29
32
  };
30
33
 
31
34
  export type FrameEffect = {
35
+ name: string;
32
36
  s: number;
33
37
  e: number;
34
38
  props: FrameEffectProps;
35
39
  };
36
40
 
37
-
38
41
  export type FrameEffectProps = {
39
42
  frameSize: SizeArray;
40
43
  frameShape: "circle" | "rect";
@@ -75,8 +78,9 @@ export type Caption = {
75
78
  t: string;
76
79
  s: number;
77
80
  e: number;
81
+ capStyle?: string;
78
82
  props?: CaptionProps;
79
- }
83
+ };
80
84
 
81
85
  export type CaptionProps = {
82
86
  colors: CaptionColors;
@@ -106,25 +110,26 @@ export type CaptionFont = {
106
110
 
107
111
  export type VisualizerElement = {
108
112
  id: string;
113
+ trackId?: string;
109
114
  frame?: any;
110
115
  props?: any;
111
- objectFit?: 'contain' | 'cover' | 'fill';
116
+ objectFit?: "contain" | "cover" | "fill";
112
117
  type?: string;
113
118
  s: number;
114
119
  e: number;
115
120
  backgroundColor?: string;
116
- elements?: VisualizerElement[];
117
- animations?: any[];
121
+ animation?: AnimationProps;
122
+ textEffect: TextEffectProps;
123
+ frameEffects?: FrameEffect[];
118
124
  scale?: number;
119
125
  t?: string;
120
126
  hWords?: any;
121
127
  };
122
128
 
123
- export type VisualizerTimeline = {
129
+ export type VisualizerTrack = {
124
130
  id: string;
125
131
  type: string;
126
132
  elements: VisualizerElement[];
127
- captions?: Caption[];
128
133
  props?: {
129
134
  capStyle?: string;
130
135
  bgOpacity?: number;
@@ -144,3 +149,93 @@ export type VisualizerTimeline = {
144
149
  captionProps?: CaptionProps;
145
150
  };
146
151
  };
152
+
153
+ export type ElementParams = {
154
+ view: View2D;
155
+ containerRef: Reference<any>;
156
+ element?: VisualizerElement;
157
+ caption?: Caption;
158
+ waitOnStart?: boolean;
159
+ };
160
+
161
+ export interface Element<Params = ElementParams> {
162
+ name: string;
163
+ create(params: Params): ThreadGenerator;
164
+ }
165
+
166
+ export type TextEffectParams = {
167
+ elementRef: Reference<any>;
168
+ interval?: number;
169
+ duration?: number;
170
+ bufferTime?: number;
171
+ delay?: number;
172
+ direction?: "left" | "right" | "center";
173
+ };
174
+
175
+ export type TextEffectProps = {
176
+ name: string;
177
+ interval?: number;
178
+ duration?: number;
179
+ bufferTime?: number;
180
+ delay?: number;
181
+ direction?: "left" | "right" | "center";
182
+ };
183
+
184
+ export interface TextEffect<Params = TextEffectParams> {
185
+ name: string;
186
+ run(params: Params): Generator;
187
+ }
188
+
189
+ export type AnimationParams = {
190
+ elementRef: Reference<any>;
191
+ containerRef?: Reference<any>;
192
+ view: View2D;
193
+ interval?: number;
194
+ duration?: number;
195
+ intensity?: number;
196
+ mode?: "in" | "out";
197
+ animate?: "enter" | "exit" | "both";
198
+ direction?: "left" | "right" | "center" | "up" | "down";
199
+ };
200
+
201
+ export type AnimationProps = {
202
+ name: string;
203
+ interval?: number;
204
+ duration?: number;
205
+ intensity?: number;
206
+ mode?: "in" | "out";
207
+ animate?: "enter" | "exit" | "both";
208
+ direction?: "left" | "right" | "center" | "up" | "down";
209
+ };
210
+
211
+ export interface Animation<Params = AnimationParams> {
212
+ name: string;
213
+ run(params: Params): ThreadGenerator;
214
+ }
215
+
216
+ export type FrameEffectParams = {
217
+ elementRef: Reference<any>;
218
+ containerRef?: Reference<any>;
219
+ initFrameState: FrameState;
220
+ frameEffect: FrameEffect;
221
+ };
222
+
223
+ export interface FrameEffectPlugin<Params = FrameEffectParams> {
224
+ name: string;
225
+ run(params: Params): ThreadGenerator;
226
+ }
227
+
228
+ export type FrameState = {
229
+ frame: {
230
+ size: Vector2;
231
+ pos: Vector2;
232
+ radius: number;
233
+ scale: Vector2;
234
+ rotation: number;
235
+ };
236
+ element: {
237
+ size: Vector2;
238
+ pos: Vector2;
239
+ scale: Vector2;
240
+ };
241
+ };
@@ -0,0 +1,20 @@
1
+ export const hexToRGB = (color: string) => {
2
+ // Remove leading '#' if present
3
+ let hex = color.replace(/^#/, '');
4
+
5
+ // Handle shorthand hex (e.g., #abc)
6
+ if (hex.length === 3) {
7
+ hex = hex.split('').map(c => c + c).join('');
8
+ }
9
+
10
+ if (hex.length !== 6) {
11
+ throw new Error('Invalid hex color');
12
+ }
13
+
14
+ const num = parseInt(hex, 16);
15
+ const r = (num >> 16) & 255;
16
+ const g = (num >> 8) & 255;
17
+ const b = num & 255;
18
+
19
+ return { r, g, b };
20
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
- export * from './visualizer';
2
- export * from './components/element';
1
+ // Types
3
2
  export * from './helpers/types';
4
3
 
4
+ // Components
5
+ export * from './visualizer';
6
+
package/src/live.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { makeProject } from "@revideo/core";
1
+ import { makeProject } from "@twick/core";
2
2
  import { scene } from "./visualizer";
3
3
  import { sample } from "./sample";
4
4
 
package/src/project.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { makeProject } from "@revideo/core";
1
+ import { makeProject } from "@twick/core";
2
2
  import { scene } from "./visualizer";
3
3
 
4
4
  export default makeProject({
package/src/sample.ts CHANGED
@@ -26,7 +26,7 @@ export const sample = {
26
26
  "orgId": "a251d9971a55"
27
27
  },
28
28
  "basePath": "carlyn",
29
- "timeline": [
29
+ "tracks": [
30
30
  {
31
31
  "id": "t-scene",
32
32
  "type": "scene",
@@ -45,6 +45,13 @@ export const sample = {
45
45
  "volume": 1,
46
46
  "playbackRate": 2.4
47
47
  },
48
+ "animation": {
49
+ "name": "photo-zoom",
50
+ "interval": 1,
51
+ "mode": "out",
52
+ "animate": "both",
53
+ "direction": "top"
54
+ },
48
55
  "frame": {
49
56
  "size": [
50
57
  720,
@@ -102,130 +109,6 @@ export const sample = {
102
109
  "x": 269.48206359326264,
103
110
  "y": 6.293648227413769
104
111
  }
105
- },
106
- {
107
- "id": "e-8e7713322971",
108
- "type": "video",
109
- "s": 9.395,
110
- "e": 11.045625,
111
- "objectFit": "cover",
112
- "props": {
113
- "time": 0,
114
- "src": "https://static-assets.kifferai.com/developmen/a251d9971a55/785c23ac-46b9-412e-afa6-a2cb56359409/video_1746791788884-91cb1098-d5f0-4528-83ee-40396774362e-normalized-1747225092682.mp4",
115
- "play": true,
116
- "decoder": "web",
117
- "volume": 1,
118
- "playbackRate": 1.6
119
- },
120
- "frame": {
121
- "size": [
122
- 2308.2048543926235,
123
- 1295.1593905203056
124
- ],
125
- "layout": false,
126
- "clip": "true",
127
- "rotation": 0,
128
- "x": -13.276787982361498,
129
- "y": 7.751279402190107
130
- }
131
- },
132
- {
133
- "id": "e-7531e6fa1e0d",
134
- "type": "video",
135
- "s": 11.046,
136
- "e": 12.07,
137
- "objectFit": "cover",
138
- "props": {
139
- "time": 7.721,
140
- "src": "https://static-assets.kifferai.com/developmen/a251d9971a55/9e75d848-ee72-417c-a0ab-9b03cbe8a8e8/video_1746791788884-91cb1098-d5f0-4528-83ee-40396774362e-normalized-1747225094687.mp4",
141
- "play": true,
142
- "decoder": "web",
143
- "volume": 1,
144
- "playbackRate": 1.25
145
- },
146
- "frame": {
147
- "size": [
148
- 2311.1861840816873,
149
- 1296.8322477347244
150
- ],
151
- "layout": false,
152
- "clip": "true",
153
- "rotation": 0,
154
- "x": 449.37247349257666,
155
- "y": 9.38850926628038
156
- }
157
- },
158
- {
159
- "id": "e-3b7b7272a680",
160
- "type": "video",
161
- "s": 12.07,
162
- "e": 12.65,
163
- "objectFit": "cover",
164
- "props": {
165
- "time": 15.761000000000001,
166
- "src": "https://static-assets.kifferai.com/developmen/a251d9971a55/c8411732-b0c8-45a9-808d-e5adf6396cc0/video_1746791788884-91cb1098-d5f0-4528-83ee-40396774362e-normalized-1747225095328.mp4",
167
- "play": true,
168
- "decoder": "web"
169
- },
170
- "frame": {
171
- "size": [
172
- 2350.1907312156336,
173
- 1318.718132515439
174
- ],
175
- "layout": false,
176
- "clip": "true",
177
- "rotation": 0,
178
- "x": 172.4452216608522,
179
- "y": 15.93068052161425
180
- }
181
- },
182
- {
183
- "id": "e-6121e86e1529",
184
- "type": "video",
185
- "s": 12.65,
186
- "e": 12.97,
187
- "objectFit": "cover",
188
- "props": {
189
- "time": 16.467,
190
- "src": "https://static-assets.kifferai.com/developmen/a251d9971a55/ebdd6f60-5750-4228-a5cd-f321f60664c2/video_1746791788884-91cb1098-d5f0-4528-83ee-40396774362e-normalized-1747225093091.mp4",
191
- "play": true,
192
- "decoder": "web"
193
- },
194
- "frame": {
195
- "size": [
196
- 2250.6441550073,
197
- 1262.861442531874
198
- ],
199
- "layout": false,
200
- "clip": "true",
201
- "rotation": 0,
202
- "x": 349.31120819483976,
203
- "y": 10.893313528412591
204
- }
205
- },
206
- {
207
- "id": "e-1b1fe6998ebd",
208
- "type": "video",
209
- "s": 12.97,
210
- "e": 16.644000000000002,
211
- "objectFit": "cover",
212
- "props": {
213
- "time": 23.286,
214
- "src": "https://static-assets.kifferai.com/developmen/a251d9971a55/d0156321-eb31-4168-adc0-9c4ad1fd3963/video_1746791788884-91cb1098-d5f0-4528-83ee-40396774362e-normalized-1747225092630.mp4",
215
- "play": true,
216
- "decoder": "web"
217
- },
218
- "frame": {
219
- "size": [
220
- 2338.697802344495,
221
- 1312.2693224266334
222
- ],
223
- "layout": false,
224
- "clip": "true",
225
- "rotation": 0,
226
- "x": 105.53672564636827,
227
- "y": 12.299877537072007
228
- }
229
112
  }
230
113
  ],
231
114
  "name": "scene"
@@ -239,7 +122,7 @@ export const sample = {
239
122
  "id": "e-50848c2d8271",
240
123
  "type": "audio",
241
124
  "s": 0,
242
- "e": 16.587755,
125
+ "e": 10,
243
126
  "props": {
244
127
  "time": 0,
245
128
  "src": "https://static-assets.kifferai.com/developmen/a251d9971a55/brand-music/Unstoppable-Reprise-909549aa-6807-482b-ab92-bce7e6834fe7.mp3",
@@ -267,6 +150,10 @@ export const sample = {
267
150
  "s": 0.009,
268
151
  "e": 4.691000000000001,
269
152
  "t": "GOD WILL BREAK YOU",
153
+ "textEffect": {
154
+ "name": "elastic",
155
+ "duration": 1,
156
+ },
270
157
  "props": {
271
158
  "fill": "#ffd700",
272
159
  "font": {
@@ -352,99 +239,10 @@ export const sample = {
352
239
  "x": 4.522605909576328,
353
240
  "y": 81.35589157062759
354
241
  }
355
- },
356
- {
357
- "id": "e-7eada9ab2394",
358
- "type": "text",
359
- "s": 9.521,
360
- "e": 10.993,
361
- "t": "BUT",
362
- "props": {
363
- "fill": "#ffd700",
364
- "font": {
365
- "size": 48,
366
- "family": "Poppins"
367
- },
368
- "fontSize": 54,
369
- "fontFamily": "Impact",
370
- "stroke": "#000000",
371
- "lineWidth": 0.25,
372
- "fontWeight": "bold",
373
- "fontStyle": "Italic"
374
- }
375
- },
376
- {
377
- "id": "e-03039d49039b",
378
- "type": "text",
379
- "s": 11.171,
380
- "e": 12.831999999999999,
381
- "t": "IN THE END",
382
- "props": {
383
- "fill": "#ffd700",
384
- "font": {
385
- "size": 48,
386
- "family": "Poppins"
387
- },
388
- "fontSize": 54,
389
- "fontFamily": "Impact",
390
- "stroke": "#000000",
391
- "lineWidth": 0.25,
392
- "fontWeight": "bold",
393
- "fontStyle": "Italic",
394
- "rotation": 0,
395
- "x": 2.2613029547882206,
396
- "y": 108.4745220941702
397
- }
398
- },
399
- {
400
- "id": "e-91d44d6180f9",
401
- "type": "text",
402
- "s": 13.077,
403
- "e": 16.543,
404
- "t": "HE WILL MAKE YOU",
405
- "props": {
406
- "fill": "#FFFFFF",
407
- "font": {
408
- "size": 48,
409
- "family": "Poppins"
410
- },
411
- "fontSize": 54,
412
- "fontFamily": "Impact",
413
- "stroke": "#000000",
414
- "lineWidth": 0.25,
415
- "fontWeight": "bold",
416
- "fontStyle": "normal",
417
- "rotation": 0,
418
- "x": 0,
419
- "y": 27.118630523542492
420
- }
421
- },
422
- {
423
- "id": "e-ebf12eac0207",
424
- "type": "text",
425
- "s": 13.811,
426
- "e": 16.534,
427
- "t": "UNSTOPPABLE",
428
- "props": {
429
- "fill": "#ffd700",
430
- "font": {
431
- "size": 48,
432
- "family": "Poppins"
433
- },
434
- "rotation": 0,
435
- "x": -6.783908864364491,
436
- "y": 99.43497858632281,
437
- "fontSize": 58,
438
- "fontFamily": "Impact",
439
- "stroke": "#000000",
440
- "lineWidth": 0.25,
441
- "fontWeight": "bold",
442
- "fontStyle": "Italic"
443
- }
444
242
  }
445
243
  ]
446
244
  }
447
- ]
448
- },
449
- "version": 1
245
+ ],
246
+ "version": 1
247
+ }
450
248
  }
@@ -0,0 +1,39 @@
1
+ import { easeOutElastic, waitFor } from "@twick/core";
2
+ import { TextEffectParams } from "../helpers/types";
3
+
4
+ /**
5
+ * ElasticEffect applies a scaling animation to text elements
6
+ * with an elastic easing curve for a "pop" or "bounce" effect.
7
+ *
8
+ * Behavior:
9
+ * - Optionally waits for a delay.
10
+ * - Starts at zero scale (invisible).
11
+ * - Scales up to full size with an elastic bounce.
12
+ *
13
+ * @param elementRef - Reference to the text element to animate.
14
+ * @param duration - Duration of the scaling animation.
15
+ * @param delay - Optional delay before the animation starts.
16
+ */
17
+ export const ElasticEffect = {
18
+ name: "elastic",
19
+
20
+ /**
21
+ * Generator function controlling the elastic text scaling effect.
22
+ */
23
+ *run({
24
+ elementRef,
25
+ duration,
26
+ delay,
27
+ }: TextEffectParams) {
28
+ // If a delay is specified, wait before starting the animation
29
+ if (delay) {
30
+ yield* waitFor(delay);
31
+ }
32
+
33
+ // Instantly set scale to 0 (invisible)
34
+ elementRef().scale(0);
35
+
36
+ // Animate scaling up to full size using elastic easing
37
+ yield* elementRef().scale(1, duration, easeOutElastic);
38
+ },
39
+ };
@@ -0,0 +1,58 @@
1
+ import { waitFor } from "@twick/core";
2
+ import { TextEffectParams } from "../helpers/types";
3
+
4
+ /**
5
+ * EraseEffect animates text disappearing letter by letter,
6
+ * simulating an "erasing" or backspace effect.
7
+ *
8
+ * Behavior:
9
+ * - Optionally waits for a delay before starting.
10
+ * - Preserves the original element size.
11
+ * - Animates removing one character at a time from the end.
12
+ *
13
+ * @param elementRef - Reference to the text element to animate.
14
+ * @param duration - Total duration of the erasing animation.
15
+ * @param delay - Optional delay before starting.
16
+ * @param bufferTime - Time reserved at the end of animation (default: 0.1).
17
+ */
18
+ export const EraseEffect = {
19
+ name: "erase",
20
+
21
+ /**
22
+ * Generator function controlling the erase text effect.
23
+ */
24
+ *run({
25
+ elementRef,
26
+ duration,
27
+ delay,
28
+ bufferTime = 0.1,
29
+ }: TextEffectParams) {
30
+ // Get the full original text
31
+ const fullText = elementRef().text();
32
+
33
+ // Store the original size to avoid resizing during animation
34
+ const size = elementRef().size();
35
+
36
+ // Initialize element: clear text, set fixed size, align left
37
+ elementRef().setText("");
38
+ elementRef().size(size);
39
+ elementRef().textAlign("left");
40
+
41
+ // Wait for the optional initial delay
42
+ if (delay) {
43
+ yield* waitFor(delay);
44
+ }
45
+
46
+ // Compute the time interval between each character removal
47
+ let timeInterval = (duration - bufferTime) / fullText.length;
48
+
49
+ // Optionally wait a bit before starting erasing
50
+ yield* waitFor(timeInterval);
51
+
52
+ // Loop backwards through the text and erase one character at a time
53
+ for (let i = fullText.length; i >= 0; i--) {
54
+ yield* waitFor(timeInterval);
55
+ elementRef().setText(fullText.substring(0, i));
56
+ }
57
+ },
58
+ };
@@ -0,0 +1,60 @@
1
+ import { waitFor } from "@twick/core";
2
+ import { TextEffectParams } from "../helpers/types";
3
+
4
+ /**
5
+ * StreamWordEffect animates text appearing word by word,
6
+ * creating a smooth "typing" or "streaming" effect.
7
+ *
8
+ * Behavior:
9
+ * - Optionally waits for a delay before starting.
10
+ * - Clears the text initially and preserves the original size.
11
+ * - Reveals one word at a time with a consistent interval.
12
+ *
13
+ * @param elementRef - Reference to the text element to animate.
14
+ * @param duration - Total duration of the animation.
15
+ * @param delay - Optional delay before starting.
16
+ * @param bufferTime - Time reserved at the end of animation (default: 0.1).
17
+ */
18
+ export const StreamWordEffect = {
19
+ name: "stream-word",
20
+
21
+ /**
22
+ * Generator function controlling the word streaming effect.
23
+ */
24
+ *run({
25
+ elementRef,
26
+ duration,
27
+ delay,
28
+ bufferTime = 0.1,
29
+ }: TextEffectParams) {
30
+ // Retrieve the full text content
31
+ const fullText = elementRef().text();
32
+
33
+ // Store the element's size to avoid resizing during animation
34
+ const size = elementRef().size();
35
+
36
+ // Split the text into words
37
+ const words = fullText.split(" ");
38
+
39
+ // Clear the text and set fixed size
40
+ elementRef().setText("");
41
+ elementRef().size(size);
42
+
43
+ // Wait for optional delay before starting
44
+ if (delay) {
45
+ yield* waitFor(delay);
46
+ }
47
+
48
+ // Align text to the left
49
+ elementRef().textAlign("left");
50
+
51
+ // Calculate the interval between words
52
+ let timeInterval =(duration - bufferTime) / words.length;
53
+
54
+ // Reveal each word one at a time
55
+ for (let i = 0; i < words.length; i++) {
56
+ yield* waitFor(timeInterval);
57
+ elementRef().setText(words.slice(0, i + 1).join(" "));
58
+ }
59
+ },
60
+ };