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 preview_signal_num = 1
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
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
34 function make_sbs_input(scene)
36 input = scene:add_input(0), -- Live inputs only.
37 resample_effect = scene:add_effect({ResampleEffect.new(), ResizeEffect.new()}),
38 wb_effect = scene:add_white_balance(),
39 padding_effect = scene:add_effect(IntegralPaddingEffect.new())
43 -- The main live scene.
44 function make_sbs_scene()
45 local scene = Scene.new(16, 9)
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)
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)
55 scene:add_effect(OverlayEffect.new(), input0.padding_effect, input1.padding_effect)
64 local sbs_scene = make_sbs_scene()
66 function make_fade_input(scene)
68 input = scene:add_input(),
69 resample_effect = scene:add_optional_effect(ResampleEffect.new()), -- Activated if scaling.
70 wb_effect = scene:add_white_balance() -- Activated for video inputs.
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.
87 mix_effect = mix_effect
90 local fade_scene = make_fade_scene()
92 -- A scene to show a single input on screen.
93 local scene = Scene.new(16, 9)
94 local simple_scene = {
96 input = scene:add_input(),
97 resample_effect = scene:add_effect({ResampleEffect.new(), ResizeEffect.new(), IdentityEffect.new()}),
98 wb_effect = scene:add_white_balance()
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()
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)
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)
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)
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")
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
139 if channel_involved_in(channel, state.live_signal_num) then
143 if channel_involved_in(channel, state.preview_signal_num) then
149 function is_plain_signal(num)
150 return num == INPUT0_SIGNAL_NUM or num == INPUT1_SIGNAL_NUM
153 function channel_involved_in(channel, signal_num)
154 if is_plain_signal(signal_num) then
155 return channel == (signal_num + 2)
157 if signal_num == SBS_SIGNAL_NUM then
158 return (channel == 2 or channel == 3)
160 if signal_num == STATIC_SIGNAL_NUM then
161 return (channel == 5)
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
173 function in_transition(t)
174 return t >= state.transition_start and t <= state.transition_end
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?)
186 finish_transitions(t)
188 if state.live_signal_num == state.preview_signal_num then
189 -- No transitions possible.
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"}
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"}
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
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
224 -- Called when the user clicks a transition button.
225 function transition_clicked(num, t)
228 if in_transition(t) then
229 -- Ongoing transition; finish it immediately before the cut.
230 finish_transitions(state.transition_end)
236 finish_transitions(t)
238 if state.live_signal_num == state.preview_signal_num then
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")
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)
255 finish_transitions(t)
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)
265 -- Fades involving SBS are ignored (we have no scene for it).
271 function channel_clicked(num)
272 state.preview_signal_num = num
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()
280 -- We assume this is already correctly scaled at load time.
281 input.resample_effect:disable()
283 input.input:display(signal_num)
284 input.wb_effect:enable()
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)
291 input.resample_effect:disable()
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.
301 assert(is_plain_signal(signal_num))
302 return (signals:get_width(signal_num) ~= width or signals:get_height(signal_num) ~= height)
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
309 simple_scene.resample_effect:choose(ResampleEffect) -- High-quality resampling.
311 simple_scene.resample_effect:choose(ResizeEffect) -- Low-quality resampling.
313 simple_scene.resample_effect:set_int("width", width)
314 simple_scene.resample_effect:set_int("height", height)
316 simple_scene.resample_effect:disable() -- No scaling.
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).
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.)
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
342 width = signals:get_frame_width(signal_num),
343 height = signals:get_frame_height(signal_num),
345 input_resolution[signal_num] = res
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 .. ")")
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
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)
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)
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.
379 if num == 1 then -- Preview.
380 num = state.preview_signal_num + 2
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
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
392 if num == STATIC_SIGNAL_NUM + 2 then
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)
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)
418 srcx0 = -x0 / (x1 - x0)
422 srcy0 = -y0 / (y1 - y0)
425 if x1 > screen_width then
426 srcx1 = (screen_width - x0) / (x1 - x0)
429 if y1 > screen_height then
430 srcy1 = (screen_height - y0) / (y1 - y0)
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)
439 local x_subpixel_offset = x0 - math.floor(x0)
440 local y_subpixel_offset = y0 - math.floor(y0)
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)
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)
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))
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)
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))
472 -- Lower-quality simple resizing.
473 input.resample_effect:choose(ResizeEffect)
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)
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))
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)
492 -- This is broken, of course (even for positive numbers), but Lua doesn't give us access to real rounding.
494 return math.floor(x + 0.5)
497 function lerp(a, b, t)
498 return a + (b - a) * t
501 function lerp_pos(a, b, t)
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)
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
516 x1 = round(xs * (x + width)),
517 y1 = round(ys * (y + height))
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)
527 local pos_fs = { x0 = 0, y0 = 0, x1 = screen_width, y1 = screen_height }
529 if transition_type == NO_TRANSITION then
531 affine_param = { sx = 1.0, sy = 1.0, tx = 0.0, ty = 0.0 } -- Identity.
533 -- Zooming to/from SBS view into or out of a single view.
534 assert(transition_type == ZOOM_TRANSITION)
536 if src_signal == SBS_SIGNAL_NUM then
540 assert(dst_signal == SBS_SIGNAL_NUM)
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))
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)
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)
564 tx = b.x0 - a.x0 * sx,
565 ty = b.y0 - a.y0 * sy
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
575 place_rectangle(input, x0, y0, x1, y1, screen_width, screen_height, input_width, input_height, hq)
578 function calc_zoom_progress(state, t)
579 if t < state.transition_start then
581 elseif t > state.transition_end then
584 local tt = (t - state.transition_start) / (state.transition_end - state.transition_start)
586 return math.sin(tt * 3.14159265358 * 0.5)
590 function calc_fade_progress(t, transition_start, transition_end)
591 local tt = (t - transition_start) / (transition_end - transition_start)
598 -- Make the fade look maybe a tad more natural, by pumping it
599 -- through a sigmoid function.
601 tt = 1.0 / (1.0 + math.exp(-tt))