]> git.sesse.net Git - nageru/blob - nageru/theme.lua
Fix a dangling reference (found by GCC 14).
[nageru] / nageru / theme.lua
1 -- The theme is what decides what's actually shown on screen, what kind of
2 -- transitions are available (if any), and what kind of inputs there are,
3 -- if any. In general, it drives the entire display logic by creating Movit
4 -- chains (called “scenes”), setting their parameters and then deciding which
5 -- to show when.
6 --
7 -- Themes are written in Lua, which reflects a simplified form of the Movit API
8 -- where all the low-level details (such as texture formats) and alternatives
9 -- (e.g. turning scaling on or off) are handled by the C++ side and you
10 -- generally just build scenes.
11
12 local state = {
13         transition_start = -2.0,
14         transition_end = -1.0,
15         transition_type = 0,
16         transition_src_signal = 0,
17         transition_dst_signal = 0,
18
19         live_signal_num = 0,
20         preview_signal_num = 1
21 }
22
23 -- Valid values for live_signal_num and preview_signal_num.
24 local INPUT0_SIGNAL_NUM = 0
25 local INPUT1_SIGNAL_NUM = 1
26 local SBS_SIGNAL_NUM = 2
27 local STATIC_SIGNAL_NUM = 3
28
29 -- Valid values for transition_type. (Cuts are done directly, so they need no entry.)
30 local NO_TRANSITION = 0
31 local ZOOM_TRANSITION = 1  -- Also for slides.
32 local FADE_TRANSITION = 2
33
34 function make_sbs_input(scene)
35         return {
36                 input = scene:add_input(0),  -- Live inputs only.
37                 resample_effect = scene:add_effect({ResampleEffect.new(), ResizeEffect.new()}),
38                 wb_effect = scene:add_white_balance(),
39                 padding_effect = scene:add_effect(IntegralPaddingEffect.new())
40         }
41 end
42
43 -- The main live scene.
44 function make_sbs_scene()
45         local scene = Scene.new(16, 9)
46
47         local input0 = make_sbs_input(scene)
48         input0.input:display(0)
49         input0.padding_effect:set_vec4("border_color", 0.0, 0.0, 0.0, 1.0)
50
51         local input1 = make_sbs_input(scene)
52         input1.input:display(1)
53         input1.padding_effect:set_vec4("border_color", 0.0, 0.0, 0.0, 0.0)
54
55         scene:add_effect(OverlayEffect.new(), input0.padding_effect, input1.padding_effect)
56         scene:finalize()
57
58         return {
59                 scene = scene,
60                 input0 = input0,
61                 input1 = input1
62         }
63 end
64 local sbs_scene = make_sbs_scene()
65
66 function make_fade_input(scene)
67         return {
68                 input = scene:add_input(),
69                 resample_effect = scene:add_optional_effect(ResampleEffect.new()),  -- Activated if scaling.
70                 wb_effect = scene:add_white_balance()  -- Activated for video inputs.
71         }
72 end
73
74 -- A scene to fade between two inputs, of which either can be a picture
75 -- or a live input. Only used live.
76 function make_fade_scene()
77         local scene = Scene.new(16, 9)
78         local input0 = make_fade_input(scene)
79         local input1 = make_fade_input(scene)
80         local mix_effect = scene:add_effect(MixEffect.new(), input0.wb_effect, input1.wb_effect)
81         scene:finalize(true)  -- Only used live.
82
83         return {
84                 scene = scene,
85                 input0 = input0,
86                 input1 = input1,
87                 mix_effect = mix_effect
88         }
89 end
90 local fade_scene = make_fade_scene()
91
92 -- A scene to show a single input on screen.
93 local scene = Scene.new(16, 9)
94 local simple_scene = {
95         scene = scene,
96         input = scene:add_input(),
97         resample_effect = scene:add_effect({ResampleEffect.new(), ResizeEffect.new(), IdentityEffect.new()}),
98         wb_effect = scene:add_white_balance()
99 }
100 scene:finalize()
101
102 -- A scene to show a single static picture on screen.
103 local static_image = ImageInput.new("bg.jpeg")  -- Also used as input to other scenes.
104 local static_scene = Scene.new(16, 9)
105 static_scene:add_input(static_image)  -- Note: Locks this input to images only.
106 static_scene:finalize()
107
108 -- Set some global state. Unless marked otherwise, these can only be set once,
109 -- at the start of the program.
110 Nageru.set_num_channels(4)
111
112 -- Sets, for each channel, which signal it corresponds to (starting from 0).
113 -- The information is used for whether right-click on the channel should bring up
114 -- an input selector or not. Only call this for channels that actually correspond
115 -- directly to a signal (ie., live inputs, not live (0) or preview (1)).
116 Nageru.set_channel_signal(2, 0)
117 Nageru.set_channel_signal(3, 1)
118
119 -- Set whether a given channel supports setting white balance. (Default is false.)
120 Nageru.set_supports_wb(2, true)
121 Nageru.set_supports_wb(3, true)
122
123 -- These can be set at any time.
124 Nageru.set_channel_name(SBS_SIGNAL_NUM + 2, "Side-by-side")
125 Nageru.set_channel_name(STATIC_SIGNAL_NUM + 2, "Static picture")
126
127 -- API ENTRY POINT
128 -- Called every frame. Returns the color (if any) to paint around the given
129 -- channel. Returns a CSS color (typically to mark live and preview signals);
130 -- "transparent" is allowed.
131 -- Will never be called for live (0) or preview (1).
132 function channel_color(channel)
133         if state.transition_type ~= NO_TRANSITION then
134                 if channel_involved_in(channel, state.transition_src_signal) or
135                    channel_involved_in(channel, state.transition_dst_signal) then
136                         return "#f00"
137                 end
138         else
139                 if channel_involved_in(channel, state.live_signal_num) then
140                         return "#f00"
141                 end
142         end
143         if channel_involved_in(channel, state.preview_signal_num) then
144                 return "#0f0"
145         end
146         return "transparent"
147 end
148
149 function is_plain_signal(num)
150         return num == INPUT0_SIGNAL_NUM or num == INPUT1_SIGNAL_NUM
151 end
152
153 function channel_involved_in(channel, signal_num)
154         if is_plain_signal(signal_num) then
155                 return channel == (signal_num + 2)
156         end
157         if signal_num == SBS_SIGNAL_NUM then
158                 return (channel == 2 or channel == 3)
159         end
160         if signal_num == STATIC_SIGNAL_NUM then
161                 return (channel == 5)
162         end
163         return false
164 end
165
166 function finish_transitions(t)
167         if state.transition_type ~= NO_TRANSITION and t >= state.transition_end then
168                 state.live_signal_num = state.transition_dst_signal
169                 state.transition_type = NO_TRANSITION
170         end
171 end
172
173 function in_transition(t)
174        return t >= state.transition_start and t <= state.transition_end
175 end
176
177 -- API ENTRY POINT
178 -- Called every frame.
179 function get_transitions(t)
180         if in_transition(t) then
181                 -- Transition already in progress, the only thing we can do is really
182                 -- cut to the preview. (TODO: Make an “abort” and/or “finish”, too?)
183                 return {"Cut"}
184         end
185
186         finish_transitions(t)
187
188         if state.live_signal_num == state.preview_signal_num then
189                 -- No transitions possible.
190                 return {}
191         end
192
193         if (is_plain_signal(state.live_signal_num) or state.live_signal_num == STATIC_SIGNAL_NUM) and
194            (is_plain_signal(state.preview_signal_num) or state.preview_signal_num == STATIC_SIGNAL_NUM) then
195                 return {"Cut", "", "Fade"}
196         end
197
198         -- Various zooms.
199         if state.live_signal_num == SBS_SIGNAL_NUM and is_plain_signal(state.preview_signal_num) then
200                 return {"Cut", "Zoom in"}
201         elseif is_plain_signal(state.live_signal_num) and state.preview_signal_num == SBS_SIGNAL_NUM then
202                 return {"Cut", "Zoom out"}
203         end
204
205         return {"Cut"}
206 end
207
208 function swap_preview_live()
209         local temp = state.live_signal_num
210         state.live_signal_num = state.preview_signal_num
211         state.preview_signal_num = temp
212 end
213
214 function start_transition(type_, t, duration)
215         state.transition_start = t
216         state.transition_end = t + duration
217         state.transition_type = type_
218         state.transition_src_signal = state.live_signal_num
219         state.transition_dst_signal = state.preview_signal_num
220         swap_preview_live()
221 end
222
223 -- API ENTRY POINT
224 -- Called when the user clicks a transition button.
225 function transition_clicked(num, t)
226         if num == 0 then
227                 -- Cut.
228                 if in_transition(t) then
229                         -- Ongoing transition; finish it immediately before the cut.
230                         finish_transitions(state.transition_end)
231                 end
232
233                 swap_preview_live()
234         elseif num == 1 then
235                 -- Zoom.
236                 finish_transitions(t)
237
238                 if state.live_signal_num == state.preview_signal_num then
239                         -- Nothing to do.
240                         return
241                 end
242
243                 if is_plain_signal(state.live_signal_num) and is_plain_signal(state.preview_signal_num) then
244                         -- We can't zoom between these. Just make a cut.
245                         io.write("Cutting from " .. state.live_signal_num .. " to " .. state.live_signal_num .. "\n")
246                         swap_preview_live()
247                         return
248                 end
249
250                 if (state.live_signal_num == SBS_SIGNAL_NUM and is_plain_signal(state.preview_signal_num)) or
251                    (state.preview_signal_num == SBS_SIGNAL_NUM and is_plain_signal(state.live_signal_num)) then
252                         start_transition(ZOOM_TRANSITION, t, 1.0)
253                 end
254         elseif num == 2 then
255                 finish_transitions(t)
256
257                 -- Fade.
258                 if (state.live_signal_num ~= state.preview_signal_num) and
259                    (is_plain_signal(state.live_signal_num) or
260                     state.live_signal_num == STATIC_SIGNAL_NUM) and
261                    (is_plain_signal(state.preview_signal_num) or
262                     state.preview_signal_num == STATIC_SIGNAL_NUM) then
263                         start_transition(FADE_TRANSITION, t, 1.0)
264                 else
265                         -- Fades involving SBS are ignored (we have no scene for it).
266                 end
267         end
268 end
269
270 -- API ENTRY POINT
271 function channel_clicked(num)
272         state.preview_signal_num = num
273 end
274
275 function setup_fade_input(state, input, signals, signal_num, width, height)
276         if signal_num == STATIC_SIGNAL_NUM then
277                 input.input:display(static_image)
278                 input.wb_effect:disable()
279
280                 -- We assume this is already correctly scaled at load time.
281                 input.resample_effect:disable()
282         else
283                 input.input:display(signal_num)
284                 input.wb_effect:enable()
285
286                 if (signals:get_width(signal_num) ~= width or signals:get_height(signal_num) ~= height) then
287                         input.resample_effect:enable()
288                         input.resample_effect:set_int("width", width)
289                         input.resample_effect:set_int("height", height)
290                 else
291                         input.resample_effect:disable()
292                 end
293         end
294 end
295
296 function needs_scale(signals, signal_num, width, height)
297         if signal_num == STATIC_SIGNAL_NUM then
298                 -- We assume this is already correctly scaled at load time.
299                 return false
300         end
301         assert(is_plain_signal(signal_num))
302         return (signals:get_width(signal_num) ~= width or signals:get_height(signal_num) ~= height)
303 end
304
305 function setup_simple_input(state, signals, signal_num, width, height, hq)
306         simple_scene.input:display(signal_num)
307         if needs_scale(signals, signal_num, width, height) then
308                 if hq then
309                         simple_scene.resample_effect:choose(ResampleEffect)  -- High-quality resampling.
310                 else
311                         simple_scene.resample_effect:choose(ResizeEffect)  -- Low-quality resampling.
312                 end
313                 simple_scene.resample_effect:set_int("width", width)
314                 simple_scene.resample_effect:set_int("height", height)
315         else
316                 simple_scene.resample_effect:disable()  -- No scaling.
317         end
318 end
319
320 -- API ENTRY POINT
321 -- Called every frame. Get the scene for displaying at input <num>,
322 -- where 0 is live, 1 is preview, 2 is the first channel to display
323 -- in the bottom bar, and so on up to num_channels()+1. t is the
324 -- current time in seconds. width and height are the dimensions of
325 -- the output, although you can ignore them if you don't need them
326 -- (they're useful if you want to e.g. know what to resample by).
327 --
328 -- <signals> is an object which you can query for information
329 -- about the signals at the point of the current frame. In particular,
330 -- you can call get_frame_width() and get_frame_height() for any
331 -- signal number, and use that to e.g. see if you wish to enable
332 -- a scaler or not. (You can also use get_width() and get_height(),
333 -- which return the _field_ size. This has half the height for
334 -- interlaced signals.)
335 --
336 -- You should return scene to use, after having set any parameters you
337 -- want to set (through set_int() etc.). The parameters will be snapshot
338 -- at return time and used during rendering.
339 function get_scene(num, t, width, height, signals)
340         local input_resolution = {}
341         for signal_num=0,1 do
342                 local res = {
343                         width = signals:get_frame_width(signal_num),
344                         height = signals:get_frame_height(signal_num),
345                 }
346                 input_resolution[signal_num] = res
347
348                 local text_res = signals:get_human_readable_resolution(signal_num)
349                 Nageru.set_channel_name(signal_num + 2, "Input " .. (signal_num + 1) .. " (" .. text_res .. ")")
350         end
351
352         if num == 0 then  -- Live.
353                 finish_transitions(t)
354                 if state.transition_type == ZOOM_TRANSITION then
355                         -- Transition in or out of SBS.
356                         prepare_sbs_scene(state, calc_zoom_progress(state, t), state.transition_type, state.transition_src_signal, state.transition_dst_signal, width, height, input_resolution, true)
357                         return sbs_scene.scene
358                 elseif state.transition_type == NO_TRANSITION and state.live_signal_num == SBS_SIGNAL_NUM then
359                         -- Static SBS view.
360                         prepare_sbs_scene(state, 0.0, NO_TRANSITION, 0, SBS_SIGNAL_NUM, width, height, input_resolution, true)
361                         return sbs_scene.scene
362                 elseif state.transition_type == FADE_TRANSITION then
363                         setup_fade_input(state, fade_scene.input0, signals, state.transition_src_signal, width, height)
364                         setup_fade_input(state, fade_scene.input1, signals, state.transition_dst_signal, width, height)
365
366                         local tt = calc_fade_progress(t, state.transition_start, state.transition_end)
367                         fade_scene.mix_effect:set_float("strength_first", 1.0 - tt)
368                         fade_scene.mix_effect:set_float("strength_second", tt)
369
370                         return fade_scene.scene
371                 elseif is_plain_signal(state.live_signal_num) then
372                         setup_simple_input(state, signals, state.live_signal_num, width, height, true)
373                         return simple_scene.scene
374                 elseif state.live_signal_num == STATIC_SIGNAL_NUM then  -- Static picture.
375                         return static_scene
376                 else
377                         assert(false)
378                 end
379         end
380         if num == 1 then  -- Preview.
381                 num = state.preview_signal_num + 2
382         end
383
384         -- Individual preview inputs.
385         if is_plain_signal(num - 2) then
386                 setup_simple_input(state, signals, num - 2, width, height, false)
387                 return simple_scene.scene
388         end
389         if num == SBS_SIGNAL_NUM + 2 then
390                 prepare_sbs_scene(state, 0.0, NO_TRANSITION, 0, SBS_SIGNAL_NUM, width, height, input_resolution, false)
391                 return sbs_scene.scene
392         end
393         if num == STATIC_SIGNAL_NUM + 2 then
394                 return static_scene
395         end
396 end
397
398 function place_rectangle(input, x0, y0, x1, y1, screen_width, screen_height, input_width, input_height, hq)
399         input.padding_effect:set_int("width", screen_width)
400         input.padding_effect:set_int("height", screen_height)
401
402         -- Cull.
403         if x0 > screen_width or x1 < 0.0 or y0 > screen_height or y1 < 0.0 then
404                 input.resample_effect:choose(ResizeEffect)  -- Low-quality resizing.
405                 input.resample_effect:set_int("width", 1)
406                 input.resample_effect:set_int("height", 1)
407                 input.padding_effect:set_int("left", screen_width + 100)
408                 input.padding_effect:set_int("top", screen_height + 100)
409                 return
410         end
411
412         local srcx0 = 0.0
413         local srcx1 = 1.0
414         local srcy0 = 0.0
415         local srcy1 = 1.0
416
417         -- Clip.
418         if x0 < 0 then
419                 srcx0 = -x0 / (x1 - x0)
420                 x0 = 0
421         end
422         if y0 < 0 then
423                 srcy0 = -y0 / (y1 - y0)
424                 y0 = 0
425         end
426         if x1 > screen_width then
427                 srcx1 = (screen_width - x0) / (x1 - x0)
428                 x1 = screen_width
429         end
430         if y1 > screen_height then
431                 srcy1 = (screen_height - y0) / (y1 - y0)
432                 y1 = screen_height
433         end
434
435         if hq then
436                 -- High-quality resampling. Go for the actual effect (returned by choose())
437                 -- since we want to set zoom_*, which will give an error if set on ResizeEffect.
438                 local resample_effect = input.resample_effect:choose(ResampleEffect)
439
440                 local x_subpixel_offset = x0 - math.floor(x0)
441                 local y_subpixel_offset = y0 - math.floor(y0)
442
443                 -- Resampling must be to an integral number of pixels. Round up,
444                 -- and then add an extra pixel so we have some leeway for the border.
445                 local width = math.ceil(x1 - x0) + 1
446                 local height = math.ceil(y1 - y0) + 1
447                 resample_effect:set_int("width", width)
448                 resample_effect:set_int("height", height)
449
450                 -- Correct the discrepancy with zoom. (This will leave a small
451                 -- excess edge of pixels and subpixels, which we'll correct for soon.)
452                 local zoom_x = (x1 - x0) / (width * (srcx1 - srcx0))
453                 local zoom_y = (y1 - y0) / (height * (srcy1 - srcy0))
454                 resample_effect:set_float("zoom_x", zoom_x)
455                 resample_effect:set_float("zoom_y", zoom_y)
456                 resample_effect:set_float("zoom_center_x", 0.0)
457                 resample_effect:set_float("zoom_center_y", 0.0)
458
459                 -- Padding must also be to a whole-pixel offset.
460                 input.padding_effect:set_int("left", math.floor(x0))
461                 input.padding_effect:set_int("top", math.floor(y0))
462
463                 -- Correct _that_ discrepancy by subpixel offset in the resampling.
464                 resample_effect:set_float("left", srcx0 * input_width - x_subpixel_offset / zoom_x)
465                 resample_effect:set_float("top", srcy0 * input_height - y_subpixel_offset / zoom_y)
466
467                 -- Finally, adjust the border so it is exactly where we want it.
468                 input.padding_effect:set_float("border_offset_left", x_subpixel_offset)
469                 input.padding_effect:set_float("border_offset_right", x1 - (math.floor(x0) + width))
470                 input.padding_effect:set_float("border_offset_top", y_subpixel_offset)
471                 input.padding_effect:set_float("border_offset_bottom", y1 - (math.floor(y0) + height))
472         else
473                 -- Lower-quality simple resizing.
474                 input.resample_effect:choose(ResizeEffect)
475
476                 local width = round(x1 - x0)
477                 local height = round(y1 - y0)
478                 input.resample_effect:set_int("width", width)
479                 input.resample_effect:set_int("height", height)
480
481                 -- Padding must also be to a whole-pixel offset.
482                 input.padding_effect:set_int("left", math.floor(x0))
483                 input.padding_effect:set_int("top", math.floor(y0))
484
485                 -- No subpixel stuff.
486                 input.padding_effect:set_float("border_offset_left", 0.0)
487                 input.padding_effect:set_float("border_offset_right", 0.0)
488                 input.padding_effect:set_float("border_offset_top", 0.0)
489                 input.padding_effect:set_float("border_offset_bottom", 0.0)
490         end
491 end
492
493 -- This is broken, of course (even for positive numbers), but Lua doesn't give us access to real rounding.
494 function round(x)
495         return math.floor(x + 0.5)
496 end
497
498 function lerp(a, b, t)
499         return a + (b - a) * t
500 end
501
502 function lerp_pos(a, b, t)
503         return {
504                 x0 = lerp(a.x0, b.x0, t),
505                 y0 = lerp(a.y0, b.y0, t),
506                 x1 = lerp(a.x1, b.x1, t),
507                 y1 = lerp(a.y1, b.y1, t)
508         }
509 end
510
511 function pos_from_top_left(x, y, width, height, screen_width, screen_height)
512         local xs = screen_width / 1280.0
513         local ys = screen_height / 720.0
514         return {
515                 x0 = round(xs * x),
516                 y0 = round(ys * y),
517                 x1 = round(xs * (x + width)),
518                 y1 = round(ys * (y + height))
519         }
520 end
521
522 function prepare_sbs_scene(state, t, transition_type, src_signal, dst_signal, screen_width, screen_height, input_resolution, hq)
523         -- First input is positioned (16,48) from top-left.
524         -- Second input is positioned (16,48) from the bottom-right.
525         local pos0 = pos_from_top_left(16, 48, 848, 477, screen_width, screen_height)
526         local pos1 = pos_from_top_left(1280 - 384 - 16, 720 - 216 - 48, 384, 216, screen_width, screen_height)
527
528         local pos_fs = { x0 = 0, y0 = 0, x1 = screen_width, y1 = screen_height }
529         local affine_param
530         if transition_type == NO_TRANSITION then
531                 -- Static SBS view.
532                 affine_param = { sx = 1.0, sy = 1.0, tx = 0.0, ty = 0.0 }   -- Identity.
533         else
534                 -- Zooming to/from SBS view into or out of a single view.
535                 assert(transition_type == ZOOM_TRANSITION)
536                 local signal, real_t
537                 if src_signal == SBS_SIGNAL_NUM then
538                         signal = dst_signal
539                         real_t = t
540                 else
541                         assert(dst_signal == SBS_SIGNAL_NUM)
542                         signal = src_signal
543                         real_t = 1.0 - t
544                 end
545
546                 if signal == INPUT0_SIGNAL_NUM then
547                         affine_param = find_affine_param(pos0, lerp_pos(pos0, pos_fs, real_t))
548                 elseif signal == INPUT1_SIGNAL_NUM then
549                         affine_param = find_affine_param(pos1, lerp_pos(pos1, pos_fs, real_t))
550                 end
551         end
552
553         -- NOTE: input_resolution is not 1-indexed, unlike usual Lua arrays.
554         place_rectangle_with_affine(sbs_scene.input0, pos0, affine_param, screen_width, screen_height, input_resolution[0].width, input_resolution[0].height, hq)
555         place_rectangle_with_affine(sbs_scene.input1, pos1, affine_param, screen_width, screen_height, input_resolution[1].width, input_resolution[1].height, hq)
556 end
557
558 -- Find the transformation that changes the first rectangle to the second one.
559 function find_affine_param(a, b)
560         local sx = (b.x1 - b.x0) / (a.x1 - a.x0)
561         local sy = (b.y1 - b.y0) / (a.y1 - a.y0)
562         return {
563                 sx = sx,
564                 sy = sy,
565                 tx = b.x0 - a.x0 * sx,
566                 ty = b.y0 - a.y0 * sy
567         }
568 end
569
570 function place_rectangle_with_affine(input, pos, aff, screen_width, screen_height, input_width, input_height, hq)
571         local x0 = pos.x0 * aff.sx + aff.tx
572         local x1 = pos.x1 * aff.sx + aff.tx
573         local y0 = pos.y0 * aff.sy + aff.ty
574         local y1 = pos.y1 * aff.sy + aff.ty
575
576         place_rectangle(input, x0, y0, x1, y1, screen_width, screen_height, input_width, input_height, hq)
577 end
578
579 function calc_zoom_progress(state, t)
580         if t < state.transition_start then
581                 return 0.0
582         elseif t > state.transition_end then
583                 return 1.0
584         else
585                 local tt = (t - state.transition_start) / (state.transition_end - state.transition_start)
586                 -- Smooth it a bit.
587                 return math.sin(tt * 3.14159265358 * 0.5)
588         end
589 end
590
591 function calc_fade_progress(t, transition_start, transition_end)
592         local tt = (t - transition_start) / (transition_end - transition_start)
593         if tt < 0.0 then
594                 return 0.0
595         elseif tt > 1.0 then
596                 return 1.0
597         end
598
599         -- Make the fade look maybe a tad more natural, by pumping it
600         -- through a sigmoid function.
601         tt = 10.0 * tt - 5.0
602         tt = 1.0 / (1.0 + math.exp(-tt))
603
604         return tt
605 end