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
130 -- Returns the name for each additional channel (starting from 2).
131 -- Called at the start of the program, and then each frame for live
132 -- channels in case they change resolution.
133 function channel_name(channel)
134 local signal_num = channel - 2
135 if is_plain_signal(signal_num) then
136 if last_resolution[signal_num] then
137 return "Input " .. (signal_num + 1) .. " (" .. last_resolution[signal_num].human_readable_resolution .. ")"
139 return "Input " .. (signal_num + 1)
141 elseif signal_num == SBS_SIGNAL_NUM then
142 return "Side-by-side"
143 elseif signal_num == STATIC_SIGNAL_NUM then
144 return "Static picture"
149 -- Returns, given a channel number, which signal it corresponds to (starting from 0).
150 -- Should return -1 if the channel does not correspond to a simple signal
151 -- (one connected to a capture card, or a video input). The information is used for
152 -- whether right-click on the channel should bring up a context menu or not,
153 -- typically containing an input selector, resolution menu etc.
155 -- Called once for each channel, at the start of the program.
156 -- Will never be called for live (0) or preview (1).
157 function channel_signal(channel)
160 elseif channel == 3 then
168 -- Called every frame. Returns the color (if any) to paint around the given
169 -- channel. Returns a CSS color (typically to mark live and preview signals);
170 -- "transparent" is allowed.
171 -- Will never be called for live (0) or preview (1).
172 function channel_color(channel)
173 if state.transition_type ~= NO_TRANSITION then
174 if channel_involved_in(channel, state.transition_src_signal) or
175 channel_involved_in(channel, state.transition_dst_signal) then
179 if channel_involved_in(channel, state.live_signal_num) then
183 if channel_involved_in(channel, state.preview_signal_num) then
189 function channel_involved_in(channel, signal_num)
190 if is_plain_signal(signal_num) then
191 return channel == (signal_num + 2)
193 if signal_num == SBS_SIGNAL_NUM then
194 return (channel == 2 or channel == 3)
196 if signal_num == STATIC_SIGNAL_NUM then
197 return (channel == 5)
203 -- Returns if a given channel supports setting white balance (starting from 2).
204 -- Called only once for each channel, at the start of the program.
205 function supports_set_wb(channel)
206 return is_plain_signal(channel - 2)
210 -- Gets called with a new gray point when the white balance is changing.
211 -- The color is in linear light (not sRGB gamma).
212 function set_wb(channel, red, green, blue)
213 if is_plain_signal(channel - 2) then
214 state.neutral_colors[channel - 2 + 1] = { red, green, blue }
218 function finish_transitions(t)
219 if state.transition_type ~= NO_TRANSITION and t >= state.transition_end then
220 state.live_signal_num = state.transition_dst_signal
221 state.transition_type = NO_TRANSITION
225 function in_transition(t)
226 return t >= state.transition_start and t <= state.transition_end
230 -- Called every frame.
231 function get_transitions(t)
232 if in_transition(t) then
233 -- Transition already in progress, the only thing we can do is really
234 -- cut to the preview. (TODO: Make an “abort” and/or “finish”, too?)
238 finish_transitions(t)
240 if state.live_signal_num == state.preview_signal_num then
241 -- No transitions possible.
245 if (is_plain_signal(state.live_signal_num) or state.live_signal_num == STATIC_SIGNAL_NUM) and
246 (is_plain_signal(state.preview_signal_num) or state.preview_signal_num == STATIC_SIGNAL_NUM) then
247 return {"Cut", "", "Fade"}
251 if state.live_signal_num == SBS_SIGNAL_NUM and is_plain_signal(state.preview_signal_num) then
252 return {"Cut", "Zoom in"}
253 elseif is_plain_signal(state.live_signal_num) and state.preview_signal_num == SBS_SIGNAL_NUM then
254 return {"Cut", "Zoom out"}
260 function swap_preview_live()
261 local temp = state.live_signal_num
262 state.live_signal_num = state.preview_signal_num
263 state.preview_signal_num = temp
266 function start_transition(type_, t, duration)
267 state.transition_start = t
268 state.transition_end = t + duration
269 state.transition_type = type_
270 state.transition_src_signal = state.live_signal_num
271 state.transition_dst_signal = state.preview_signal_num
276 -- Called when the user clicks a transition button.
277 function transition_clicked(num, t)
280 if in_transition(t) then
281 -- Ongoing transition; finish it immediately before the cut.
282 finish_transitions(state.transition_end)
288 finish_transitions(t)
290 if state.live_signal_num == state.preview_signal_num then
295 if is_plain_signal(state.live_signal_num) and is_plain_signal(state.preview_signal_num) then
296 -- We can't zoom between these. Just make a cut.
297 io.write("Cutting from " .. state.live_signal_num .. " to " .. state.live_signal_num .. "\n")
302 if (state.live_signal_num == SBS_SIGNAL_NUM and is_plain_signal(state.preview_signal_num)) or
303 (state.preview_signal_num == SBS_SIGNAL_NUM and is_plain_signal(state.live_signal_num)) then
304 start_transition(ZOOM_TRANSITION, t, 1.0)
307 finish_transitions(t)
310 if (state.live_signal_num ~= state.preview_signal_num) and
311 (is_plain_signal(state.live_signal_num) or
312 state.live_signal_num == STATIC_SIGNAL_NUM) and
313 (is_plain_signal(state.preview_signal_num) or
314 state.preview_signal_num == STATIC_SIGNAL_NUM) then
315 start_transition(FADE_TRANSITION, t, 1.0)
317 -- Fades involving SBS are ignored (we have no scene for it).
323 function channel_clicked(num)
324 state.preview_signal_num = num
327 function setup_fade_input(state, input, signals, signal_num, width, height)
328 if signal_num == STATIC_SIGNAL_NUM then
329 input.input:display(static_image)
330 input.wb_effect:disable()
332 -- We assume this is already correctly scaled at load time.
333 input.resample_effect:disable()
335 input.input:display(signal_num)
336 input.wb_effect:enable()
337 set_neutral_color(input.wb_effect, state.neutral_colors[signal_num - INPUT0_SIGNAL_NUM + 1])
339 if (signals:get_width(signal_num) ~= width or signals:get_height(signal_num) ~= height) then
340 input.resample_effect:enable()
341 input.resample_effect:set_int("width", width)
342 input.resample_effect:set_int("height", height)
344 input.resample_effect:disable()
349 function needs_scale(signals, signal_num, width, height)
350 if signal_num == STATIC_SIGNAL_NUM then
351 -- We assume this is already correctly scaled at load time.
354 assert(is_plain_signal(signal_num))
355 return (signals:get_width(signal_num) ~= width or signals:get_height(signal_num) ~= height)
358 function setup_simple_input(state, signals, signal_num, width, height, hq)
359 simple_scene.input:display(signal_num)
360 if needs_scale(signals, signal_num, width, height) then
362 simple_scene.resample_effect:choose(ResampleEffect) -- High-quality resampling.
364 simple_scene.resample_effect:choose(ResizeEffect) -- Low-quality resampling.
366 simple_scene.resample_effect:set_int("width", width)
367 simple_scene.resample_effect:set_int("height", height)
369 simple_scene.resample_effect:disable() -- No scaling.
371 set_neutral_color_from_signal(state, simple_scene.wb_effect, signal_num)
375 -- Called every frame. Get the scene for displaying at input <num>,
376 -- where 0 is live, 1 is preview, 2 is the first channel to display
377 -- in the bottom bar, and so on up to num_channels()+1. t is the
378 -- current time in seconds. width and height are the dimensions of
379 -- the output, although you can ignore them if you don't need them
380 -- (they're useful if you want to e.g. know what to resample by).
382 -- <signals> is basically an exposed InputState, which you can use to
383 -- query for information about the signals at the point of the current
384 -- frame. In particular, you can call get_width() and get_height()
385 -- for any signal number, and use that to e.g. assist in scene selection.
387 -- You should return scene to use, after having set any parameters you
388 -- want to set (through set_int() etc.). The parameters will be snapshot
389 -- at return time and used during rendering.
390 function get_scene(num, t, width, height, signals)
391 local input_resolution = {}
392 for signal_num=0,1 do
394 width = signals:get_width(signal_num),
395 height = signals:get_height(signal_num),
396 interlaced = signals:get_interlaced(signal_num),
397 is_connected = signals:get_is_connected(signal_num),
398 has_signal = signals:get_has_signal(signal_num),
399 frame_rate_nom = signals:get_frame_rate_nom(signal_num),
400 frame_rate_den = signals:get_frame_rate_den(signal_num),
401 human_readable_resolution = signals:get_human_readable_resolution(signal_num)
404 if res.interlaced then
405 -- Convert height from frame height to field height.
406 -- (Needed for e.g. place_rectangle.)
407 res.height = res.height * 2
409 -- Show field rate instead of frame rate; really for cosmetics only
410 -- (and actually contrary to EBU recommendations, although in line
411 -- with typical user expectations).
412 res.frame_rate_nom = res.frame_rate_nom * 2
415 input_resolution[signal_num] = res
417 last_resolution = input_resolution
419 if num == 0 then -- Live.
420 finish_transitions(t)
421 if state.transition_type == ZOOM_TRANSITION then
422 -- Transition in or out of SBS.
423 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)
424 return sbs_scene.scene
425 elseif state.transition_type == NO_TRANSITION and state.live_signal_num == SBS_SIGNAL_NUM then
427 prepare_sbs_scene(state, 0.0, NO_TRANSITION, 0, SBS_SIGNAL_NUM, width, height, input_resolution, true)
428 return sbs_scene.scene
429 elseif state.transition_type == FADE_TRANSITION then
430 setup_fade_input(state, fade_scene.input0, signals, state.transition_src_signal, width, height)
431 setup_fade_input(state, fade_scene.input1, signals, state.transition_dst_signal, width, height)
433 local tt = calc_fade_progress(t, state.transition_start, state.transition_end)
434 fade_scene.mix_effect:set_float("strength_first", 1.0 - tt)
435 fade_scene.mix_effect:set_float("strength_second", tt)
437 return fade_scene.scene
438 elseif is_plain_signal(state.live_signal_num) then
439 setup_simple_input(state, signals, state.live_signal_num, width, height, true)
440 return simple_scene.scene
441 elseif state.live_signal_num == STATIC_SIGNAL_NUM then -- Static picture.
447 if num == 1 then -- Preview.
448 num = state.preview_signal_num + 2
451 -- Individual preview inputs.
452 if is_plain_signal(num - 2) then
453 setup_simple_input(state, signals, num - 2, width, height, false)
454 return simple_scene.scene
456 if num == SBS_SIGNAL_NUM + 2 then
457 prepare_sbs_scene(state, 0.0, NO_TRANSITION, 0, SBS_SIGNAL_NUM, width, height, input_resolution, false)
458 return sbs_scene.scene
460 if num == STATIC_SIGNAL_NUM + 2 then
465 function place_rectangle(input, x0, y0, x1, y1, screen_width, screen_height, input_width, input_height, hq)
466 input.padding_effect:set_int("width", screen_width)
467 input.padding_effect:set_int("height", screen_height)
470 if x0 > screen_width or x1 < 0.0 or y0 > screen_height or y1 < 0.0 then
471 input.resample_effect:choose(ResizeEffect) -- Low-quality resizing.
472 input.resample_effect:set_int("width", 1)
473 input.resample_effect:set_int("height", 1)
474 input.padding_effect:set_int("left", screen_width + 100)
475 input.padding_effect:set_int("top", screen_height + 100)
486 srcx0 = -x0 / (x1 - x0)
490 srcy0 = -y0 / (y1 - y0)
493 if x1 > screen_width then
494 srcx1 = (screen_width - x0) / (x1 - x0)
497 if y1 > screen_height then
498 srcy1 = (screen_height - y0) / (y1 - y0)
503 -- High-quality resampling. Go for the actual effect (returned by choose())
504 -- since we want to set zoom_*, which will give an error if set on ResizeEffect.
505 local resample_effect = input.resample_effect:choose(ResampleEffect)
507 local x_subpixel_offset = x0 - math.floor(x0)
508 local y_subpixel_offset = y0 - math.floor(y0)
510 -- Resampling must be to an integral number of pixels. Round up,
511 -- and then add an extra pixel so we have some leeway for the border.
512 local width = math.ceil(x1 - x0) + 1
513 local height = math.ceil(y1 - y0) + 1
514 resample_effect:set_int("width", width)
515 resample_effect:set_int("height", height)
517 -- Correct the discrepancy with zoom. (This will leave a small
518 -- excess edge of pixels and subpixels, which we'll correct for soon.)
519 local zoom_x = (x1 - x0) / (width * (srcx1 - srcx0))
520 local zoom_y = (y1 - y0) / (height * (srcy1 - srcy0))
521 resample_effect:set_float("zoom_x", zoom_x)
522 resample_effect:set_float("zoom_y", zoom_y)
523 resample_effect:set_float("zoom_center_x", 0.0)
524 resample_effect:set_float("zoom_center_y", 0.0)
526 -- Padding must also be to a whole-pixel offset.
527 input.padding_effect:set_int("left", math.floor(x0))
528 input.padding_effect:set_int("top", math.floor(y0))
530 -- Correct _that_ discrepancy by subpixel offset in the resampling.
531 resample_effect:set_float("left", srcx0 * input_width - x_subpixel_offset / zoom_x)
532 resample_effect:set_float("top", srcy0 * input_height - y_subpixel_offset / zoom_y)
534 -- Finally, adjust the border so it is exactly where we want it.
535 input.padding_effect:set_float("border_offset_left", x_subpixel_offset)
536 input.padding_effect:set_float("border_offset_right", x1 - (math.floor(x0) + width))
537 input.padding_effect:set_float("border_offset_top", y_subpixel_offset)
538 input.padding_effect:set_float("border_offset_bottom", y1 - (math.floor(y0) + height))
540 -- Lower-quality simple resizing.
541 input.resample_effect:choose(ResizeEffect)
543 local width = round(x1 - x0)
544 local height = round(y1 - y0)
545 input.resample_effect:set_int("width", width)
546 input.resample_effect:set_int("height", height)
548 -- Padding must also be to a whole-pixel offset.
549 input.padding_effect:set_int("left", math.floor(x0))
550 input.padding_effect:set_int("top", math.floor(y0))
552 -- No subpixel stuff.
553 input.padding_effect:set_float("border_offset_left", 0.0)
554 input.padding_effect:set_float("border_offset_right", 0.0)
555 input.padding_effect:set_float("border_offset_top", 0.0)
556 input.padding_effect:set_float("border_offset_bottom", 0.0)
560 -- This is broken, of course (even for positive numbers), but Lua doesn't give us access to real rounding.
562 return math.floor(x + 0.5)
565 function lerp(a, b, t)
566 return a + (b - a) * t
569 function lerp_pos(a, b, t)
571 x0 = lerp(a.x0, b.x0, t),
572 y0 = lerp(a.y0, b.y0, t),
573 x1 = lerp(a.x1, b.x1, t),
574 y1 = lerp(a.y1, b.y1, t)
578 function pos_from_top_left(x, y, width, height, screen_width, screen_height)
579 local xs = screen_width / 1280.0
580 local ys = screen_height / 720.0
584 x1 = round(xs * (x + width)),
585 y1 = round(ys * (y + height))
589 function prepare_sbs_scene(state, t, transition_type, src_signal, dst_signal, screen_width, screen_height, input_resolution, hq)
590 set_neutral_color(sbs_scene.input0.wb_effect, state.neutral_colors[1])
591 set_neutral_color(sbs_scene.input1.wb_effect, state.neutral_colors[2])
593 -- First input is positioned (16,48) from top-left.
594 -- Second input is positioned (16,48) from the bottom-right.
595 local pos0 = pos_from_top_left(16, 48, 848, 477, screen_width, screen_height)
596 local pos1 = pos_from_top_left(1280 - 384 - 16, 720 - 216 - 48, 384, 216, screen_width, screen_height)
598 local pos_fs = { x0 = 0, y0 = 0, x1 = screen_width, y1 = screen_height }
600 if transition_type == NO_TRANSITION then
602 affine_param = { sx = 1.0, sy = 1.0, tx = 0.0, ty = 0.0 } -- Identity.
604 -- Zooming to/from SBS view into or out of a single view.
605 assert(transition_type == ZOOM_TRANSITION)
607 if src_signal == SBS_SIGNAL_NUM then
611 assert(dst_signal == SBS_SIGNAL_NUM)
616 if signal == INPUT0_SIGNAL_NUM then
617 affine_param = find_affine_param(pos0, lerp_pos(pos0, pos_fs, real_t))
618 elseif signal == INPUT1_SIGNAL_NUM then
619 affine_param = find_affine_param(pos1, lerp_pos(pos1, pos_fs, real_t))
623 -- NOTE: input_resolution is not 1-indexed, unlike usual Lua arrays.
624 place_rectangle_with_affine(sbs_scene.input0, pos0, affine_param, screen_width, screen_height, input_resolution[0].width, input_resolution[0].height, hq)
625 place_rectangle_with_affine(sbs_scene.input1, pos1, affine_param, screen_width, screen_height, input_resolution[1].width, input_resolution[1].height, hq)
628 -- Find the transformation that changes the first rectangle to the second one.
629 function find_affine_param(a, b)
630 local sx = (b.x1 - b.x0) / (a.x1 - a.x0)
631 local sy = (b.y1 - b.y0) / (a.y1 - a.y0)
635 tx = b.x0 - a.x0 * sx,
636 ty = b.y0 - a.y0 * sy
640 function place_rectangle_with_affine(input, pos, aff, screen_width, screen_height, input_width, input_height, hq)
641 local x0 = pos.x0 * aff.sx + aff.tx
642 local x1 = pos.x1 * aff.sx + aff.tx
643 local y0 = pos.y0 * aff.sy + aff.ty
644 local y1 = pos.y1 * aff.sy + aff.ty
646 place_rectangle(input, x0, y0, x1, y1, screen_width, screen_height, input_width, input_height, hq)
649 function set_neutral_color(effect, color)
650 effect:set_vec3("neutral_color", color[1], color[2], color[3])
653 function set_neutral_color_from_signal(state, effect, signal)
654 if is_plain_signal(signal) then
655 set_neutral_color(effect, state.neutral_colors[signal - INPUT0_SIGNAL_NUM + 1])
659 function calc_zoom_progress(state, t)
660 if t < state.transition_start then
662 elseif t > state.transition_end then
665 local tt = (t - state.transition_start) / (state.transition_end - state.transition_start)
667 return math.sin(tt * 3.14159265358 * 0.5)
671 function calc_fade_progress(t, transition_start, transition_end)
672 local tt = (t - transition_start) / (transition_end - transition_start)
679 -- Make the fade look maybe a tad more natural, by pumping it
680 -- through a sigmoid function.
682 tt = 1.0 / (1.0 + math.exp(-tt))