liquidsoap-prettier 1.8.2 → 1.8.3

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 (67) hide show
  1. package/.github/workflows/check-formatting.yml +32 -0
  2. package/README.md +31 -5
  3. package/package.json +1 -1
  4. package/src/cli.js +104 -9
  5. package/tests/liq/audio.liq +460 -0
  6. package/tests/liq/autocue.liq +1081 -0
  7. package/tests/liq/clock.liq +14 -0
  8. package/tests/liq/cron.liq +74 -0
  9. package/tests/liq/error.liq +48 -0
  10. package/tests/liq/extra/audio.liq +677 -0
  11. package/tests/liq/extra/audioscrobbler.liq +482 -0
  12. package/tests/liq/extra/deprecations.liq +976 -0
  13. package/tests/liq/extra/externals.liq +196 -0
  14. package/tests/liq/extra/fades.liq +260 -0
  15. package/tests/liq/extra/file.liq +66 -0
  16. package/tests/liq/extra/http.liq +160 -0
  17. package/tests/liq/extra/interactive.liq +917 -0
  18. package/tests/liq/extra/metadata.liq +75 -0
  19. package/tests/liq/extra/native.liq +201 -0
  20. package/tests/liq/extra/openai.liq +150 -0
  21. package/tests/liq/extra/server.liq +177 -0
  22. package/tests/liq/extra/source.liq +476 -0
  23. package/tests/liq/extra/spinitron.liq +272 -0
  24. package/tests/liq/extra/telnet.liq +266 -0
  25. package/tests/liq/extra/video.liq +59 -0
  26. package/tests/liq/extra/visualization.liq +68 -0
  27. package/tests/liq/fades.liq +941 -0
  28. package/tests/liq/ffmpeg.liq +605 -0
  29. package/tests/liq/file.liq +387 -0
  30. package/tests/liq/getter.liq +74 -0
  31. package/tests/liq/hls.liq +329 -0
  32. package/tests/liq/http.liq +1048 -0
  33. package/tests/liq/http_codes.liq +447 -0
  34. package/tests/liq/icecast.liq +58 -0
  35. package/tests/liq/io.liq +106 -0
  36. package/tests/liq/liquidsoap.liq +31 -0
  37. package/tests/liq/list.liq +440 -0
  38. package/tests/liq/log.liq +47 -0
  39. package/tests/liq/lufs.liq +295 -0
  40. package/tests/liq/math.liq +23 -0
  41. package/tests/liq/medialib.liq +752 -0
  42. package/tests/liq/metadata.liq +253 -0
  43. package/tests/liq/nfo.liq +258 -0
  44. package/tests/liq/null.liq +71 -0
  45. package/tests/liq/playlist.liq +1347 -0
  46. package/tests/liq/predicate.liq +106 -0
  47. package/tests/liq/process.liq +93 -0
  48. package/tests/liq/profiler.liq +5 -0
  49. package/tests/liq/protocols.liq +1139 -0
  50. package/tests/liq/ref.liq +28 -0
  51. package/tests/liq/replaygain.liq +135 -0
  52. package/tests/liq/request.liq +467 -0
  53. package/tests/liq/resolvers.liq +33 -0
  54. package/tests/liq/runtime.liq +70 -0
  55. package/tests/liq/server.liq +99 -0
  56. package/tests/liq/settings.liq +41 -0
  57. package/tests/liq/socket.liq +33 -0
  58. package/tests/liq/source.liq +362 -0
  59. package/tests/liq/sqlite.liq +161 -0
  60. package/tests/liq/stdlib.liq +172 -0
  61. package/tests/liq/string.liq +476 -0
  62. package/tests/liq/switches.liq +197 -0
  63. package/tests/liq/testing.liq +37 -0
  64. package/tests/liq/thread.liq +161 -0
  65. package/tests/liq/tracks.liq +100 -0
  66. package/tests/liq/utils.liq +81 -0
  67. package/tests/liq/video.liq +918 -0
@@ -0,0 +1,272 @@
1
+ let spinitron = {submit = ()}
2
+
3
+ # Submit a track to the spinitron track system
4
+ # and return the raw response.
5
+ # @category Interaction
6
+ # @flag extra
7
+ # @param ~api_key API key
8
+ def spinitron.submit.raw(
9
+ ~host="https://spinitron.com/api",
10
+ ~api_key,
11
+ ~live=false,
12
+ ~start=null,
13
+ ~duration=null,
14
+ ~artist,
15
+ ~release=null,
16
+ ~label=null,
17
+ ~genre=null,
18
+ ~song,
19
+ ~composer=null,
20
+ ~isrc=null
21
+ ) =
22
+ params = [("song", song), ("artist", artist)]
23
+
24
+ def fold_optional_string_params(params, param) =
25
+ let (label, param) = param
26
+ if
27
+ null.defined(param)
28
+ then
29
+ [(label, null.get(param)), ...params]
30
+ else
31
+ params
32
+ end
33
+ end
34
+
35
+ params =
36
+ list.fold(
37
+ fold_optional_string_params,
38
+ params,
39
+ [
40
+ ("live", null.map(fun (b) -> b ? "1" : "0", (live : bool?))),
41
+ ("start", start),
42
+ ("duration", null.map(string, (duration : int?))),
43
+ ("release", release),
44
+ ("label", label),
45
+ ("genre", genre),
46
+ ("composer", composer),
47
+ ("isrc", isrc)
48
+ ]
49
+ )
50
+
51
+ def encode_param(param) =
52
+ let (label, param) = param
53
+ "#{label}=#{url.encode(param)}"
54
+ end
55
+
56
+ params = string.concat(separator="&", list.map(encode_param, params))
57
+
58
+ http.post(
59
+ data=params,
60
+ headers=[
61
+ ("Accept", "application/json"),
62
+ ("Content-Type", "application/x-www-form-urlencoded"),
63
+ (
64
+ "Authorization",
65
+ "Bearer #{(api_key : string)}"
66
+ )
67
+ ],
68
+ "#{host}/spins"
69
+ )
70
+ end
71
+
72
+ # Submit a track to the spinitron track system
73
+ # and return the parsed response
74
+ # @category Interaction
75
+ # @flag extra
76
+ # @param ~api_key API key
77
+ def replaces spinitron.submit(%argsof(spinitron.submit.raw)) =
78
+ resp = spinitron.submit.raw(%argsof(spinitron.submit.raw))
79
+
80
+ if
81
+ resp.status_code == 201
82
+ then
83
+ let json.parse (resp :
84
+ {
85
+ id: int,
86
+ playlist_id: int,
87
+ "start" as spin_start: string,
88
+ "end" as spin_end: string?,
89
+ duration: int?,
90
+ timezone: string?,
91
+ image: string?,
92
+ classical: bool?,
93
+ artist: string,
94
+ "artist-custom" as artist_custom: string?,
95
+ composer: string?,
96
+ release: string?,
97
+ "release-custom" as release_custom: string?,
98
+ va: bool?,
99
+ label: string?,
100
+ "label-custom" as label_custom: string?,
101
+ released: int?,
102
+ medium: string?,
103
+ genre: string?,
104
+ song: string,
105
+ note: string?,
106
+ request: bool?,
107
+ local: bool?,
108
+ new: bool?,
109
+ work: string?,
110
+ conductor: string?,
111
+ performers: string?,
112
+ ensemble: string?,
113
+ "catalog-number" as catalog_number: string?,
114
+ isrc: string?,
115
+ upc: string?,
116
+ iswc: string?,
117
+ "_links" as links: {self: {href: string}?, playlist: {href: string}?}?
118
+ }
119
+ ) = resp
120
+
121
+ resp
122
+ elsif
123
+ resp.status_code == 422
124
+ then
125
+ let json.parse (errors : [{field: string, message: string}]) = resp
126
+
127
+ errors =
128
+ list.map(
129
+ fun (p) ->
130
+ begin
131
+ let {field, message} = p
132
+ "#{field}: #{message}"
133
+ end,
134
+ errors
135
+ )
136
+
137
+ errors =
138
+ string.concat(
139
+ separator=", ",
140
+ errors
141
+ )
142
+
143
+ error.raise(
144
+ error.raise(
145
+ error.http,
146
+ "Invalid fields: #{errors}"
147
+ )
148
+ )
149
+ else
150
+ let json.parse ({name, message, code, status, type} :
151
+ {name: string, message: string, code: int, status: int, type: string?}
152
+ ) = resp
153
+
154
+ type = type ?? "undefined"
155
+
156
+ error.raise(
157
+ error.raise(
158
+ error.http,
159
+ "#{name}: #{message} (code: #{code}, status: #{status}, type: #{type})"
160
+ )
161
+ )
162
+ end
163
+ end
164
+
165
+ # Submit a spin using the given metadata to the spinitron track system
166
+ # and return the parsed response. `artist` and `song` (or `title`) must
167
+ # be present either as metadata or as optional argument.
168
+ # @category Interaction
169
+ # @flag extra
170
+ # @param m Metadata to submit. Overrides optional arguments when present.
171
+ # @param ~mapper Metadata mapper that can be used to map metadata fields to spinitron's expected. \
172
+ # Returned metadata are added to the submitted metadata. By default, `title` is \
173
+ # mapped to `song` and `album` to `release` if neither of those passed otherwise.
174
+ # @param ~api_key API key
175
+ def spinitron.submit.metadata(
176
+ %argsof(spinitron.submit[!artist,!song]),
177
+ ~mapper=(
178
+ fun (m) ->
179
+ [
180
+ ...(m["song"] != "" or m["title"] == "" ? [] : [("song", m["title"])]),
181
+ ...(
182
+ m["release"] != "" or m["album"] == ""
183
+ ? []
184
+ : [("release", m["album"])]
185
+ )
186
+ ]
187
+ ),
188
+ ~artist=null,
189
+ ~song=null,
190
+ m
191
+ ) =
192
+ m = [...m, ...mapper(m)]
193
+
194
+ def conv_opt_arg(convert, label, default) =
195
+ list.assoc.mem(label, m) ? convert(m[label]) : default
196
+ end
197
+
198
+ opt_arg =
199
+ fun (label, default) -> conv_opt_arg(fun (x) -> null(x), label, default)
200
+
201
+ live = conv_opt_arg(bool_of_string, "live", live)
202
+ start = opt_arg("start", start)
203
+ duration = conv_opt_arg(int_of_string, "duration", duration)
204
+ artist = opt_arg("artist", artist)
205
+ release = opt_arg("release", release)
206
+ label = opt_arg("label", label)
207
+ genre = opt_arg("genre", genre)
208
+ song = opt_arg("song", song)
209
+ composer = opt_arg("composer", composer)
210
+ isrc = opt_arg("isrc", isrc)
211
+
212
+ if
213
+ artist == null or song == null
214
+ then
215
+ error.raise(
216
+ error.invalid,
217
+ "Both \"artist\" and \"song\" (or \"title\" metadata) must be provided!"
218
+ )
219
+ end
220
+
221
+ artist = null.get(artist)
222
+ song = null.get(song)
223
+
224
+ res = spinitron.submit(%argsof(spinitron.submit))
225
+
226
+ print(res)
227
+
228
+ res
229
+ end
230
+
231
+ # Specialized version of `s.on_metadata` that submits spins using
232
+ # the source's metadata to the spinitron track system. `artist` and `song`
233
+ # (or `title`) must be present either as metadata or as optional argument.
234
+ # @category Interaction
235
+ # @flag extra
236
+ # @param m Metadata to submit. Overrides optional arguments when present.
237
+ # @param ~api_key API key
238
+ def spinitron.submit.on_metadata(%argsof(spinitron.submit.metadata), s) =
239
+ def on_metadata(m) =
240
+ if
241
+ m["title"] == "" and m["song"] == ""
242
+ then
243
+ log.severe(
244
+ label=source.id(s),
245
+ "Field \"song\" or \"title\" missing, skipping metadata spinitron \
246
+ submission."
247
+ )
248
+ elsif
249
+ m["artist"] == ""
250
+ then
251
+ log.severe(
252
+ label=source.id(s),
253
+ "Field \"artist\" missing, skipping metadata spinitron submission."
254
+ )
255
+ else
256
+ try
257
+ ignore(spinitron.submit.metadata(%argsof(spinitron.submit.metadata), m))
258
+ log.important(
259
+ label=source.id(s),
260
+ "Successfully submitted spin from metadata"
261
+ )
262
+ catch err do
263
+ log.severe(
264
+ label=source.id(s),
265
+ "Error while submitting spin from metadata: #{err}"
266
+ )
267
+ end
268
+ end
269
+ end
270
+
271
+ source.methods(s).on_metadata(synchronous=false, on_metadata)
272
+ end
@@ -0,0 +1,266 @@
1
+ # @docof request.dynamic
2
+ def replaces request.dynamic(%argsof(request.dynamic), fn) =
3
+ s = request.dynamic(%argsof(request.dynamic), fn)
4
+ s.on_wake_up(
5
+ synchronous=false,
6
+ memoize(
7
+ {
8
+ server.register(
9
+ namespace=source.id(s),
10
+ description="Flush the queue and skip the current track",
11
+ "flush_and_skip",
12
+ fun (_) ->
13
+ try
14
+ s.set_queue([])
15
+ s.skip()
16
+ "Done."
17
+ catch err do
18
+ "Error while flushing and skipping source: #{err}"
19
+ end
20
+ )
21
+ }
22
+ )
23
+ )
24
+
25
+ s
26
+ end
27
+
28
+ # @docof blank.strip
29
+ def replaces blank.strip(%argsof(blank.strip), s) =
30
+ s = blank.strip(%argsof(blank.strip), s)
31
+ s.on_wake_up(
32
+ synchronous=false,
33
+ memoize(
34
+ {
35
+ server.register(
36
+ namespace=source.id(s),
37
+ description="Check if the source is stripping.",
38
+ "is_stripping",
39
+ fun (_) -> begin "#{s.is_blank()}" end
40
+ )
41
+ }
42
+ )
43
+ )
44
+
45
+ s
46
+ end
47
+
48
+ # @docof output.external
49
+ def replaces output.external(%argsof(output.external), f, p, s) =
50
+ s = output.external(%argsof(output.external), f, p, s)
51
+ s.on_wake_up(
52
+ synchronous=false,
53
+ memoize(
54
+ {
55
+ server.register(
56
+ namespace=s.id(),
57
+ description="Re-open the output.",
58
+ "reopen",
59
+ fun (_) ->
60
+ begin
61
+ s.reopen()
62
+ "Done."
63
+ end
64
+ )
65
+ }
66
+ )
67
+ )
68
+
69
+ s
70
+ end
71
+
72
+ # @docof input.external.avi
73
+ def replaces input.external.avi(%argsof(input.external.avi), s) =
74
+ s = input.external.avi(%argsof(input.external.avi), s)
75
+ s.on_wake_up(
76
+ synchronous=false,
77
+ memoize(
78
+ {
79
+ server.register(
80
+ namespace=source.id(s),
81
+ description="Show internal buffer length (in seconds).",
82
+ "buffer_length",
83
+ fun (_) ->
84
+ begin
85
+ buffered = s.buffered()
86
+ audio = list.assoc(default=0., "audio", buffered)
87
+ video = list.assoc(default=0., "video", buffered)
88
+ total = min(audio, video)
89
+ "audio buffer length: #{audio}\nvideo buffer length: #{
90
+ video
91
+ }\ntotal buffer length: #{total}"
92
+ end
93
+ )
94
+ }
95
+ )
96
+ )
97
+
98
+ s
99
+ end
100
+
101
+ # @docof input.harbor
102
+ def replaces input.harbor(%argsof(input.harbor), s) =
103
+ s = input.harbor(%argsof(input.harbor), s)
104
+ s.on_wake_up(
105
+ synchronous=false,
106
+ memoize(
107
+ {
108
+ begin
109
+ server.register(
110
+ namespace=source.id(s),
111
+ description="Stop current source client, if connected.",
112
+ "stop",
113
+ fun (_) ->
114
+ begin
115
+ s.stop()
116
+ "Done"
117
+ end
118
+ )
119
+
120
+ server.register(
121
+ namespace=source.id(s),
122
+ description="Display current status.",
123
+ "status",
124
+ fun (_) -> begin s.status() end
125
+ )
126
+
127
+ server.register(
128
+ namespace=source.id(s),
129
+ description="Get the buffer's length, in seconds.",
130
+ "buffer_length",
131
+ fun (_) -> begin "#{s.buffer_length()}" end
132
+ )
133
+ end
134
+ }
135
+ )
136
+ )
137
+
138
+ s
139
+ end
140
+
141
+ %ifdef input.http
142
+ # @docof input.http
143
+ def replaces input.http(%argsof(input.http), s) =
144
+ s = input.http(%argsof(input.http), s)
145
+ s.on_wake_up(
146
+ synchronous=false,
147
+ memoize(
148
+ {
149
+ begin
150
+ server.register(
151
+ namespace=source.id(s),
152
+ description="Start the source, if needed.",
153
+ "start",
154
+ fun (_) ->
155
+ begin
156
+ s.start()
157
+ "Done!"
158
+ end
159
+ )
160
+
161
+ server.register(
162
+ namespace=source.id(s),
163
+ description="Stop the source if connected.",
164
+ "stop",
165
+ fun (_) ->
166
+ begin
167
+ s.stop()
168
+ "Done!"
169
+ end
170
+ )
171
+
172
+ server.register(
173
+ namespace=source.id(s),
174
+ description="Get or set the stream's HTTP URL. Setting a new URL \
175
+ will not affect an ongoing connection.",
176
+ usage="url [url]",
177
+ "url",
178
+ fun (url) ->
179
+ begin
180
+ if
181
+ url == ""
182
+ then
183
+ s.url()
184
+ else
185
+ begin
186
+ s.set_url({url})
187
+ "Done!"
188
+ end
189
+ end
190
+ end
191
+ )
192
+
193
+ server.register(
194
+ namespace=source.id(s),
195
+ description="Return the current status of the source, either \
196
+ \"stopped\" (the source isn't trying to relay the HTTP stream), \
197
+ \"polling\" (attempting to connect to the HTTP stream) or \
198
+ \"connected <url>\" (connected to <url>, buffering or playing back \
199
+ the stream).",
200
+ "status",
201
+ fun (_) -> begin s.status() end
202
+ )
203
+
204
+ server.register(
205
+ namespace=source.id(s),
206
+ description="Get the buffer's length, in seconds.",
207
+ "buffer_length",
208
+ fun (_) ->
209
+ "#{list.fold(fun (l, (_, l')) -> max(l, l'), 0., s.buffered())}"
210
+ )
211
+ end
212
+ }
213
+ )
214
+ )
215
+
216
+ s
217
+ end
218
+ %endif
219
+
220
+ # @docof lufs
221
+ def replaces lufs(%argsof(lufs), s) =
222
+ s = lufs(%argsof(lufs), s)
223
+ s.on_wake_up(
224
+ synchronous=false,
225
+ memoize(
226
+ {
227
+ server.register(
228
+ namespace=source.id(s),
229
+ description="Current value for the LUFS (short-term value computed \
230
+ over the duration specified by the `window` parameter).",
231
+ "lufs",
232
+ fun (_) ->
233
+ "#{s.lufs()} LUFS"
234
+ )
235
+
236
+ server.register(
237
+ namespace=source.id(s),
238
+ description="Average LUFS value over the current track.",
239
+ "lufs_integrated",
240
+ fun (_) ->
241
+ "#{s.lufs_integrated()} LUFS"
242
+ )
243
+
244
+ server.register(
245
+ namespace=source.id(s),
246
+ description="Momentary LUFS (over a 400ms window).",
247
+ "lufs_momentary",
248
+ fun (_) ->
249
+ "#{s.lufs_momentary()} LUFS"
250
+ )
251
+ }
252
+ )
253
+ )
254
+
255
+ s
256
+ end
257
+
258
+ server.register(
259
+ description="Shutdown the running liquidsoap instance",
260
+ "shutdown",
261
+ fun (_) ->
262
+ begin
263
+ shutdown()
264
+ "OK"
265
+ end
266
+ )
@@ -0,0 +1,59 @@
1
+ # Extract cover from the source's metadata and add it as a static image.
2
+ # @category Track / Video processing
3
+ # @flag extra
4
+ # @param ~id Force the value of the source ID.
5
+ # @param ~fallible Whether we are allowed to fail (in case the file is non-existent or invalid).
6
+ # @param ~width Scale to width
7
+ # @param ~height Scale to height
8
+ # @param ~x x position.
9
+ # @param ~y y position.
10
+ # @param ~default Default cover file when no cover is available
11
+ # @param ~mime_types Recognized mime types and their corresponding file extensions.
12
+ def video.add_cover(
13
+ ~fallible=false,
14
+ ~width=null,
15
+ ~height=null,
16
+ ~x=getter(0),
17
+ ~y=getter(0),
18
+ ~mime_types=[
19
+ ("image/gif", "gif"),
20
+ ("image/jpg", "jpeg"),
21
+ ("image/jpeg", "jpeg"),
22
+ ("image/png", "png"),
23
+ ("image/webp", "webp")
24
+ ],
25
+ ~default,
26
+ s
27
+ ) =
28
+ cover_file = file.cover.manager(mime_types=mime_types, default=default)
29
+ s.on_metadata(synchronous=true, cover_file.set)
30
+ video.add_request(
31
+ fallible=fallible,
32
+ x=x,
33
+ y=y,
34
+ width=width,
35
+ height=height,
36
+ request=cover_file,
37
+ s
38
+ )
39
+ end
40
+
41
+ let video.ffmpeg = ()
42
+
43
+ %ifdef input.ffmpeg
44
+ # ffmpeg's test source video (useful for testing and debugging).
45
+ # @category Source / Video processing
46
+ # @flag extra
47
+ def video.ffmpeg.testsrc(~id="video.testsrc") =
48
+ video =
49
+ "testsrc=size=#{video.frame.width()}x#{video.frame.height()}:rate=#{
50
+ video.frame.rate()
51
+ }"
52
+ audio = "sine=frequency=440:beep_factor=2:sample_rate=#{audio.samplerate()}"
53
+ input.ffmpeg(
54
+ id=id,
55
+ format="lavfi",
56
+ "#{video} [out0]; #{audio} [out1]"
57
+ )
58
+ end
59
+ %endif
@@ -0,0 +1,68 @@
1
+ # VU meter: display the audio volume (RMS in dB) on the standard output.
2
+ # @category Source / Visualization
3
+ # @flag extra
4
+ # @param ~rms_min Minimal volume (dB).
5
+ # @param ~rms_max Maximal volume (dB).
6
+ # @param ~scroll Update the display in the same line.
7
+ # @param ~window Duration in seconds of volume computation.
8
+ def vumeter(~rms_min=-25., ~rms_max=-5., ~window=0.5, ~scroll=false, s) =
9
+ screen_width = 80
10
+ bar_width = screen_width
11
+ let s = rms(duration=window, s)
12
+
13
+ def display() =
14
+ v = dB_of_lin(s.rms())
15
+ x = (v - rms_min) / (rms_max - rms_min)
16
+ x = if x < 0. then 0. else x end
17
+ x = if x > 1. then 1. else x end
18
+ n = int_of_float(x * float_of_int(bar_width))
19
+ bar = ref("")
20
+ if not scroll then bar := "\r" end
21
+ for _ = 0 to n - 1 do bar := bar() ^ "=" end
22
+ for _ = 0 to bar_width - n - 1 do bar := bar() ^ "." end
23
+ bar :=
24
+ bar() ^
25
+ " " ^
26
+ string(v)
27
+ if scroll then bar := bar() ^ "\n" end
28
+ print(newline=false, bar())
29
+ end
30
+
31
+ thread.run(fast=true, every=window, display)
32
+ s
33
+ end
34
+
35
+ # VU meter: display the audio volume (RMS in dB). This adds a video track to the
36
+ # source.
37
+ # @category Source / Visualization
38
+ # @flag extra
39
+ # @param ~rms_min Minimal volume (dB).
40
+ # @param ~rms_max Maximal volume (dB).
41
+ # @param ~window Duration in seconds of volume computation.
42
+ # @param ~color Color of the display (0xRRGGBB).
43
+ # @param ~persistence Persistence of the display (s).
44
+ def video.vumeter(
45
+ ~rms_min=-35.,
46
+ ~rms_max=0.,
47
+ ~window=0.1,
48
+ ~color=0xff0000,
49
+ ~persistence=0.,
50
+ s
51
+ ) =
52
+ s = source(s.{video = source.tracks(blank()).video})
53
+ s = rms(duration=window, s)
54
+ height = video.frame.height()
55
+ width = ref(0)
56
+
57
+ def update() =
58
+ v = dB_of_lin(s.rms())
59
+ x = (v - rms_min) / (rms_max - rms_min)
60
+ x = if x < 0. then 0. else x end
61
+ x = if x > 1. then 1. else x end
62
+ width := int_of_float(x * float_of_int(video.frame.width()))
63
+ end
64
+
65
+ thread.run(fast=true, every=window, update)
66
+ s = video.add_rectangle(width=width, height=height, color=color, s)
67
+ video.persistence(duration=persistence, s)
68
+ end