]> git.sesse.net Git - nageru/blob - nageru/theme.lua
Allow giving class names to Block.choose_alternative.
[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         neutral_colors = {
20                 {0.5, 0.5, 0.5},  -- Input 0.
21                 {0.5, 0.5, 0.5}   -- Input 1.
22         },
23
24         live_signal_num = 0,
25         preview_signal_num = 1
26 }
27
28 -- Valid values for live_signal_num and preview_signal_num.
29 local INPUT0_SIGNAL_NUM = 0
30 local INPUT1_SIGNAL_NUM = 1
31 local SBS_SIGNAL_NUM = 2
32 local STATIC_SIGNAL_NUM = 3
33
34 -- Valid values for transition_type. (Cuts are done directly, so they need no entry.)
35 local NO_TRANSITION = 0
36 local ZOOM_TRANSITION = 1  -- Also for slides.
37 local FADE_TRANSITION = 2
38
39 -- Last width/height/frame rate for each channel, if we have it.
40 -- Note that unlike the values we get from Nageru, the resolution is per
41 -- frame and not per field, since we deinterlace.
42 local last_resolution = {}
43
44 function make_sbs_input(scene)
45         local resample_effect = ResampleEffect.new()
46         local resize_effect = ResizeEffect.new()
47         return {
48                 input = scene:add_input(),
49                 resample_effect = resample_effect,
50                 resize_effect = resize_effect,
51                 resample_switcher = scene:add_effect({resample_effect, resize_effect}),
52                 wb_effect = scene:add_effect(WhiteBalanceEffect.new()),
53                 padding_effect = scene:add_effect(IntegralPaddingEffect.new())
54         }
55 end
56
57 -- The main live scene.
58 function make_sbs_scene()
59         local scene = Scene.new(16, 9)
60
61         local input0 = make_sbs_input(scene)
62         input0.input:display(0)
63         input0.padding_effect:set_vec4("border_color", 0.0, 0.0, 0.0, 1.0)
64
65         local input1 = make_sbs_input(scene)
66         input1.input:display(1)
67         input1.padding_effect:set_vec4("border_color", 0.0, 0.0, 0.0, 0.0)
68
69         scene:add_effect(OverlayEffect.new(), input0.padding_effect, input1.padding_effect)
70         scene:finalize()
71
72         return {
73                 scene = scene,
74                 input0 = input0,
75                 input1 = input1
76         }
77 end
78 local sbs_scene = make_sbs_scene()
79
80 function make_fade_input(scene)
81         return {
82                 input = scene:add_input(),
83                 resample_effect = scene:add_optional_effect(ResampleEffect.new()),  -- Activated if scaling.
84                 wb_effect = scene:add_optional_effect(WhiteBalanceEffect.new())  -- Activated for video inputs.
85         }
86 end
87
88 -- A scene to fade between two inputs, of which either can be a picture
89 -- or a live input. Only used live.
90 function make_fade_scene()
91         local scene = Scene.new(16, 9)
92         local input0 = make_fade_input(scene)
93         local input1 = make_fade_input(scene)
94         local mix_effect = scene:add_effect(MixEffect.new(), input0.wb_effect, input1.wb_effect)
95         scene:finalize(true)  -- Only used live.
96
97         return {
98                 scene = scene,
99                 input0 = input0,
100                 input1 = input1,
101                 mix_effect = mix_effect
102         }
103 end
104 local fade_scene = make_fade_scene()
105
106 -- A scene to show a single input on screen.
107 local scene = Scene.new(16, 9)
108 local simple_scene = {
109         scene = scene,
110         input = scene:add_input(),
111         resample_effect = scene:add_effect({ResampleEffect.new(), ResizeEffect.new(), IdentityEffect.new()}),
112         wb_effect = scene:add_effect(WhiteBalanceEffect.new())
113 }
114 scene:finalize()
115
116 -- A scene to show a single static picture on screen.
117 local static_image = ImageInput.new("bg.jpeg")  -- Also used as input to other scenes.
118 local static_scene = Scene.new(16, 9)
119 static_scene:add_input(static_image)  -- Note: Locks this input to images only.
120 static_scene:finalize()
121
122 -- API ENTRY POINT
123 -- Returns the number of outputs in addition to the live (0) and preview (1).
124 -- Called only once, at the start of the program.
125 function num_channels()
126         return 4
127 end
128
129 function is_plain_signal(num)
130         return num == INPUT0_SIGNAL_NUM or num == INPUT1_SIGNAL_NUM
131 end
132
133 -- Helper function to write e.g. “720p60”. The difference between this
134 -- and get_channel_resolution_raw() is that this one also can say that
135 -- there's no signal.
136 function get_channel_resolution(signal_num)
137         local res = last_resolution[signal_num]
138         if (not res) or not res.is_connected then
139                 return "disconnected"
140         end
141         if res.height <= 0 then
142                 return "no signal"
143         end
144         if not res.has_signal then
145                 if res.height == 525 then
146                         -- Special mode for the USB3 cards.
147                         return "no signal"
148                 end
149                 return get_channel_resolution_raw(res) .. ", no signal"
150         else
151                 return get_channel_resolution_raw(res)
152         end
153 end
154
155 -- Helper function to write e.g. “60” or “59.94”.
156 function get_frame_rate(res)
157         local nom = res.frame_rate_nom
158         local den = res.frame_rate_den
159         if nom % den == 0 then
160                 return nom / den
161         else
162                 return string.format("%.2f", nom / den)
163         end
164 end
165
166 -- Helper function to write e.g. “720p60”.
167 function get_channel_resolution_raw(res)
168         if res.interlaced then
169                 return res.height .. "i" .. get_frame_rate(res)
170         else
171                 return res.height .. "p" .. get_frame_rate(res)
172         end
173 end
174
175 -- API ENTRY POINT
176 -- Returns the name for each additional channel (starting from 2).
177 -- Called at the start of the program, and then each frame for live
178 -- channels in case they change resolution.
179 function channel_name(channel)
180         local signal_num = channel - 2
181         if is_plain_signal(signal_num) then
182                 return "Input " .. (signal_num + 1) .. " (" .. get_channel_resolution(signal_num) .. ")"
183         elseif signal_num == SBS_SIGNAL_NUM then
184                 return "Side-by-side"
185         elseif signal_num == STATIC_SIGNAL_NUM then
186                 return "Static picture"
187         end
188 end
189
190 -- API ENTRY POINT
191 -- Returns, given a channel number, which signal it corresponds to (starting from 0).
192 -- Should return -1 if the channel does not correspond to a simple signal
193 -- (one connected to a capture card, or a video input). The information is used for
194 -- whether right-click on the channel should bring up a context menu or not,
195 -- typically containing an input selector, resolution menu etc.
196 --
197 -- Called once for each channel, at the start of the program.
198 -- Will never be called for live (0) or preview (1).
199 function channel_signal(channel)
200         if channel == 2 then
201                 return 0
202         elseif channel == 3 then
203                 return 1
204         else
205                 return -1
206         end
207 end
208
209 -- API ENTRY POINT
210 -- Called every frame. Returns the color (if any) to paint around the given
211 -- channel. Returns a CSS color (typically to mark live and preview signals);
212 -- "transparent" is allowed.
213 -- Will never be called for live (0) or preview (1).
214 function channel_color(channel)
215         if state.transition_type ~= NO_TRANSITION then
216                 if channel_involved_in(channel, state.transition_src_signal) or
217                    channel_involved_in(channel, state.transition_dst_signal) then
218                         return "#f00"
219                 end
220         else
221                 if channel_involved_in(channel, state.live_signal_num) then
222                         return "#f00"
223                 end
224         end
225         if channel_involved_in(channel, state.preview_signal_num) then
226                 return "#0f0"
227         end
228         return "transparent"
229 end
230
231 function channel_involved_in(channel, signal_num)
232         if is_plain_signal(signal_num) then
233                 return channel == (signal_num + 2)
234         end
235         if signal_num == SBS_SIGNAL_NUM then
236                 return (channel == 2 or channel == 3)
237         end
238         if signal_num == STATIC_SIGNAL_NUM then
239                 return (channel == 5)
240         end
241         return false
242 end
243
244 -- API ENTRY POINT
245 -- Returns if a given channel supports setting white balance (starting from 2).
246 -- Called only once for each channel, at the start of the program.
247 function supports_set_wb(channel)
248         return is_plain_signal(channel - 2)
249 end
250
251 -- API ENTRY POINT
252 -- Gets called with a new gray point when the white balance is changing.
253 -- The color is in linear light (not sRGB gamma).
254 function set_wb(channel, red, green, blue)
255         if is_plain_signal(channel - 2) then
256                 state.neutral_colors[channel - 2 + 1] = { red, green, blue }
257         end
258 end
259
260 function finish_transitions(t)
261         if state.transition_type ~= NO_TRANSITION and t >= state.transition_end then
262                 state.live_signal_num = state.transition_dst_signal
263                 state.transition_type = NO_TRANSITION
264         end
265 end
266
267 function in_transition(t)
268        return t >= state.transition_start and t <= state.transition_end
269 end
270
271 -- API ENTRY POINT
272 -- Called every frame.
273 function get_transitions(t)
274         if in_transition(t) then
275                 -- Transition already in progress, the only thing we can do is really
276                 -- cut to the preview. (TODO: Make an “abort” and/or “finish”, too?)
277                 return {"Cut"}
278         end
279
280         finish_transitions(t)
281
282         if state.live_signal_num == state.preview_signal_num then
283                 -- No transitions possible.
284                 return {}
285         end
286
287         if (is_plain_signal(state.live_signal_num) or state.live_signal_num == STATIC_SIGNAL_NUM) and
288            (is_plain_signal(state.preview_signal_num) or state.preview_signal_num == STATIC_SIGNAL_NUM) then
289                 return {"Cut", "", "Fade"}
290         end
291
292         -- Various zooms.
293         if state.live_signal_num == SBS_SIGNAL_NUM and is_plain_signal(state.preview_signal_num) then
294                 return {"Cut", "Zoom in"}
295         elseif is_plain_signal(state.live_signal_num) and state.preview_signal_num == SBS_SIGNAL_NUM then
296                 return {"Cut", "Zoom out"}
297         end
298
299         return {"Cut"}
300 end
301
302 function swap_preview_live()
303         local temp = state.live_signal_num
304         state.live_signal_num = state.preview_signal_num
305         state.preview_signal_num = temp
306 end
307
308 function start_transition(type_, t, duration)
309         state.transition_start = t
310         state.transition_end = t + duration
311         state.transition_type = type_
312         state.transition_src_signal = state.live_signal_num
313         state.transition_dst_signal = state.preview_signal_num
314         swap_preview_live()
315 end
316
317 -- API ENTRY POINT
318 -- Called when the user clicks a transition button.
319 function transition_clicked(num, t)
320         if num == 0 then
321                 -- Cut.
322                 if in_transition(t) then
323                         -- Ongoing transition; finish it immediately before the cut.
324                         finish_transitions(state.transition_end)
325                 end
326
327                 swap_preview_live()
328         elseif num == 1 then
329                 -- Zoom.
330                 finish_transitions(t)
331
332                 if state.live_signal_num == state.preview_signal_num then
333                         -- Nothing to do.
334                         return
335                 end
336
337                 if is_plain_signal(state.live_signal_num) and is_plain_signal(state.preview_signal_num) then
338                         -- We can't zoom between these. Just make a cut.
339                         io.write("Cutting from " .. state.live_signal_num .. " to " .. state.live_signal_num .. "\n")
340                         swap_preview_live()
341                         return
342                 end
343
344                 if (state.live_signal_num == SBS_SIGNAL_NUM and is_plain_signal(state.preview_signal_num)) or
345                    (state.preview_signal_num == SBS_SIGNAL_NUM and is_plain_signal(state.live_signal_num)) then
346                         start_transition(ZOOM_TRANSITION, t, 1.0)
347                 end
348         elseif num == 2 then
349                 finish_transitions(t)
350
351                 -- Fade.
352                 if (state.live_signal_num ~= state.preview_signal_num) and
353                    (is_plain_signal(state.live_signal_num) or
354                     state.live_signal_num == STATIC_SIGNAL_NUM) and
355                    (is_plain_signal(state.preview_signal_num) or
356                     state.preview_signal_num == STATIC_SIGNAL_NUM) then
357                         start_transition(FADE_TRANSITION, t, 1.0)
358                 else
359                         -- Fades involving SBS are ignored (we have no scene for it).
360                 end
361         end
362 end
363
364 -- API ENTRY POINT
365 function channel_clicked(num)
366         state.preview_signal_num = num
367 end
368
369 function setup_fade_input(state, input, signals, signal_num, width, height)
370         if signal_num == STATIC_SIGNAL_NUM then
371                 input.input:display(static_image)
372                 input.wb_effect:disable()
373
374                 -- We assume this is already correctly scaled at load time.
375                 input.resample_effect:disable()
376         else
377                 input.input:display(signal_num)
378                 input.wb_effect:enable()
379                 set_neutral_color(input.wb_effect, state.neutral_colors[signal_num - INPUT0_SIGNAL_NUM + 1])
380
381                 if (signals:get_width(signal_num) ~= width or signals:get_height(signal_num) ~= height) then
382                         input.resample_effect:enable()
383                         input.resample_effect:set_int("width", width)
384                         input.resample_effect:set_int("height", height)
385                 else
386                         input.resample_effect:disable()
387                 end
388         end
389 end
390
391 function needs_scale(signals, signal_num, width, height)
392         if signal_num == STATIC_SIGNAL_NUM then
393                 -- We assume this is already correctly scaled at load time.
394                 return false
395         end
396         assert(is_plain_signal(signal_num))
397         return (signals:get_width(signal_num) ~= width or signals:get_height(signal_num) ~= height)
398 end
399
400 function setup_simple_input(state, signals, signal_num, width, height, hq)
401         simple_scene.input:display(signal_num)
402         if needs_scale(signals, signal_num, width, height) then
403                 if hq then
404                         simple_scene.resample_effect:choose_alternative(ResampleEffect)  -- High-quality resampling.
405                 else
406                         simple_scene.resample_effect:choose_alternative(ResizeEffect)  -- Low-quality resampling.
407                 end
408                 simple_scene.resample_effect:set_int("width", width)
409                 simple_scene.resample_effect:set_int("height", height)
410         else
411                 simple_scene.resample_effect:choose_alternative(2)  -- No scaling.
412         end
413         set_neutral_color_from_signal(state, simple_scene.wb_effect, signal_num)
414 end
415
416 -- API ENTRY POINT
417 -- Called every frame. Get the scene for displaying at input <num>,
418 -- where 0 is live, 1 is preview, 2 is the first channel to display
419 -- in the bottom bar, and so on up to num_channels()+1. t is the
420 -- current time in seconds. width and height are the dimensions of
421 -- the output, although you can ignore them if you don't need them
422 -- (they're useful if you want to e.g. know what to resample by).
423 --
424 -- <signals> is basically an exposed InputState, which you can use to
425 -- query for information about the signals at the point of the current
426 -- frame. In particular, you can call get_width() and get_height()
427 -- for any signal number, and use that to e.g. assist in scene selection.
428 --
429 -- You should return scene to use, after having set any parameters you
430 -- want to set (through set_int() etc.). The parameters will be snapshot
431 -- at return time and used during rendering.
432 function get_scene(num, t, width, height, signals)
433         local input_resolution = {}
434         for signal_num=0,1 do
435                 local res = {
436                         width = signals:get_width(signal_num),
437                         height = signals:get_height(signal_num),
438                         interlaced = signals:get_interlaced(signal_num),
439                         is_connected = signals:get_is_connected(signal_num),
440                         has_signal = signals:get_has_signal(signal_num),
441                         frame_rate_nom = signals:get_frame_rate_nom(signal_num),
442                         frame_rate_den = signals:get_frame_rate_den(signal_num)
443                 }
444
445                 if res.interlaced then
446                         -- Convert height from frame height to field height.
447                         -- (Needed for e.g. place_rectangle.)
448                         res.height = res.height * 2
449
450                         -- Show field rate instead of frame rate; really for cosmetics only
451                         -- (and actually contrary to EBU recommendations, although in line
452                         -- with typical user expectations).
453                         res.frame_rate_nom = res.frame_rate_nom * 2
454                 end
455
456                 input_resolution[signal_num] = res
457         end
458         last_resolution = input_resolution
459
460         if num == 0 then  -- Live.
461                 finish_transitions(t)
462                 if state.transition_type == ZOOM_TRANSITION then
463                         -- Transition in or out of SBS.
464                         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)
465                         return sbs_scene.scene
466                 elseif state.transition_type == NO_TRANSITION and state.live_signal_num == SBS_SIGNAL_NUM then
467                         -- Static SBS view.
468                         prepare_sbs_scene(state, 0.0, NO_TRANSITION, 0, SBS_SIGNAL_NUM, width, height, input_resolution, true)
469                         return sbs_scene.scene
470                 elseif state.transition_type == FADE_TRANSITION then
471                         setup_fade_input(state, fade_scene.input0, signals, state.transition_src_signal, width, height)
472                         setup_fade_input(state, fade_scene.input1, signals, state.transition_dst_signal, width, height)
473
474                         local tt = calc_fade_progress(t, state.transition_start, state.transition_end)
475                         fade_scene.mix_effect:set_float("strength_first", 1.0 - tt)
476                         fade_scene.mix_effect:set_float("strength_second", tt)
477
478                         return fade_scene.scene
479                 elseif is_plain_signal(state.live_signal_num) then
480                         setup_simple_input(state, signals, state.live_signal_num, width, height, true)
481                         return simple_scene.scene
482                 elseif state.live_signal_num == STATIC_SIGNAL_NUM then  -- Static picture.
483                         return static_scene
484                 else
485                         assert(false)
486                 end
487         end
488         if num == 1 then  -- Preview.
489                 num = state.preview_signal_num + 2
490         end
491
492         -- Individual preview inputs.
493         if is_plain_signal(num - 2) then
494                 setup_simple_input(state, signals, num - 2, width, height, false)
495                 return simple_scene.scene
496         end
497         if num == SBS_SIGNAL_NUM + 2 then
498                 prepare_sbs_scene(state, 0.0, NO_TRANSITION, 0, SBS_SIGNAL_NUM, width, height, input_resolution, false)
499                 return sbs_scene.scene
500         end
501         if num == STATIC_SIGNAL_NUM + 2 then
502                 return static_scene
503         end
504 end
505
506 function place_rectangle(input, x0, y0, x1, y1, screen_width, screen_height, input_width, input_height, hq)
507         input.padding_effect:set_int("width", screen_width)
508         input.padding_effect:set_int("height", screen_height)
509
510         -- Cull.
511         if x0 > screen_width or x1 < 0.0 or y0 > screen_height or y1 < 0.0 then
512                 input.resample_switcher:choose_alternative(ResizeEffect)  -- Low-quality resizing.
513                 input.resize_effect:set_int("width", 1)
514                 input.resize_effect:set_int("height", 1)
515                 input.padding_effect:set_int("left", screen_width + 100)
516                 input.padding_effect:set_int("top", screen_height + 100)
517                 return
518         end
519
520         local srcx0 = 0.0
521         local srcx1 = 1.0
522         local srcy0 = 0.0
523         local srcy1 = 1.0
524
525         -- Clip.
526         if x0 < 0 then
527                 srcx0 = -x0 / (x1 - x0)
528                 x0 = 0
529         end
530         if y0 < 0 then
531                 srcy0 = -y0 / (y1 - y0)
532                 y0 = 0
533         end
534         if x1 > screen_width then
535                 srcx1 = (screen_width - x0) / (x1 - x0)
536                 x1 = screen_width
537         end
538         if y1 > screen_height then
539                 srcy1 = (screen_height - y0) / (y1 - y0)
540                 y1 = screen_height
541         end
542
543         if hq then
544                 -- High-quality resampling.
545                 input.resample_switcher:choose_alternative(ResampleEffect)
546
547                 local x_subpixel_offset = x0 - math.floor(x0)
548                 local y_subpixel_offset = y0 - math.floor(y0)
549
550                 -- Resampling must be to an integral number of pixels. Round up,
551                 -- and then add an extra pixel so we have some leeway for the border.
552                 local width = math.ceil(x1 - x0) + 1
553                 local height = math.ceil(y1 - y0) + 1
554                 input.resample_effect:set_int("width", width)
555                 input.resample_effect:set_int("height", height)
556
557                 -- Correct the discrepancy with zoom. (This will leave a small
558                 -- excess edge of pixels and subpixels, which we'll correct for soon.)
559                 local zoom_x = (x1 - x0) / (width * (srcx1 - srcx0))
560                 local zoom_y = (y1 - y0) / (height * (srcy1 - srcy0))
561                 input.resample_effect:set_float("zoom_x", zoom_x)
562                 input.resample_effect:set_float("zoom_y", zoom_y)
563                 input.resample_effect:set_float("zoom_center_x", 0.0)
564                 input.resample_effect:set_float("zoom_center_y", 0.0)
565
566                 -- Padding must also be to a whole-pixel offset.
567                 input.padding_effect:set_int("left", math.floor(x0))
568                 input.padding_effect:set_int("top", math.floor(y0))
569
570                 -- Correct _that_ discrepancy by subpixel offset in the resampling.
571                 input.resample_effect:set_float("left", srcx0 * input_width - x_subpixel_offset / zoom_x)
572                 input.resample_effect:set_float("top", srcy0 * input_height - y_subpixel_offset / zoom_y)
573
574                 -- Finally, adjust the border so it is exactly where we want it.
575                 input.padding_effect:set_float("border_offset_left", x_subpixel_offset)
576                 input.padding_effect:set_float("border_offset_right", x1 - (math.floor(x0) + width))
577                 input.padding_effect:set_float("border_offset_top", y_subpixel_offset)
578                 input.padding_effect:set_float("border_offset_bottom", y1 - (math.floor(y0) + height))
579         else
580                 -- Lower-quality simple resizing.
581                 input.resample_switcher:choose_alternative(ResizeEffect)
582
583                 local width = round(x1 - x0)
584                 local height = round(y1 - y0)
585                 input.resize_effect:set_int("width", width)
586                 input.resize_effect:set_int("height", height)
587
588                 -- Padding must also be to a whole-pixel offset.
589                 input.padding_effect:set_int("left", math.floor(x0))
590                 input.padding_effect:set_int("top", math.floor(y0))
591
592                 -- No subpixel stuff.
593                 input.padding_effect:set_float("border_offset_left", 0.0)
594                 input.padding_effect:set_float("border_offset_right", 0.0)
595                 input.padding_effect:set_float("border_offset_top", 0.0)
596                 input.padding_effect:set_float("border_offset_bottom", 0.0)
597         end
598 end
599
600 -- This is broken, of course (even for positive numbers), but Lua doesn't give us access to real rounding.
601 function round(x)
602         return math.floor(x + 0.5)
603 end
604
605 function lerp(a, b, t)
606         return a + (b - a) * t
607 end
608
609 function lerp_pos(a, b, t)
610         return {
611                 x0 = lerp(a.x0, b.x0, t),
612                 y0 = lerp(a.y0, b.y0, t),
613                 x1 = lerp(a.x1, b.x1, t),
614                 y1 = lerp(a.y1, b.y1, t)
615         }
616 end
617
618 function pos_from_top_left(x, y, width, height, screen_width, screen_height)
619         local xs = screen_width / 1280.0
620         local ys = screen_height / 720.0
621         return {
622                 x0 = round(xs * x),
623                 y0 = round(ys * y),
624                 x1 = round(xs * (x + width)),
625                 y1 = round(ys * (y + height))
626         }
627 end
628
629 function prepare_sbs_scene(state, t, transition_type, src_signal, dst_signal, screen_width, screen_height, input_resolution, hq)
630         set_neutral_color(sbs_scene.input0.wb_effect, state.neutral_colors[1])
631         set_neutral_color(sbs_scene.input1.wb_effect, state.neutral_colors[2])
632
633         -- First input is positioned (16,48) from top-left.
634         -- Second input is positioned (16,48) from the bottom-right.
635         local pos0 = pos_from_top_left(16, 48, 848, 477, screen_width, screen_height)
636         local pos1 = pos_from_top_left(1280 - 384 - 16, 720 - 216 - 48, 384, 216, screen_width, screen_height)
637
638         local pos_fs = { x0 = 0, y0 = 0, x1 = screen_width, y1 = screen_height }
639         local affine_param
640         if transition_type == NO_TRANSITION then
641                 -- Static SBS view.
642                 affine_param = { sx = 1.0, sy = 1.0, tx = 0.0, ty = 0.0 }   -- Identity.
643         else
644                 -- Zooming to/from SBS view into or out of a single view.
645                 assert(transition_type == ZOOM_TRANSITION)
646                 local signal, real_t
647                 if src_signal == SBS_SIGNAL_NUM then
648                         signal = dst_signal
649                         real_t = t
650                 else
651                         assert(dst_signal == SBS_SIGNAL_NUM)
652                         signal = src_signal
653                         real_t = 1.0 - t
654                 end
655
656                 if signal == INPUT0_SIGNAL_NUM then
657                         affine_param = find_affine_param(pos0, lerp_pos(pos0, pos_fs, real_t))
658                 elseif signal == INPUT1_SIGNAL_NUM then
659                         affine_param = find_affine_param(pos1, lerp_pos(pos1, pos_fs, real_t))
660                 end
661         end
662
663         -- NOTE: input_resolution is not 1-indexed, unlike usual Lua arrays.
664         place_rectangle_with_affine(sbs_scene.input0, pos0, affine_param, screen_width, screen_height, input_resolution[0].width, input_resolution[0].height, hq)
665         place_rectangle_with_affine(sbs_scene.input1, pos1, affine_param, screen_width, screen_height, input_resolution[1].width, input_resolution[1].height, hq)
666 end
667
668 -- Find the transformation that changes the first rectangle to the second one.
669 function find_affine_param(a, b)
670         local sx = (b.x1 - b.x0) / (a.x1 - a.x0)
671         local sy = (b.y1 - b.y0) / (a.y1 - a.y0)
672         return {
673                 sx = sx,
674                 sy = sy,
675                 tx = b.x0 - a.x0 * sx,
676                 ty = b.y0 - a.y0 * sy
677         }
678 end
679
680 function place_rectangle_with_affine(input, pos, aff, screen_width, screen_height, input_width, input_height, hq)
681         local x0 = pos.x0 * aff.sx + aff.tx
682         local x1 = pos.x1 * aff.sx + aff.tx
683         local y0 = pos.y0 * aff.sy + aff.ty
684         local y1 = pos.y1 * aff.sy + aff.ty
685
686         place_rectangle(input, x0, y0, x1, y1, screen_width, screen_height, input_width, input_height, hq)
687 end
688
689 function set_neutral_color(effect, color)
690         effect:set_vec3("neutral_color", color[1], color[2], color[3])
691 end
692
693 function set_neutral_color_from_signal(state, effect, signal)
694         if is_plain_signal(signal) then
695                 set_neutral_color(effect, state.neutral_colors[signal - INPUT0_SIGNAL_NUM + 1])
696         end
697 end
698
699 function calc_zoom_progress(state, t)
700         if t < state.transition_start then
701                 return 0.0
702         elseif t > state.transition_end then
703                 return 1.0
704         else
705                 local tt = (t - state.transition_start) / (state.transition_end - state.transition_start)
706                 -- Smooth it a bit.
707                 return math.sin(tt * 3.14159265358 * 0.5)
708         end
709 end
710
711 function calc_fade_progress(t, transition_start, transition_end)
712         local tt = (t - transition_start) / (transition_end - transition_start)
713         if tt < 0.0 then
714                 return 0.0
715         elseif tt > 1.0 then
716                 return 1.0
717         end
718
719         -- Make the fade look maybe a tad more natural, by pumping it
720         -- through a sigmoid function.
721         tt = 10.0 * tt - 5.0
722         tt = 1.0 / (1.0 + math.exp(-tt))
723
724         return tt
725 end