]> git.sesse.net Git - nageru/blob - nageru/theme.lua
Fix a comment typo.
[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_auto_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_auto_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_auto_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 basically an exposed InputState, which you can use to
329 -- query for information about the signals at the point of the current
330 -- frame. In particular, you can call get_frame_width() and get_frame_height()
331 -- for any signal number, and use that to e.g. assist in scene selection.
332 -- (You can also use get_width() and get_height(), which return the
333 -- _field_ size. This has half the height for interlaced signals.)
334 --
335 -- You should return scene to use, after having set any parameters you
336 -- want to set (through set_int() etc.). The parameters will be snapshot
337 -- at return time and used during rendering.
338 function get_scene(num, t, width, height, signals)
339         local input_resolution = {}
340         for signal_num=0,1 do
341                 local res = {
342                         width = signals:get_frame_width(signal_num),
343                         height = signals:get_frame_height(signal_num),
344                 }
345                 input_resolution[signal_num] = res
346
347                 local text_res = signals:get_human_readable_resolution(signal_num)
348                 Nageru.set_channel_name(signal_num + 2, "Input " .. (signal_num + 1) .. " (" .. text_res .. ")")
349         end
350
351         if num == 0 then  -- Live.
352                 finish_transitions(t)
353                 if state.transition_type == ZOOM_TRANSITION then
354                         -- Transition in or out of SBS.
355                         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)
356                         return sbs_scene.scene
357                 elseif state.transition_type == NO_TRANSITION and state.live_signal_num == SBS_SIGNAL_NUM then
358                         -- Static SBS view.
359                         prepare_sbs_scene(state, 0.0, NO_TRANSITION, 0, SBS_SIGNAL_NUM, width, height, input_resolution, true)
360                         return sbs_scene.scene
361                 elseif state.transition_type == FADE_TRANSITION then
362                         setup_fade_input(state, fade_scene.input0, signals, state.transition_src_signal, width, height)
363                         setup_fade_input(state, fade_scene.input1, signals, state.transition_dst_signal, width, height)
364
365                         local tt = calc_fade_progress(t, state.transition_start, state.transition_end)
366                         fade_scene.mix_effect:set_float("strength_first", 1.0 - tt)
367                         fade_scene.mix_effect:set_float("strength_second", tt)
368
369                         return fade_scene.scene
370                 elseif is_plain_signal(state.live_signal_num) then
371                         setup_simple_input(state, signals, state.live_signal_num, width, height, true)
372                         return simple_scene.scene
373                 elseif state.live_signal_num == STATIC_SIGNAL_NUM then  -- Static picture.
374                         return static_scene
375                 else
376                         assert(false)
377                 end
378         end
379         if num == 1 then  -- Preview.
380                 num = state.preview_signal_num + 2
381         end
382
383         -- Individual preview inputs.
384         if is_plain_signal(num - 2) then
385                 setup_simple_input(state, signals, num - 2, width, height, false)
386                 return simple_scene.scene
387         end
388         if num == SBS_SIGNAL_NUM + 2 then
389                 prepare_sbs_scene(state, 0.0, NO_TRANSITION, 0, SBS_SIGNAL_NUM, width, height, input_resolution, false)
390                 return sbs_scene.scene
391         end
392         if num == STATIC_SIGNAL_NUM + 2 then
393                 return static_scene
394         end
395 end
396
397 function place_rectangle(input, x0, y0, x1, y1, screen_width, screen_height, input_width, input_height, hq)
398         input.padding_effect:set_int("width", screen_width)
399         input.padding_effect:set_int("height", screen_height)
400
401         -- Cull.
402         if x0 > screen_width or x1 < 0.0 or y0 > screen_height or y1 < 0.0 then
403                 input.resample_effect:choose(ResizeEffect)  -- Low-quality resizing.
404                 input.resample_effect:set_int("width", 1)
405                 input.resample_effect:set_int("height", 1)
406                 input.padding_effect:set_int("left", screen_width + 100)
407                 input.padding_effect:set_int("top", screen_height + 100)
408                 return
409         end
410
411         local srcx0 = 0.0
412         local srcx1 = 1.0
413         local srcy0 = 0.0
414         local srcy1 = 1.0
415
416         -- Clip.
417         if x0 < 0 then
418                 srcx0 = -x0 / (x1 - x0)
419                 x0 = 0
420         end
421         if y0 < 0 then
422                 srcy0 = -y0 / (y1 - y0)
423                 y0 = 0
424         end
425         if x1 > screen_width then
426                 srcx1 = (screen_width - x0) / (x1 - x0)
427                 x1 = screen_width
428         end
429         if y1 > screen_height then
430                 srcy1 = (screen_height - y0) / (y1 - y0)
431                 y1 = screen_height
432         end
433
434         if hq then
435                 -- High-quality resampling. Go for the actual effect (returned by choose())
436                 -- since we want to set zoom_*, which will give an error if set on ResizeEffect.
437                 local resample_effect = input.resample_effect:choose(ResampleEffect)
438
439                 local x_subpixel_offset = x0 - math.floor(x0)
440                 local y_subpixel_offset = y0 - math.floor(y0)
441
442                 -- Resampling must be to an integral number of pixels. Round up,
443                 -- and then add an extra pixel so we have some leeway for the border.
444                 local width = math.ceil(x1 - x0) + 1
445                 local height = math.ceil(y1 - y0) + 1
446                 resample_effect:set_int("width", width)
447                 resample_effect:set_int("height", height)
448
449                 -- Correct the discrepancy with zoom. (This will leave a small
450                 -- excess edge of pixels and subpixels, which we'll correct for soon.)
451                 local zoom_x = (x1 - x0) / (width * (srcx1 - srcx0))
452                 local zoom_y = (y1 - y0) / (height * (srcy1 - srcy0))
453                 resample_effect:set_float("zoom_x", zoom_x)
454                 resample_effect:set_float("zoom_y", zoom_y)
455                 resample_effect:set_float("zoom_center_x", 0.0)
456                 resample_effect:set_float("zoom_center_y", 0.0)
457
458                 -- Padding must also be to a whole-pixel offset.
459                 input.padding_effect:set_int("left", math.floor(x0))
460                 input.padding_effect:set_int("top", math.floor(y0))
461
462                 -- Correct _that_ discrepancy by subpixel offset in the resampling.
463                 resample_effect:set_float("left", srcx0 * input_width - x_subpixel_offset / zoom_x)
464                 resample_effect:set_float("top", srcy0 * input_height - y_subpixel_offset / zoom_y)
465
466                 -- Finally, adjust the border so it is exactly where we want it.
467                 input.padding_effect:set_float("border_offset_left", x_subpixel_offset)
468                 input.padding_effect:set_float("border_offset_right", x1 - (math.floor(x0) + width))
469                 input.padding_effect:set_float("border_offset_top", y_subpixel_offset)
470                 input.padding_effect:set_float("border_offset_bottom", y1 - (math.floor(y0) + height))
471         else
472                 -- Lower-quality simple resizing.
473                 input.resample_effect:choose(ResizeEffect)
474
475                 local width = round(x1 - x0)
476                 local height = round(y1 - y0)
477                 input.resample_effect:set_int("width", width)
478                 input.resample_effect:set_int("height", height)
479
480                 -- Padding must also be to a whole-pixel offset.
481                 input.padding_effect:set_int("left", math.floor(x0))
482                 input.padding_effect:set_int("top", math.floor(y0))
483
484                 -- No subpixel stuff.
485                 input.padding_effect:set_float("border_offset_left", 0.0)
486                 input.padding_effect:set_float("border_offset_right", 0.0)
487                 input.padding_effect:set_float("border_offset_top", 0.0)
488                 input.padding_effect:set_float("border_offset_bottom", 0.0)
489         end
490 end
491
492 -- This is broken, of course (even for positive numbers), but Lua doesn't give us access to real rounding.
493 function round(x)
494         return math.floor(x + 0.5)
495 end
496
497 function lerp(a, b, t)
498         return a + (b - a) * t
499 end
500
501 function lerp_pos(a, b, t)
502         return {
503                 x0 = lerp(a.x0, b.x0, t),
504                 y0 = lerp(a.y0, b.y0, t),
505                 x1 = lerp(a.x1, b.x1, t),
506                 y1 = lerp(a.y1, b.y1, t)
507         }
508 end
509
510 function pos_from_top_left(x, y, width, height, screen_width, screen_height)
511         local xs = screen_width / 1280.0
512         local ys = screen_height / 720.0
513         return {
514                 x0 = round(xs * x),
515                 y0 = round(ys * y),
516                 x1 = round(xs * (x + width)),
517                 y1 = round(ys * (y + height))
518         }
519 end
520
521 function prepare_sbs_scene(state, t, transition_type, src_signal, dst_signal, screen_width, screen_height, input_resolution, hq)
522         -- First input is positioned (16,48) from top-left.
523         -- Second input is positioned (16,48) from the bottom-right.
524         local pos0 = pos_from_top_left(16, 48, 848, 477, screen_width, screen_height)
525         local pos1 = pos_from_top_left(1280 - 384 - 16, 720 - 216 - 48, 384, 216, screen_width, screen_height)
526
527         local pos_fs = { x0 = 0, y0 = 0, x1 = screen_width, y1 = screen_height }
528         local affine_param
529         if transition_type == NO_TRANSITION then
530                 -- Static SBS view.
531                 affine_param = { sx = 1.0, sy = 1.0, tx = 0.0, ty = 0.0 }   -- Identity.
532         else
533                 -- Zooming to/from SBS view into or out of a single view.
534                 assert(transition_type == ZOOM_TRANSITION)
535                 local signal, real_t
536                 if src_signal == SBS_SIGNAL_NUM then
537                         signal = dst_signal
538                         real_t = t
539                 else
540                         assert(dst_signal == SBS_SIGNAL_NUM)
541                         signal = src_signal
542                         real_t = 1.0 - t
543                 end
544
545                 if signal == INPUT0_SIGNAL_NUM then
546                         affine_param = find_affine_param(pos0, lerp_pos(pos0, pos_fs, real_t))
547                 elseif signal == INPUT1_SIGNAL_NUM then
548                         affine_param = find_affine_param(pos1, lerp_pos(pos1, pos_fs, real_t))
549                 end
550         end
551
552         -- NOTE: input_resolution is not 1-indexed, unlike usual Lua arrays.
553         place_rectangle_with_affine(sbs_scene.input0, pos0, affine_param, screen_width, screen_height, input_resolution[0].width, input_resolution[0].height, hq)
554         place_rectangle_with_affine(sbs_scene.input1, pos1, affine_param, screen_width, screen_height, input_resolution[1].width, input_resolution[1].height, hq)
555 end
556
557 -- Find the transformation that changes the first rectangle to the second one.
558 function find_affine_param(a, b)
559         local sx = (b.x1 - b.x0) / (a.x1 - a.x0)
560         local sy = (b.y1 - b.y0) / (a.y1 - a.y0)
561         return {
562                 sx = sx,
563                 sy = sy,
564                 tx = b.x0 - a.x0 * sx,
565                 ty = b.y0 - a.y0 * sy
566         }
567 end
568
569 function place_rectangle_with_affine(input, pos, aff, screen_width, screen_height, input_width, input_height, hq)
570         local x0 = pos.x0 * aff.sx + aff.tx
571         local x1 = pos.x1 * aff.sx + aff.tx
572         local y0 = pos.y0 * aff.sy + aff.ty
573         local y1 = pos.y1 * aff.sy + aff.ty
574
575         place_rectangle(input, x0, y0, x1, y1, screen_width, screen_height, input_width, input_height, hq)
576 end
577
578 function calc_zoom_progress(state, t)
579         if t < state.transition_start then
580                 return 0.0
581         elseif t > state.transition_end then
582                 return 1.0
583         else
584                 local tt = (t - state.transition_start) / (state.transition_end - state.transition_start)
585                 -- Smooth it a bit.
586                 return math.sin(tt * 3.14159265358 * 0.5)
587         end
588 end
589
590 function calc_fade_progress(t, transition_start, transition_end)
591         local tt = (t - transition_start) / (transition_end - transition_start)
592         if tt < 0.0 then
593                 return 0.0
594         elseif tt > 1.0 then
595                 return 1.0
596         end
597
598         -- Make the fade look maybe a tad more natural, by pumping it
599         -- through a sigmoid function.
600         tt = 10.0 * tt - 5.0
601         tt = 1.0 / (1.0 + math.exp(-tt))
602
603         return tt
604 end