1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
|
local utils = require "mp.utils"
local msg = require "mp.msg"
--[[ Utility functions ]]------------------------------------------------------
-- Change this to `msg.info' to see debug messages on mpv output.
local msg_debug = msg.debug
-- Count elements in a table.
function count(tbl)
local n = 0
for _ in pairs(tbl) do n = n + 1 end
return n
end
-- Make a copy a table (but not of its elements).
function shallow_copy(orig)
local copy
if type(orig) == "table" then
copy = {}
for key, val in pairs(orig) do
copy[key] = val
end
else
copy = orig
end
return copy
end
-- Round to nearest integer.
math.round = function(x)
return math.floor(x + 0.5)
end
--[[ Time functions ]]---------------------------------------------------------
-- Only used for rounding purposes: having the exact value is not necessary.
-- Default to 25fps (i.e., 40ms).
local frame_duration = 40
-- Get frame duration from container-fps property.
function update_frame_duration()
local fps = mp.get_property_number("container-fps") or 25
frame_duration = 1000 / (fps > 0 and fps or 25)
end
mp.register_event("file-loaded", update_frame_duration)
-- Round time position (in ms) to a multiple of frame_duration.
function round_to_frame(time)
return math.round(math.round(time / frame_duration) * frame_duration)
end
-- Time position of previous frame.
function prev_frame(time)
return round_to_frame(time - frame_duration)
end
-- Format time position (in ms) as hh:mm:ss.msc.
function time_to_string(time)
return string.format("%02d:%02d:%06.3f",
math.floor(time / 3600000),
math.floor(time / 60000) % 60,
(time % 60000) / 1000)
end
-- Retrieve the time-pos property (in ms) and round it to a frame position.
function get_time_pos()
local t = (mp.get_property_number("time-pos") or 0) * 1000 - frame_duration
return round_to_frame(t)
end
-- Seek playback to given time position (in ms).
function set_time_pos(time)
msg_debug("seek to: " .. time_to_string(time))
local t = round_to_frame(time + frame_duration) / 1000
mp.set_property_number("time-pos", t)
end
-- Retrieve the duration property (in ms) and round it to a frame position.
function get_duration()
local t = (mp.get_property_number("duration") or 0) * 1000 - frame_duration
return round_to_frame(t)
end
--[[ Global data and state variables ]]----------------------------------------
local initial_segment, segments, moments, preconditions, segment_groups
local state = { active = false }
--[[ Apply impression ]]-------------------------------------------------------
function apply_impression(impression)
if impression == nil then
return
elseif impression.type == "userState" then
for var, val in pairs(impression.data.persistent or {}) do
msg_debug("set variable: " .. var .. " = " ..
utils.to_string(val) .. " (was: " ..
utils.to_string(state.vars[var]) .. ")")
state.vars[var] = val
end
else
msg.error("Invalid type of impression data: " ..
utils.to_string(impression.type))
end
end
--[[ Evaluate precondition ]]--------------------------------------------------
function eval_precondition(cond)
if cond == nil then
return true
elseif type(cond) ~= "table" then
return cond
elseif #cond == 0 then
msg.error("Empty precondition expression")
elseif cond[1] == "not" and #cond == 2 then
return not eval_precondition(cond[2])
elseif cond[1] == "and" then
for i = 2, #cond do
if not eval_precondition(cond[i]) then return false end
end
return true
elseif cond[1] == "or" then
for i = 2, #cond do
if eval_precondition(cond[i]) then return true end
end
return false
elseif cond[1] == "eql" and #cond == 3 then
return eval_precondition(cond[2]) == eval_precondition(cond[3])
elseif cond[1] == "persistentState" and #cond == 2 then
return state.vars[cond[2]]
else
msg.error("Invalid precondition: " .. utils.to_string(cond))
end
return false
end
--[[ Format strings for displaying choices on OSD ]]---------------------------
function format_osd_choices(choices)
local osd_choices = {}
for i = 1, #choices do
local str = ""
for j, ch in ipairs(choices) do
local b = i == j
str = str .. "{\\fscx70\\fscy70\\an5"
.. "\\pos(" .. ((2*j-1) / (2*#choices)) .. ",0.048)}"
.. (i == j and "{\\c&HFFFFFF&}[ " or "{\\c&H7F7F7F&}")
.. ch.text
.. (i == j and " ]" or "")
.. "\n"
end
table.insert(osd_choices, str)
end
return osd_choices
end
--[[ Update OSD according to current state ]]----------------------------------
function update_osd()
-- Multiple-choice input.
if state.osd_choices and type(state.osd_input) == "number" then
mp.set_osd_ass(1, 1, state.osd_choices[state.osd_input])
-- Code entry input.
elseif state.osd_prompt and type(state.osd_input) == "string" then
mp.set_osd_ass(1, 1, "{\\fscx70\\fscy70\\an4\\pos(0.02,0.048)}" ..
state.osd_prompt .. " " .. state.osd_input .. "_\n")
-- Nothing to display.
else
mp.set_osd_ass(0, 0, "")
end
end
--[[ Navigation and input control functions ]]---------------------------------
-- Dummy function.
function cmd_nop()
end
-- Seek forward.
function cmd_fwd(sec, skip)
return function()
process_events(get_time_pos() + sec * 1000, { no_skip = not skip,
ffwd = true })
end
end
-- Seek backward.
function cmd_bwd(sec, skip)
return function()
rewind(sec * 1000, { no_skip = not skip })
end
end
-- Select next choice.
function cmd_input_next()
if state.osd_input < #state.osd_choices then
state.osd_input = state.osd_input + 1
else
state.osd_input = 1
end
update_osd()
end
-- Select previous choice.
function cmd_input_prev()
if state.osd_input > 1 then
state.osd_input = state.osd_input - 1
else
state.osd_input = #state.osd_choices
end
update_osd()
end
-- Input character.
function cmd_input_char(char)
return function()
state.osd_input = state.osd_input .. char
update_osd()
end
end
-- Delete last input character.
function cmd_input_backspace()
if string.len(state.osd_input) > 0 then
state.osd_input = string.sub(state.osd_input, 1, -2)
update_osd()
end
end
-- Submit current input.
function cmd_input_submit()
state.osd_choices = nil
state.osd_prompt = nil
table.insert(state.events, 1, { time = get_time_pos(), type = "submit",
mom_id = state.osd_mom_id })
update_osd()
update_controls()
end
--[[ Key mappings for control functions ]]-------------------------------------
-- Merge key mappings.
-- Mappings in map1 take precedence over those in map2.
function mapping_merge(map1, map2)
for key, ctrl in pairs(map2) do
map1[key] = map1[key] or ctrl
end
end
-- Key mapping for navigation.
local nav_mapping = {
["RIGHT"] = { "iv-nav-right", cmd_fwd( 10), {repeatable=true} },
["SHIFT+RIGHT"] = { "iv-nav-sright", cmd_fwd( 10,true), {repeatable=true} },
["LEFT"] = { "iv-nav-left", cmd_bwd( 10), {repeatable=true} },
["SHIFT+LEFT"] = { "iv-nav-sleft", cmd_bwd( 10,true), {repeatable=true} },
["UP"] = { "iv-nav-up", cmd_fwd( 60), {repeatable=true} },
["SHIFT+UP"] = { "iv-nav-sup", cmd_fwd( 60,true), {repeatable=true} },
["DOWN"] = { "iv-nav-down", cmd_bwd( 60), {repeatable=true} },
["SHIFT+DOWN"] = { "iv-nav-sdown", cmd_bwd( 60,true), {repeatable=true} },
["PGUP"] = { "iv-nav-pgup", cmd_fwd(1/0), {repeatable=true} },
["SHIFT+PGUP"] = { "iv-nav-spgup", cmd_fwd(600,true), {repeatable=true} },
["PGDWN"] = { "iv-nav-pgdwn", cmd_bwd(600), {repeatable=true} },
["SHIFT+PGDWN"] = { "iv-nav-spgdwn", cmd_bwd(600,true), {repeatable=true} },
}
-- Control mapping for multiple-choice input.
local input_choice_mapping = {
["RIGHT"] = { "iv-input-right", cmd_input_next, { repeatable = true } },
["LEFT"] = { "iv-input-left", cmd_input_prev, { repeatable = true } },
["ENTER"] = { "iv-input-enter", cmd_input_submit },
}
for _, key in ipairs({ "UP", "DOWN", "PGUP", "PGDWN" }) do
input_choice_mapping[key] = { "iv-nop-"..key, cmd_nop }
end
mapping_merge(input_choice_mapping, nav_mapping)
-- Control mapping for code entry input.
local input_code_mapping = {
["BS"] = { "iv-input-bs", cmd_input_backspace, { repeatable = true } },
["ENTER"] = { "iv-input-enter", cmd_input_submit },
}
for _, key in ipairs({ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" }) do
input_code_mapping[key] = { "iv-input-" ..key, cmd_input_char(key) }
input_code_mapping["KP"..key] = { "iv-input-kp"..key, cmd_input_char(key) }
end
for _, key in ipairs({ "RIGHT", "LEFT", "UP", "DOWN", "PGUP", "PGDWN" }) do
input_code_mapping[key] = { "iv-nop-" ..key, cmd_nop }
end
mapping_merge(input_code_mapping, nav_mapping)
--[[ Update controls according to current state ]]-----------------------------
local current_mapping
function update_controls()
local new_mapping
if state.active then
-- Multiple-choice input.
if state.osd_choices and type(state.osd_input) == "number" then
new_mapping = input_choice_mapping
-- Code entry input.
elseif state.osd_prompt and type(state.osd_input) == "string" then
new_mapping = input_code_mapping
-- No input: only navigation controls.
else
new_mapping = nav_mapping
end
end
if new_mapping ~= current_mapping then
-- Remove current control bindings.
for key, ctrl in pairs(current_mapping or {}) do
mp.remove_key_binding(ctrl[1])
end
-- Register new control bindings.
current_mapping = new_mapping
for key, ctrl in pairs(current_mapping or {}) do
mp.add_forced_key_binding(key, table.unpack(ctrl))
end
end
end
--[[ Deactivate interactive video playback ]]----------------------------------
function deactivate()
mp.unregister_event(on_tick)
state = { active = false }
update_osd()
update_controls()
end
--[[ Retrieve first valid segment from segment group ]]------------------------
function resolve_segment_group(group_id)
for _, seg_id in ipairs(segment_groups[group_id] or {}) do
if type(seg_id) == "table" and seg_id.segmentGroup then
seg_id = resolve_segment_group(seg_id.segmentGroup)
if seg_id then return seg_id end
else
local cond_id = seg_id
if type(seg_id) == "table" then
cond_id = seg_id.precondition
seg_id = seg_id.segment
end
if eval_precondition(preconditions[cond_id]) then
return seg_id
end
end
end
return nil
end
--[[ Load segment and schedule all related events in the event queue ]]--------
function load_segment(seg_id)
msg_debug("load segment: " .. seg_id)
state.seg_id = seg_id
state.events = {}
-- Add moment-related events.
for i, mom in ipairs(moments[state.seg_id] or {}) do
table.insert(state.events, { time = mom.startMs,
type = "start", mom_id = i })
table.insert(state.events, { time = prev_frame(mom.endMs),
type = "end", mom_id = i })
if string.sub(mom.type, 1, 6) == "scene:" then
table.insert(state.events, { time = mom.uiDisplayMS,
type = "display", mom_id = i })
table.insert(state.events, { time = prev_frame(mom.uiHideMS),
type = "hide", mom_id = i })
end
end
-- Sort moment-related events.
table.sort(state.events, function(ev1, ev2)
if ev1.time ~= ev2.time or ev1.mom_id ~= ev2.mom_id then
return ev1.time < ev2.time
elseif ev1.type ~= ev2.type then
return ev1.type == "start" or ev2.type == "end" or
(ev1.type == "display" and ev2.type == "hide")
else
return false
end
end)
-- Add segment start and end events.
local seg = segments[state.seg_id]
table.insert(state.events, 1, { time = seg.startTimeMs,
type = "start_seg" })
table.insert(state.events, { time = prev_frame(seg.endTimeMs or
get_duration()),
type = "end_seg" })
end
--[[ Process all pending events until given (or current) time position ]]------
function process_events(time, flags)
time = time or get_time_pos()
flags = flags or {}
local seek = flags.ffwd
if not state.events then return end
while #state.events > 0 and time >= state.events[1].time do
-- Pop first event from queue.
local ev = state.events[1]
table.remove(state.events, 1)
msg_debug("process event: " .. time_to_string(ev.time) .. " " ..
string.format("%-10s", ev.type) ..
state.seg_id .. (ev.mom_id and ("/" .. ev.mom_id) or "") ..
((ev.type == "submit" or ev.type == "end") and state.osd_input
and (" (" .. utils.to_string(state.osd_input) .. ")") or ""))
local next_seg_id = nil
-- Moment-related event?
if ev.mom_id then
local mom = moments[state.seg_id][ev.mom_id]
-- Start of new moment?
if ev.type == "start" then
-- Check precondition, and remove all moments related to this event
-- if it is not satisfied.
if not eval_precondition(mom.precondition) then
for i = #state.events, 1, -1 do
if state.events[i].mom_id == ev.mom_id then
table.remove(state.events, i)
end
end
else
apply_impression(mom.impressionData)
end
-- Scene-related event?
elseif string.sub(mom.type, 1, 6) == "scene:" then
-- Display user input controls.
if ev.type == "display" then
state.osd_mom_id = ev.mom_id
if mom.config.hasMultipleChoiceInput then
state.osd_prompt = "Enter code:"
state.osd_input = ""
else
state.osd_choices = format_osd_choices(mom.choices)
state.osd_input = mom.defaultChoiceIndex + 1
end
if state.hist[state.hist_idx].inputs[ev.mom_id] then
state.osd_input = state.hist[state.hist_idx].inputs[ev.mom_id]
end
if flags.no_skip and time > ev.time then
time = ev.time
seek = true
end
-- Hide user input controls.
elseif ev.type == "hide" then
state.osd_choices = nil
state.osd_prompt = nil
state.osd_mom_id = nil
-- Select branch according to user input.
elseif ev.type == "end" or
(ev.type == "submit" and
not mom.config.disableImmediateSceneTransition) then
-- Record input in history.
-- Clear all forward history if input different from recorded one.
local hist = state.hist[state.hist_idx]
if state.osd_input ~= hist.inputs[ev.mom_id] then
hist.inputs[ev.mom_id] = state.osd_input
for i, mi in ipairs(moments[state.seg_id]) do
if string.sub(mi.type, 1, 6) == "scene:" and
mi.startMs >= mom.endMs then
hist.inputs[i] = nil
end
end
for i = #state.hist, state.hist_idx+1, -1 do
state.hist[i] = nil
end
end
-- Find corresponding choice.
local choice
if mom.config.hasMultipleChoiceInput then
for _, ch in ipairs(mom.choices) do
if not ch.code or state.osd_input == ch.code then
choice = ch
break
end
end
if not choice then
msg.error("No choice available for input '" .. state.osd_input ..
"' in moment " .. state.seg_id .. "/" .. ev.mom_id)
deactivate()
return
end
else
choice = mom.choices[state.osd_input]
end
apply_impression(choice.impressionData)
-- Select next segment accordingly.
if not (mom.trackingInfo and
mom.trackingInfo.optionType == "fakeOption") then
next_seg_id = choice.segmentId or resolve_segment_group(choice.sg)
if not next_seg_id then
msg.error("No segment for choice '" .. choice.id .. "' " ..
"of moment " .. state.seg_id .. "/" .. ev.mom_id)
deactivate()
return
end
end
state.osd_input = nil
end
end
-- Start of current segment?
elseif ev.type == "start_seg" then
state.hist_idx = state.hist_idx + 1
if not state.hist[state.hist_idx] then
state.hist[state.hist_idx] = { seg_id = state.seg_id,
vars = shallow_copy(state.vars),
inputs = {} }
end
-- End of current segment?
elseif ev.type == "end_seg" then
next_seg_id = resolve_segment_group(state.seg_id) or
next(segments[state.seg_id].next)
if not next_seg_id then
msg.debug("No segment after " .. state.seg_id .. "; " ..
"assuming end of video")
deactivate()
mp.commandv("playlist-next", "force")
return
end
end
-- Load next segment?
if next_seg_id then
local cur_seg = segments[state.seg_id]
local next_seg = segments[next_seg_id]
-- If next segment does not directly follow current segment,
-- we need to jump.
if next_seg.startTimeMs ~= cur_seg.endTimeMs then
if flags.ffwd then
time = next_seg.startTimeMs + time - prev_frame(cur_seg.endTimeMs)
else
time = next_seg.startTimeMs
end
seek = true
-- Also jump if skipping end of current segment.
elseif time < next_seg.startTimeMs then
time = next_seg.startTimeMs
seek = true
end
load_segment(next_seg_id)
end
end
-- Jump to current time position, if required.
if seek then
set_time_pos(time)
end
-- Update OSD and controls according to current state.
update_osd()
update_controls()
end
--[[ Seek backwards and rewind history ]]--------------------------------------
function rewind(delay, flags)
flags = flags or {}
if state.hist_idx == 0 then return end
local time = get_time_pos()
local seg_id = state.hist[state.hist_idx].seg_id
local first = true
-- Keep rewinding from segment to segment until delay is consumed.
while delay > 0 and (first or state.hist_idx > 1) do
if first then
first = false
else
state.hist_idx = state.hist_idx - 1
seg_id = state.hist[state.hist_idx].seg_id
time = segments[seg_id].endTimeMs
end
-- If in no-skip mode, find previous scene, if any.
if flags.no_skip then
local prev_mom
for _, mom in ipairs(moments[seg_id]) do
if string.sub(mom.type, 1, 6) == "scene:" and
time - delay <= mom.uiDisplayMS and mom.uiDisplayMS < time and
(not prev_mom or prev_mom.uiDisplayMS < mom.uiDisplayMS) then
prev_mom = mom
end
end
if prev_mom then
time = prev_mom.uiDisplayMS
delay = 0
break
end
end
-- Rewind until start of current segment.
delay = delay - time + segments[seg_id].startTimeMs
time = segments[seg_id].startTimeMs
end
-- Adjust actual time.
if delay < 0 then
time = time - delay
end
-- Load current segment and state variables from history.
load_segment(seg_id)
shallow_copy(state.vars, state.hist[state.hist_idx].vars)
state.hist_idx = state.hist_idx - 1
state.osd_choices = nil
state.osd_prompt = nil
-- Re-run current segment's history forward until actual time.
process_events(time, { ffwd = true })
end
--[[ Load and check JSON data files, if any, on file load ]]-------------------
function on_start_file(_)
deactivate()
-- Look for JSON data files.
local dir = utils.split_path(mp.get_property("path"))
local base = mp.get_property("filename/no-ext")
local seg = utils.join_path(dir, base .. ".seg.json")
local ivm = utils.join_path(dir, base .. ".ivm.json")
if not (utils.file_info(seg) and utils.file_info(ivm)) then
return
end
msg.info("Found JSON data files for interactive video playback:\n" ..
" seg: " .. seg .. "\n" ..
" ivm: " .. ivm)
-- Read and parse JSON data files.
local file
file = io.open(seg, "r")
if not file then
msg.error("Cannot read from " .. seg)
return
end
seg = utils.parse_json(file:read("*a"))
file:close()
file = io.open(ivm, "r")
if not file then
msg.error("Cannot read from " .. ivm)
return
end
ivm = utils.parse_json(file:read("*a"))
file:close()
-- Initialize data from JSON structures.
segments = seg.segments
initial_segment = seg.initialSegment
local video_id = "" .. (seg.viewableId or "")
if not segments then
msg.error( "segments not found in JSON data files")
return
elseif not initial_segment then
msg.error("initialSegment not found in JSON data files")
return
elseif not video_id then
msg.error( "viewableId not found in JSON data files")
return
end
if ivm.videos and ivm.videos[video_id] and
ivm.videos[video_id].interactiveVideoMoments then
ivm = ivm.videos[video_id].interactiveVideoMoments.value
else
ivm = nil
end
if not ivm then
msg.error("interactiveVideoMoments not found in JSON data files")
return
end
moments = ivm.momentsBySegment
preconditions = ivm.preconditions
segment_groups = ivm.segmentGroups
state.vars = shallow_copy(ivm.stateHistory)
if not moments then
msg.error("momentsBySegment not found in JSON data files")
return
elseif not preconditions then
msg.error( "preconditions not found in JSON data files")
return
elseif not segment_groups then
msg.error( "segmentGroups not found in JSON data files")
return
elseif not state.vars then
msg.error( "stateHistory not found in JSON data files")
return
end
-- Sanity checks.
local fail = false
-- Check segment history.
if not (ivm.segmentHistory and #ivm.segmentHistory == 1 and
ivm.segmentHistory[1] == initial_segment) then
msg.error("Invalid segmentHistory" ..
": expected " .. utils.to_string({ initial_segment }) ..
", got " .. utils.to_string(ivm.segmentHistory))
fail = true
end
-- Check segments.
for k, seg in pairs(segments) do
if (seg.endTimeMs or 1/0) <= seg.startTimeMs then
msg.error("Segment " .. k .. " ends before it starts")
fail = true
end
local has_scene = false
for i, mom in ipairs(moments[k] or {}) do
if string.sub(mom.type, 1, 6) == "scene:" and
not (mom.trackingInfo and
mom.trackingInfo.optionType == "fakeOption") then
has_scene = true
break
end
end
if count(seg.next) > 1 and not has_scene and not segment_groups[k] then
msg.error("Segment " .. k .. " requires a branching moment " ..
"or a segment group")
fail = true
end
end
-- Check moments.
local n_moments = 0
for k, ml in pairs(moments) do
n_moments = n_moments + #ml
for i, mi in ipairs(ml) do
local ki = k .. "/" .. i
if mi.endMs <= mi.startMs then
msg.error("Moment " .. ki .. " ends before it starts")
fail = true
end
if mi.startMs < segments[k].startTimeMs then
msg.error("Moment " .. ki .. " starts before its segment does")
fail = true
end
if mi.endMs > (segments[k].endTimeMs or 1/0) then
msg.error("Moment " .. ki .. " ends after its segment does")
fail = true
end
-- Scene moments.
if string.sub(mi.type, 1, 6) == "scene:" then
if not mi.choices then
msg.error("Moment " .. ki .. " has no branching choices")
fail = true
end
if not (mi.uiDisplayMS and mi.uiHideMS) then
msg.error("Moment " .. ki .. " has no display interval")
fail = true
else
if mi.uiHideMS <= mi.uiDisplayMS then
msg.error("Moment " .. ki .. "'s display interval is empty")
fail = true
end
if mi.uiDisplayMS < mi.startMs or mi.uiHideMS >= mi.endMs then
msg.warn ("Moment " .. ki .. "'s display interval overflows " ..
"out of bounds")
end
end
if mi.endMs < (segments[k].endTimeMs or 1/0) and
not (mi.trackingInfo and
mi.trackingInfo.optionType == "fakeOption") then
msg.warn ("Moment " .. ki .. " ends before its segment does")
end
for j, mj in ipairs(ml) do
local kj = k .. "/" .. j
if i ~= j and mi.startMs <= mj.startMs and mj.startMs < mi.endMs then
msg.error("Moment " .. kj .. " starts while " .. ki ..
" is active")
fail = true
end
end
-- Notification moments.
elseif mi.type == "notification:playbackImpression" then
if mi.choices then
msg.error("Moment " .. ki .. " has branching choices")
fail = true
end
if not mi.impressionData then
msg.warn ("Moment " .. ki .. " has no impression data")
end
elseif mi.type == "notification:action" then
if mi.choices then
msg.error("Moment " .. ki .. " has branching choices")
fail = true
end
else
msg.error ("Moment " .. ki .. " has invalid type: " .. mi.type)
fail = true
end
end
end
if fail then
deactivate()
return
end
-- Display stats.
msg.verbose("Loaded JSON data files:\n" ..
" " .. count(segments) .. " segments\n" ..
" " .. n_moments .. " interactive moments\n" ..
" " .. count(preconditions) .. " preconditions\n" ..
" " .. count(segment_groups) .. " segment groups\n" ..
" " .. count(state.vars) .. " state variables")
state.active = true
state.hist_idx = 0
state.hist = {}
update_controls()
end
--[[ Jump to initial segment at beginning of playback ]]-----------------------
function on_file_loaded(_)
if not state.active then return end
mp.register_event("tick", on_tick)
load_segment(initial_segment)
process_events(segments[initial_segment].startTimeMs, { ffwd = true })
end
--[[ Process queued events on each frame ]]------------------------------------
function on_tick(_)
process_events()
end
--[[ Register script entry-point events ]]-------------------------------------
mp.register_event("start-file", on_start_file)
mp.register_event("file-loaded", on_file_loaded)
|