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
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.
13 transition_start = -2.0,
14 transition_end = -1.0,
16 transition_src_signal = 0,
17 transition_dst_signal = 0,
20 {0.5, 0.5, 0.5}, -- Input 0.
21 {0.5, 0.5, 0.5} -- Input 1.
25 preview_signal_num = 1
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
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
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 = {}
44 function make_sbs_input(scene)
46 input = scene:add_input(),
47 resample_effect = scene:add_effect({ResampleEffect.new(), ResizeEffect.new()}),
48 wb_effect = scene:add_effect(WhiteBalanceEffect.new()),
49 padding_effect = scene:add_effect(IntegralPaddingEffect.new())
53 -- The main live scene.
54 function make_sbs_scene()
55 local scene = Scene.new(16, 9)
57 local input0 = make_sbs_input(scene)
58 input0.input:display(0)
59 input0.padding_effect:set_vec4("border_color", 0.0, 0.0, 0.0, 1.0)
61 local input1 = make_sbs_input(scene)
62 input1.input:display(1)
63 input1.padding_effect:set_vec4("border_color", 0.0, 0.0, 0.0, 0.0)
65 scene:add_effect(OverlayEffect.new(), input0.padding_effect, input1.padding_effect)
74 local sbs_scene = make_sbs_scene()
76 function make_fade_input(scene)
78 input = scene:add_input(),
79 resample_effect = scene:add_optional_effect(ResampleEffect.new()), -- Activated if scaling.
80 wb_effect = scene:add_optional_effect(WhiteBalanceEffect.new()) -- Activated for video inputs.
84 -- A scene to fade between two inputs, of which either can be a picture
85 -- or a live input. Only used live.
86 function make_fade_scene()
87 local scene = Scene.new(16, 9)
88 local input0 = make_fade_input(scene)
89 local input1 = make_fade_input(scene)
90 local mix_effect = scene:add_effect(MixEffect.new(), input0.wb_effect, input1.wb_effect)
91 scene:finalize(true) -- Only used live.
97 mix_effect = mix_effect
100 local fade_scene = make_fade_scene()
102 -- A scene to show a single input on screen.
103 local scene = Scene.new(16, 9)
104 local simple_scene = {
106 input = scene:add_input(),
107 resample_effect = scene:add_effect({ResampleEffect.new(), ResizeEffect.new(), IdentityEffect.new()}),
108 wb_effect = scene:add_effect(WhiteBalanceEffect.new())
112 -- A scene to show a single static picture on screen.
113 local static_image = ImageInput.new("bg.jpeg") -- Also used as input to other scenes.
114 local static_scene = Scene.new(16, 9)
115 static_scene:add_input(static_image) -- Note: Locks this input to images only.
116 static_scene:finalize()
119 -- Returns the number of outputs in addition to the live (0) and preview (1).
120 -- Called only once, at the start of the program.
121 function num_channels()
125 function is_plain_signal(num)
126 return num == INPUT0_SIGNAL_NUM or num == INPUT1_SIGNAL_NUM
129 -- Helper function to write e.g. “720p60”. The difference between this
130 -- and get_channel_resolution_raw() is that this one also can say that
131 -- there's no signal.
132 function get_channel_resolution(signal_num)
133 local res = last_resolution[signal_num]
134 if (not res) or not res.is_connected then
135 return "disconnected"
137 if res.height <= 0 then
140 if not res.has_signal then
141 if res.height == 525 then
142 -- Special mode for the USB3 cards.
145 return get_channel_resolution_raw(res) .. ", no signal"
147 return get_channel_resolution_raw(res)
151 -- Helper function to write e.g. “60” or “59.94”.
152 function get_frame_rate(res)
153 local nom = res.frame_rate_nom
154 local den = res.frame_rate_den
155 if nom % den == 0 then
158 return string.format("%.2f", nom / den)
162 -- Helper function to write e.g. “720p60”.
163 function get_channel_resolution_raw(res)
164 if res.interlaced then
165 return res.height .. "i" .. get_frame_rate(res)
167 return res.height .. "p" .. get_frame_rate(res)
172 -- Returns the name for each additional channel (starting from 2).
173 -- Called at the start of the program, and then each frame for live
174 -- channels in case they change resolution.
175 function channel_name(channel)
176 local signal_num = channel - 2
177 if is_plain_signal(signal_num) then
178 return "Input " .. (signal_num + 1) .. " (" .. get_channel_resolution(signal_num) .. ")"
179 elseif signal_num == SBS_SIGNAL_NUM then
180 return "Side-by-side"
181 elseif signal_num == STATIC_SIGNAL_NUM then
182 return "Static picture"
187 -- Returns, given a channel number, which signal it corresponds to (starting from 0).
188 -- Should return -1 if the channel does not correspond to a simple signal
189 -- (one connected to a capture card, or a video input). The information is used for
190 -- whether right-click on the channel should bring up a context menu or not,
191 -- typically containing an input selector, resolution menu etc.
193 -- Called once for each channel, at the start of the program.
194 -- Will never be called for live (0) or preview (1).
195 function channel_signal(channel)
198 elseif channel == 3 then
206 -- Called every frame. Returns the color (if any) to paint around the given
207 -- channel. Returns a CSS color (typically to mark live and preview signals);
208 -- "transparent" is allowed.
209 -- Will never be called for live (0) or preview (1).
210 function channel_color(channel)
211 if state.transition_type ~= NO_TRANSITION then
212 if channel_involved_in(channel, state.transition_src_signal) or
213 channel_involved_in(channel, state.transition_dst_signal) then
217 if channel_involved_in(channel, state.live_signal_num) then
221 if channel_involved_in(channel, state.preview_signal_num) then
227 function channel_involved_in(channel, signal_num)
228 if is_plain_signal(signal_num) then
229 return channel == (signal_num + 2)
231 if signal_num == SBS_SIGNAL_NUM then
232 return (channel == 2 or channel == 3)
234 if signal_num == STATIC_SIGNAL_NUM then
235 return (channel == 5)
241 -- Returns if a given channel supports setting white balance (starting from 2).
242 -- Called only once for each channel, at the start of the program.
243 function supports_set_wb(channel)
244 return is_plain_signal(channel - 2)
248 -- Gets called with a new gray point when the white balance is changing.
249 -- The color is in linear light (not sRGB gamma).
250 function set_wb(channel, red, green, blue)
251 if is_plain_signal(channel - 2) then
252 state.neutral_colors[channel - 2 + 1] = { red, green, blue }
256 function finish_transitions(t)
257 if state.transition_type ~= NO_TRANSITION and t >= state.transition_end then
258 state.live_signal_num = state.transition_dst_signal
259 state.transition_type = NO_TRANSITION
263 function in_transition(t)
264 return t >= state.transition_start and t <= state.transition_end
268 -- Called every frame.
269 function get_transitions(t)
270 if in_transition(t) then
271 -- Transition already in progress, the only thing we can do is really
272 -- cut to the preview. (TODO: Make an “abort” and/or “finish”, too?)
276 finish_transitions(t)
278 if state.live_signal_num == state.preview_signal_num then
279 -- No transitions possible.
283 if (is_plain_signal(state.live_signal_num) or state.live_signal_num == STATIC_SIGNAL_NUM) and
284 (is_plain_signal(state.preview_signal_num) or state.preview_signal_num == STATIC_SIGNAL_NUM) then
285 return {"Cut", "", "Fade"}
289 if state.live_signal_num == SBS_SIGNAL_NUM and is_plain_signal(state.preview_signal_num) then
290 return {"Cut", "Zoom in"}
291 elseif is_plain_signal(state.live_signal_num) and state.preview_signal_num == SBS_SIGNAL_NUM then
292 return {"Cut", "Zoom out"}
298 function swap_preview_live()
299 local temp = state.live_signal_num
300 state.live_signal_num = state.preview_signal_num
301 state.preview_signal_num = temp
304 function start_transition(type_, t, duration)
305 state.transition_start = t
306 state.transition_end = t + duration
307 state.transition_type = type_
308 state.transition_src_signal = state.live_signal_num
309 state.transition_dst_signal = state.preview_signal_num
314 -- Called when the user clicks a transition button.
315 function transition_clicked(num, t)
318 if in_transition(t) then
319 -- Ongoing transition; finish it immediately before the cut.
320 finish_transitions(state.transition_end)
326 finish_transitions(t)
328 if state.live_signal_num == state.preview_signal_num then
333 if is_plain_signal(state.live_signal_num) and is_plain_signal(state.preview_signal_num) then
334 -- We can't zoom between these. Just make a cut.
335 io.write("Cutting from " .. state.live_signal_num .. " to " .. state.live_signal_num .. "\n")
340 if (state.live_signal_num == SBS_SIGNAL_NUM and is_plain_signal(state.preview_signal_num)) or
341 (state.preview_signal_num == SBS_SIGNAL_NUM and is_plain_signal(state.live_signal_num)) then
342 start_transition(ZOOM_TRANSITION, t, 1.0)
345 finish_transitions(t)
348 if (state.live_signal_num ~= state.preview_signal_num) and
349 (is_plain_signal(state.live_signal_num) or
350 state.live_signal_num == STATIC_SIGNAL_NUM) and
351 (is_plain_signal(state.preview_signal_num) or
352 state.preview_signal_num == STATIC_SIGNAL_NUM) then
353 start_transition(FADE_TRANSITION, t, 1.0)
355 -- Fades involving SBS are ignored (we have no scene for it).
361 function channel_clicked(num)
362 state.preview_signal_num = num
365 function setup_fade_input(state, input, signals, signal_num, width, height)
366 if signal_num == STATIC_SIGNAL_NUM then
367 input.input:display(static_image)
368 input.wb_effect:disable()
370 -- We assume this is already correctly scaled at load time.
371 input.resample_effect:disable()
373 input.input:display(signal_num)
374 input.wb_effect:enable()
375 set_neutral_color(input.wb_effect, state.neutral_colors[signal_num - INPUT0_SIGNAL_NUM + 1])
377 if (signals:get_width(signal_num) ~= width or signals:get_height(signal_num) ~= height) then
378 input.resample_effect:enable()
379 input.resample_effect:set_int("width", width)
380 input.resample_effect:set_int("height", height)
382 input.resample_effect:disable()
387 function needs_scale(signals, signal_num, width, height)
388 if signal_num == STATIC_SIGNAL_NUM then
389 -- We assume this is already correctly scaled at load time.
392 assert(is_plain_signal(signal_num))
393 return (signals:get_width(signal_num) ~= width or signals:get_height(signal_num) ~= height)
396 function setup_simple_input(state, signals, signal_num, width, height, hq)
397 simple_scene.input:display(signal_num)
398 if needs_scale(signals, signal_num, width, height) then
400 simple_scene.resample_effect:choose(ResampleEffect) -- High-quality resampling.
402 simple_scene.resample_effect:choose(ResizeEffect) -- Low-quality resampling.
404 simple_scene.resample_effect:set_int("width", width)
405 simple_scene.resample_effect:set_int("height", height)
407 simple_scene.resample_effect:disable() -- No scaling.
409 set_neutral_color_from_signal(state, simple_scene.wb_effect, signal_num)
413 -- Called every frame. Get the scene for displaying at input <num>,
414 -- where 0 is live, 1 is preview, 2 is the first channel to display
415 -- in the bottom bar, and so on up to num_channels()+1. t is the
416 -- current time in seconds. width and height are the dimensions of
417 -- the output, although you can ignore them if you don't need them
418 -- (they're useful if you want to e.g. know what to resample by).
420 -- <signals> is basically an exposed InputState, which you can use to
421 -- query for information about the signals at the point of the current
422 -- frame. In particular, you can call get_width() and get_height()
423 -- for any signal number, and use that to e.g. assist in scene selection.
425 -- You should return scene to use, after having set any parameters you
426 -- want to set (through set_int() etc.). The parameters will be snapshot
427 -- at return time and used during rendering.
428 function get_scene(num, t, width, height, signals)
429 local input_resolution = {}
430 for signal_num=0,1 do
432 width = signals:get_width(signal_num),
433 height = signals:get_height(signal_num),
434 interlaced = signals:get_interlaced(signal_num),
435 is_connected = signals:get_is_connected(signal_num),
436 has_signal = signals:get_has_signal(signal_num),
437 frame_rate_nom = signals:get_frame_rate_nom(signal_num),
438 frame_rate_den = signals:get_frame_rate_den(signal_num)
441 if res.interlaced then
442 -- Convert height from frame height to field height.
443 -- (Needed for e.g. place_rectangle.)
444 res.height = res.height * 2
446 -- Show field rate instead of frame rate; really for cosmetics only
447 -- (and actually contrary to EBU recommendations, although in line
448 -- with typical user expectations).
449 res.frame_rate_nom = res.frame_rate_nom * 2
452 input_resolution[signal_num] = res
454 last_resolution = input_resolution
456 if num == 0 then -- Live.
457 finish_transitions(t)
458 if state.transition_type == ZOOM_TRANSITION then
459 -- Transition in or out of SBS.
460 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)
461 return sbs_scene.scene
462 elseif state.transition_type == NO_TRANSITION and state.live_signal_num == SBS_SIGNAL_NUM then
464 prepare_sbs_scene(state, 0.0, NO_TRANSITION, 0, SBS_SIGNAL_NUM, width, height, input_resolution, true)
465 return sbs_scene.scene
466 elseif state.transition_type == FADE_TRANSITION then
467 setup_fade_input(state, fade_scene.input0, signals, state.transition_src_signal, width, height)
468 setup_fade_input(state, fade_scene.input1, signals, state.transition_dst_signal, width, height)
470 local tt = calc_fade_progress(t, state.transition_start, state.transition_end)
471 fade_scene.mix_effect:set_float("strength_first", 1.0 - tt)
472 fade_scene.mix_effect:set_float("strength_second", tt)
474 return fade_scene.scene
475 elseif is_plain_signal(state.live_signal_num) then
476 setup_simple_input(state, signals, state.live_signal_num, width, height, true)
477 return simple_scene.scene
478 elseif state.live_signal_num == STATIC_SIGNAL_NUM then -- Static picture.
484 if num == 1 then -- Preview.
485 num = state.preview_signal_num + 2
488 -- Individual preview inputs.
489 if is_plain_signal(num - 2) then
490 setup_simple_input(state, signals, num - 2, width, height, false)
491 return simple_scene.scene
493 if num == SBS_SIGNAL_NUM + 2 then
494 prepare_sbs_scene(state, 0.0, NO_TRANSITION, 0, SBS_SIGNAL_NUM, width, height, input_resolution, false)
495 return sbs_scene.scene
497 if num == STATIC_SIGNAL_NUM + 2 then
502 function place_rectangle(input, x0, y0, x1, y1, screen_width, screen_height, input_width, input_height, hq)
503 input.padding_effect:set_int("width", screen_width)
504 input.padding_effect:set_int("height", screen_height)
507 if x0 > screen_width or x1 < 0.0 or y0 > screen_height or y1 < 0.0 then
508 input.resample_effect:choose(ResizeEffect) -- Low-quality resizing.
509 input.resample_effect:set_int("width", 1)
510 input.resample_effect:set_int("height", 1)
511 input.padding_effect:set_int("left", screen_width + 100)
512 input.padding_effect:set_int("top", screen_height + 100)
523 srcx0 = -x0 / (x1 - x0)
527 srcy0 = -y0 / (y1 - y0)
530 if x1 > screen_width then
531 srcx1 = (screen_width - x0) / (x1 - x0)
534 if y1 > screen_height then
535 srcy1 = (screen_height - y0) / (y1 - y0)
540 -- High-quality resampling. Go for the actual effect (returned by choose())
541 -- since we want to set zoom_*, which will give an error if set on ResizeEffect.
542 local resample_effect = input.resample_effect:choose(ResampleEffect)
544 local x_subpixel_offset = x0 - math.floor(x0)
545 local y_subpixel_offset = y0 - math.floor(y0)
547 -- Resampling must be to an integral number of pixels. Round up,
548 -- and then add an extra pixel so we have some leeway for the border.
549 local width = math.ceil(x1 - x0) + 1
550 local height = math.ceil(y1 - y0) + 1
551 resample_effect:set_int("width", width)
552 resample_effect:set_int("height", height)
554 -- Correct the discrepancy with zoom. (This will leave a small
555 -- excess edge of pixels and subpixels, which we'll correct for soon.)
556 local zoom_x = (x1 - x0) / (width * (srcx1 - srcx0))
557 local zoom_y = (y1 - y0) / (height * (srcy1 - srcy0))
558 resample_effect:set_float("zoom_x", zoom_x)
559 resample_effect:set_float("zoom_y", zoom_y)
560 resample_effect:set_float("zoom_center_x", 0.0)
561 resample_effect:set_float("zoom_center_y", 0.0)
563 -- Padding must also be to a whole-pixel offset.
564 input.padding_effect:set_int("left", math.floor(x0))
565 input.padding_effect:set_int("top", math.floor(y0))
567 -- Correct _that_ discrepancy by subpixel offset in the resampling.
568 resample_effect:set_float("left", srcx0 * input_width - x_subpixel_offset / zoom_x)
569 resample_effect:set_float("top", srcy0 * input_height - y_subpixel_offset / zoom_y)
571 -- Finally, adjust the border so it is exactly where we want it.
572 input.padding_effect:set_float("border_offset_left", x_subpixel_offset)
573 input.padding_effect:set_float("border_offset_right", x1 - (math.floor(x0) + width))
574 input.padding_effect:set_float("border_offset_top", y_subpixel_offset)
575 input.padding_effect:set_float("border_offset_bottom", y1 - (math.floor(y0) + height))
577 -- Lower-quality simple resizing.
578 input.resample_effect:choose(ResizeEffect)
580 local width = round(x1 - x0)
581 local height = round(y1 - y0)
582 input.resample_effect:set_int("width", width)
583 input.resample_effect:set_int("height", height)
585 -- Padding must also be to a whole-pixel offset.
586 input.padding_effect:set_int("left", math.floor(x0))
587 input.padding_effect:set_int("top", math.floor(y0))
589 -- No subpixel stuff.
590 input.padding_effect:set_float("border_offset_left", 0.0)
591 input.padding_effect:set_float("border_offset_right", 0.0)
592 input.padding_effect:set_float("border_offset_top", 0.0)
593 input.padding_effect:set_float("border_offset_bottom", 0.0)
597 -- This is broken, of course (even for positive numbers), but Lua doesn't give us access to real rounding.
599 return math.floor(x + 0.5)
602 function lerp(a, b, t)
603 return a + (b - a) * t
606 function lerp_pos(a, b, t)
608 x0 = lerp(a.x0, b.x0, t),
609 y0 = lerp(a.y0, b.y0, t),
610 x1 = lerp(a.x1, b.x1, t),
611 y1 = lerp(a.y1, b.y1, t)
615 function pos_from_top_left(x, y, width, height, screen_width, screen_height)
616 local xs = screen_width / 1280.0
617 local ys = screen_height / 720.0
621 x1 = round(xs * (x + width)),
622 y1 = round(ys * (y + height))
626 function prepare_sbs_scene(state, t, transition_type, src_signal, dst_signal, screen_width, screen_height, input_resolution, hq)
627 set_neutral_color(sbs_scene.input0.wb_effect, state.neutral_colors[1])
628 set_neutral_color(sbs_scene.input1.wb_effect, state.neutral_colors[2])
630 -- First input is positioned (16,48) from top-left.
631 -- Second input is positioned (16,48) from the bottom-right.
632 local pos0 = pos_from_top_left(16, 48, 848, 477, screen_width, screen_height)
633 local pos1 = pos_from_top_left(1280 - 384 - 16, 720 - 216 - 48, 384, 216, screen_width, screen_height)
635 local pos_fs = { x0 = 0, y0 = 0, x1 = screen_width, y1 = screen_height }
637 if transition_type == NO_TRANSITION then
639 affine_param = { sx = 1.0, sy = 1.0, tx = 0.0, ty = 0.0 } -- Identity.
641 -- Zooming to/from SBS view into or out of a single view.
642 assert(transition_type == ZOOM_TRANSITION)
644 if src_signal == SBS_SIGNAL_NUM then
648 assert(dst_signal == SBS_SIGNAL_NUM)
653 if signal == INPUT0_SIGNAL_NUM then
654 affine_param = find_affine_param(pos0, lerp_pos(pos0, pos_fs, real_t))
655 elseif signal == INPUT1_SIGNAL_NUM then
656 affine_param = find_affine_param(pos1, lerp_pos(pos1, pos_fs, real_t))
660 -- NOTE: input_resolution is not 1-indexed, unlike usual Lua arrays.
661 place_rectangle_with_affine(sbs_scene.input0, pos0, affine_param, screen_width, screen_height, input_resolution[0].width, input_resolution[0].height, hq)
662 place_rectangle_with_affine(sbs_scene.input1, pos1, affine_param, screen_width, screen_height, input_resolution[1].width, input_resolution[1].height, hq)
665 -- Find the transformation that changes the first rectangle to the second one.
666 function find_affine_param(a, b)
667 local sx = (b.x1 - b.x0) / (a.x1 - a.x0)
668 local sy = (b.y1 - b.y0) / (a.y1 - a.y0)
672 tx = b.x0 - a.x0 * sx,
673 ty = b.y0 - a.y0 * sy
677 function place_rectangle_with_affine(input, pos, aff, screen_width, screen_height, input_width, input_height, hq)
678 local x0 = pos.x0 * aff.sx + aff.tx
679 local x1 = pos.x1 * aff.sx + aff.tx
680 local y0 = pos.y0 * aff.sy + aff.ty
681 local y1 = pos.y1 * aff.sy + aff.ty
683 place_rectangle(input, x0, y0, x1, y1, screen_width, screen_height, input_width, input_height, hq)
686 function set_neutral_color(effect, color)
687 effect:set_vec3("neutral_color", color[1], color[2], color[3])
690 function set_neutral_color_from_signal(state, effect, signal)
691 if is_plain_signal(signal) then
692 set_neutral_color(effect, state.neutral_colors[signal - INPUT0_SIGNAL_NUM + 1])
696 function calc_zoom_progress(state, t)
697 if t < state.transition_start then
699 elseif t > state.transition_end then
702 local tt = (t - state.transition_start) / (state.transition_end - state.transition_start)
704 return math.sin(tt * 3.14159265358 * 0.5)
708 function calc_fade_progress(t, transition_start, transition_end)
709 local tt = (t - transition_start) / (transition_end - transition_start)
716 -- Make the fade look maybe a tad more natural, by pumping it
717 -- through a sigmoid function.
719 tt = 1.0 / (1.0 + math.exp(-tt))