diff --git a/Installer/Eternal2x.lua b/Installer/Eternal2x.lua index 5a392e1..73fb427 100644 --- a/Installer/Eternal2x.lua +++ b/Installer/Eternal2x.lua @@ -197,13 +197,13 @@ local AUTO_UPDATE = parse_bool(conf["auto_update"], true) local OUTPUT_DIR = conf["output_dir"] or "" local DO_UPSCALE = parse_bool(conf["upscale"], true) local DO_INTERPOLATE = parse_bool(conf["interpolate"], true) -local GRAINY = parse_bool(conf["grain"], false) -local BASE_HOLD = tonumber(conf["base_hold"] or "0") or 0 -- How big a change Analyse counts as a new drawing, 1 to 9, 5 the default. local THRESHOLD = math.max(1, math.min(9, math.floor(tonumber(conf["threshold"] or "5") or 5))) local FORMAT = conf["format"] or "png" local STATUS_PATH = join_path(REPO_ROOT ~= "" and REPO_ROOT or root, ".eternal2x_status.json") +-- Stop creates this, and the run ends at its next frame. +local STOP_PATH = join_path(REPO_ROOT ~= "" and REPO_ROOT or root, ".eternal2x_status.stop") -- The frames marked on the clip go to the stage in this file. A long clip's -- list does not fit on a Windows command line. local MARKS_PATH = join_path(REPO_ROOT ~= "" and REPO_ROOT or root, ".eternal2x_marks.txt") @@ -230,9 +230,7 @@ local function save_conf() f:write("output_dir=" .. OUTPUT_DIR .. "\n") f:write("upscale=" .. (DO_UPSCALE and "true" or "false") .. "\n") f:write("interpolate=" .. (DO_INTERPOLATE and "true" or "false") .. "\n") - f:write("grain=" .. (GRAINY and "true" or "false") .. "\n") f:write("threshold=" .. tostring(THRESHOLD) .. "\n") - f:write("base_hold=" .. tostring(BASE_HOLD) .. "\n") f:write("format=" .. FORMAT .. "\n") f:close() end @@ -594,6 +592,7 @@ end -- minimised window through `start`. UTF-16 also carries non-ASCII paths that -- os.execute's ANSI code page cannot. Without LuaJIT's ffi, os.execute runs. local hidden_execute = nil +local hidden_start = nil if is_windows() then pcall(function() local ffi = require("ffi") @@ -640,6 +639,28 @@ if is_windows() then k32.CloseHandle(pi.process) return true, tonumber(code[0]) end + -- Starts `cmd` in `dir` and leaves it running, with no window. + hidden_start = function(cmd, dir) + local function wide(s) + local size = k32.MultiByteToWideChar(65001, 0, s, #s, nil, 0) + if size <= 0 then return nil end + local out = ffi.new("wchar_t[?]", size + 1) + k32.MultiByteToWideChar(65001, 0, s, #s, out, size) + return out + end + local line = wide((os.getenv("ComSpec") or "cmd.exe") .. " /c " .. cmd) + local cwd = wide(dir) + if not line or not cwd then return false end + local si = ffi.new("E2X_STARTUPINFOW") + si.cb = ffi.sizeof(si) + local pi = ffi.new("E2X_PROCESS_INFORMATION") + if k32.CreateProcessW(nil, line, nil, nil, 0, 0x08000000, nil, cwd, si, pi) == 0 then + return false + end + k32.CloseHandle(pi.thread) + k32.CloseHandle(pi.process) + return true + end end) end @@ -659,11 +680,25 @@ local function run_blocking(module_name, args, log) return (ok == true or ok == 0) end +-- Runs a script in the plugin folder by name, as a render does. With no +-- window, there is none to close by mistake, which would end the run without +-- a word. Where that cannot start, `fallback` opens a minimised one. +local function start_script(script, fallback) + if hidden_start then + local dir = (REPO_ROOT ~= "" and REPO_ROOT or root):gsub("/", "\\") + local ok, started = pcall(hidden_start, ".\\" .. basename(script), dir) + if ok and started then return true end + end + local ok = execute(fallback) + return (ok == true or ok == 0) +end + -- True once the run is on its way. Python reports from then on. local function run_background(module_name, args) local cmd = build_command(module_name, args, true) if not cmd then return false end print("[Upflow2x] " .. cmd) + if is_windows() then return start_script(RUN_SCRIPT, cmd) end local ok = execute(cmd) return (ok == true or ok == 0) end @@ -765,6 +800,7 @@ end local function clear_status() os.remove(STATUS_PATH) os.remove(EXIT_PATH) + os.remove(STOP_PATH) end -- The exit code of the last background run, or nil while it is still going. @@ -783,14 +819,10 @@ end local ui = fu.UIManager local disp = bmd.UIDispatcher(ui) --- No "On 1s": planning on 1s gives every frame its own slot, so it would --- change nothing. A saved 1 from an older version falls back to Auto detect. -local HOLDS = { 0, 2, 3 } -local HOLD_LABELS = { "Auto detect", "On 2s", "On 3s" } -- No AVI: Resolve refuses its lossless FFV1 there, so that file is MKV. A -- saved avi from an older version falls back to the image sequence. local FORMATS = { "png", "mp4", "mkv" } -local FORMAT_LABELS = { "Image sequence (lossless)", "MP4 (compact)", "MKV (lossless)" } +local FORMAT_LABELS = { "Image sequence", "MP4", "MKV" } local function index_of(list, value) for i, v in ipairs(list) do @@ -799,137 +831,199 @@ local function index_of(list, value) return 0 end +-- Resolve's own greys, so the panel sits beside the Inspector without +-- standing out, and the amber from upflow2x.com for what is pressed or on. +local C = { + panel = "#28282e", field = "#1d1d21", raise = "#37363c", hover = "#423f47", + line = "#45444b", hair = "#35343a", + text = "#dcdcd8", soft = "#a3a2a8", mute = "#7a7b80", + amber = "#f2a93b", amber_hover = "#f6bd63", amber_down = "#d9932c", on_amber = "#17171a", +} +-- "$name" in a style sheet becomes that colour. +local function themed(sheet) + return (sheet:gsub("%$([%w_]+)", C)) +end + -- Resolve's UI Manager ignores #name selectors in a window's style sheet, so -- every widget that looks different from the rest carries its own. -local TITLE = "QLabel { font-size: 20px; font-weight: 700; color: #ededf4; }" -local SUBTITLE = "QLabel { color: #6e6e82; font-size: 11px; padding-bottom: 4px; }" -local SECTION = [[QLabel { color: #9a9ab0; font-size: 10px; font-weight: 700; - padding-top: 8px; letter-spacing: 0.6px; }]] -local CARD = [[QLabel { background-color: #121216; border: 1px solid #1e1e26; - border-radius: 6px; padding: 6px 9px; color: #c8c8d6; font-size: 11px; }]] -local META = "QLabel { color: #5c5c70; font-size: 10px; }" -local PROGRESS = "QLabel { color: #7c6fef; font-size: 11px; font-family: monospace; }" -local START = [[ - QPushButton { background-color: #7c6fef; border: 1px solid #9b90f5; border-radius: 7px; - color: #0b0b0f; font-size: 13px; font-weight: 700; min-height: 34px; } - QPushButton:hover { background-color: #9b90f5; } - QPushButton:disabled { background-color: #2a2740; color: #6a6a80; border-color: #2a2740; } -]] -local GHOST = [[ - QPushButton { background-color: #121216; border: 1px solid #1e1e26; border-radius: 6px; - min-height: 22px; padding: 3px 10px; font-size: 11px; font-weight: 500; color: #b4b4c6; } - QPushButton:hover { background-color: #1a1a22; border-color: #7c6fef; color: #e2e2ea; } - QPushButton:disabled { background-color: #0f0f13; border-color: #18181f; color: #4a4a5a; } -]] - -local RENDER_LABEL = "\xE2\x96\xB2 Render" -local ANALYSIS_HINT = "Analyse marks the drawings on the clip. Or mark the frames yourself with M. Render uses the marked frames." +local TITLE = themed("QLabel { font-size: 17px; font-weight: 600; color: $text; }") +local BADGE = themed([[QLabel { background-color: $amber; color: $on_amber; border-radius: 4px; + padding: 1px 5px; font-family: Consolas, Menlo, monospace; font-size: 10px; font-weight: 700; }]]) +local VERSION = themed("QLabel { color: $mute; font-size: 11px; }") +local HAIRLINE = themed("QLabel { background-color: $hair; }") +local INFO = themed([[QLabel { color: $soft; border: 1px solid $line; border-radius: 8px; + font-family: Georgia, serif; font-size: 10px; font-weight: 700; }]]) +local CARD = themed([[QLabel { background-color: $field; border: 1px solid $hair; + border-radius: 6px; padding: 7px 10px; color: $text; font-size: 12px; }]]) +local NOTE = themed("QLabel { color: $soft; font-size: 11px; }") +local FIELD_LABEL = themed("QLabel { color: $soft; font-size: 12px; }") +local META = themed("QLabel { color: $mute; font-size: 10px; }") +local PROGRESS = themed("QLabel { color: $soft; font-size: 11px; font-family: Consolas, Menlo, monospace; }") +local START = themed([[ + QPushButton { background-color: $amber; border: none; border-radius: 8px; + color: $on_amber; font-size: 15px; font-weight: 700; } + QPushButton:hover { background-color: $amber_hover; } + QPushButton:pressed { background-color: $amber_down; } + QPushButton:disabled { background-color: $raise; color: $mute; } +]]) +-- On or off at a glance: the whole tile lights up when it is on. They are +-- checkable buttons, since a checkbox only takes a click on its dot and words. +local TOGGLE = themed([[ + QPushButton { background-color: $raise; border: 1px solid $line; border-radius: 8px; + padding: 0px 12px; min-height: 34px; font-size: 13px; color: $soft; } + QPushButton:hover { background-color: $hover; } + QPushButton:checked { border-color: $amber; color: $text; background-color: rgba(242, 169, 59, 40); } + QPushButton:checked:hover { background-color: rgba(242, 169, 59, 64); } +]]) +local BUTTON = themed([[ + QPushButton { background-color: $raise; border: 1px solid $line; border-radius: 6px; + min-height: 30px; padding: 3px 10px; font-size: 12px; color: $text; } + QPushButton:hover { background-color: $hover; } + QPushButton:pressed { background-color: $field; } + QPushButton:disabled { background-color: $panel; border-color: $hair; color: $mute; } +]]) +local LINK = themed([[ + QPushButton { background-color: transparent; border: none; padding: 2px 4px; + min-height: 18px; font-size: 11px; color: $soft; } + QPushButton:hover { color: $amber; } + QPushButton:disabled { color: $mute; } +]]) +local WINDOW = themed([[ + QWidget { background-color: $panel; color: $text; font-size: 12px; } + QComboBox { + background-color: $field; border: 1px solid $hair; border-radius: 5px; + padding: 4px 8px; min-height: 18px; color: $text; + } + QComboBox:hover { border-color: $line; } + QComboBox QAbstractItemView { + background-color: $field; border: 1px solid $line; outline: none; + selection-background-color: $amber; selection-color: $on_amber; + } + QSlider::groove:horizontal { height: 3px; border-radius: 1px; background-color: $field; } + QSlider::sub-page:horizontal { border-radius: 1px; background-color: $amber; } + QSlider::handle:horizontal { + width: 12px; height: 12px; margin: -5px 0; border-radius: 6px; background-color: $text; + } + QSlider::handle:horizontal:hover { background-color: #ffffff; } + QToolTip { background-color: $field; color: $text; border: 1px solid $line; padding: 5px; } +]]) +-- A thin bar, amber up to how far the run has got. Empty, it keeps its +-- space but shows nothing, so the panel doesn't jump when a run starts. +local function bar_style(fraction) + if not fraction then + return "QLabel { background-color: transparent; }" + end + local f = math.max(0.001, math.min(0.998, fraction)) + -- Qt reads only a decimal point, and some locales format with a comma. + local function stop(value) return (string.format("%.3f", value):gsub(",", ".")) end + return themed(string.format([[QLabel { border-radius: 2px; background-color: qlineargradient( + x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 $amber, stop: %s $amber, + stop: %s $field, stop: 1 $field); }]], stop(f), stop(f + 0.001))) +end + +local RENDER_LABEL = "\xE2\x96\xB6 Render" +local ANALYSIS_HINT = "Mark the drawings with M, or let Analyse do it." + +-- What each part does, shown when the pointer rests on it, so the panel +-- itself only needs names. +local TIPS = { + clip = "Upflow2x works on the clip under the playhead, since Resolve doesn't tell scripts which clip is selected. This box follows the playhead. With the playhead in a gap, it keeps the last clip.", + detection = "Analyse finds the real drawings and puts a marker on each one. Delete a marker to leave its frame out, or add one with M. Render uses the marked frames, and finds the drawings itself on a clip with none.", + threshold = "How big a change Analyse counts as a new drawing. Lower catches small moves, like only a mouth. Higher counts only big ones.", + render = "Render draws on your GPU and puts the result on the track above the clip. Resolve stays usable while it runs.", + upscale = "Draw every frame at 2x with Real-ESRGAN's anime model, on the GPU on Windows. Without a GPU, Resolve's Super Scale does it.\nIt shows on a timeline bigger than the clip.", + interp = "Draw the missing in-betweens with the RIFE network, on the GPU on Windows. Its first use downloads about 50 MB.\nThe render plays at a third of the clip's speed, ready to time remap.\nOff keeps every drawing on its frame and the clip's length, and draws nothing, to upscale only.", + format = "Image sequence is lossless and imports most reliably. MP4 is one small file but lossy, and MKV is one lossless file. All are 8-bit RGB.", +} + +local function info(tip) + return ui:Label{ Text = "i", ToolTip = tip, StyleSheet = INFO, Weight = 0, + Alignment = { AlignCenter = true }, + MinimumSize = { 16, 16 }, MaximumSize = { 16, 16 } } +end + +-- Name, control, (i): the same three columns on every row. +local function row(name, control, tip) + return ui:HGroup{ Weight = 0, Spacing = 8, + ui:Label{ Text = name, StyleSheet = FIELD_LABEL, Weight = 0.2, + Alignment = { AlignLeft = true, AlignVCenter = true } }, + control, + info(tip), + } +end +-- Top to bottom in the order you use it: the clip, Analyse and its +-- threshold, the two switches and the output, then Render. All in one +-- window, so nothing opens on top of it. local win = disp:AddWindow({ ID = "Eternal2x", WindowTitle = "Upflow2x v" .. CURRENT_VERSION, - Geometry = { 100, 100, 460, 700 }, - StyleSheet = [[ - QWidget { background-color: #0b0b0f; color: #e2e2ea; font-size: 12px; } - QPushButton { - background-color: #16161e; border: 1px solid #28283a; border-radius: 7px; - min-height: 32px; padding: 6px 14px; font-weight: 600; color: #e2e2ea; - } - QPushButton:hover { background-color: #1e1e28; border-color: #7c6fef; } - QPushButton:pressed { background-color: #121218; } - QPushButton:disabled { color: #4a4a5a; border-color: #1e1e26; background-color: #101014; } - QComboBox { - background-color: #16161e; border: 1px solid #28283a; border-radius: 6px; - padding: 5px 8px; min-height: 24px; color: #e2e2ea; - } - QComboBox:hover { border-color: #7c6fef; } - QComboBox QAbstractItemView { - background-color: #16161e; border: 1px solid #28283a; - selection-background-color: #7c6fef; selection-color: #0b0b0f; - } - QCheckBox { color: #c8c8d6; font-size: 11px; spacing: 7px; } - QCheckBox::indicator { - width: 15px; height: 15px; border-radius: 3px; - border: 1px solid #28283a; background: #121216; - } - QCheckBox::indicator:checked { background: #7c6fef; border-color: #9b90f5; } - ]], + Geometry = { 100, 100, 380, 506 }, + StyleSheet = WINDOW, }, ui:VGroup{ - Spacing = 2, - - ui:Label{ ID = "Title", Text = "Upflow2x", StyleSheet = TITLE }, - ui:Label{ ID = "SubTitle", StyleSheet = SUBTITLE, - Text = "Smooth 2x for hand-drawn animation \xC2\xB7 v" .. CURRENT_VERSION }, - - ui:Label{ ID = "SourceSection", Text = "SOURCE CLIP", StyleSheet = SECTION }, - ui:Label{ ID = "SourceLabel", Text = "No clip under the playhead.", StyleSheet = CARD, - WordWrap = true, MinimumSize = { 0, 46 } }, - ui:HGroup{ Spacing = 6, - ui:Button{ ID = "RefreshBtn", Text = "Use Clip at Playhead", StyleSheet = GHOST, - ToolTip = "Read the clip under the playhead. Resolve does not tell scripts which clip is selected." }, - ui:Button{ ID = "AnalyseBtn", Text = "Analyse", StyleSheet = GHOST, - ToolTip = "Find the drawings and mark them on the clip. Delete a marker to leave a frame out, or add your own with M." }, - }, - ui:Label{ ID = "AnalysisLabel", Text = ANALYSIS_HINT, - StyleSheet = CARD, WordWrap = true, MinimumSize = { 0, 46 } }, - - ui:Label{ ID = "SettingsSection", Text = "SETTINGS", StyleSheet = SECTION }, - ui:HGroup{ Spacing = 6, - ui:Label{ Text = "Threshold", Weight = 0.4 }, - ui:Slider{ ID = "ThresholdSlider", Weight = 0.5, Minimum = 1, Maximum = 9, - Value = THRESHOLD, Orientation = "Horizontal", - ToolTip = "How big a change Analyse counts as a new drawing. Lower catches small moves, like only a mouth. Higher counts only big ones." }, - ui:Label{ ID = "ThresholdValue", Text = tostring(THRESHOLD), Weight = 0.1 }, - }, - ui:HGroup{ Spacing = 6, - ui:Label{ Text = "Hold pattern", Weight = 0.4 }, - ui:ComboBox{ ID = "HoldCombo", Weight = 0.6, - ToolTip = "Leave on Auto detect unless a clip's timing trips the detector." }, - }, - ui:HGroup{ Spacing = 6, - ui:Label{ Text = "Output", Weight = 0.4 }, - ui:ComboBox{ ID = "FormatCombo", Weight = 0.6, - ToolTip = "Image sequence is lossless and imports most reliably. All outputs are 8-bit RGB." }, - }, - ui:HGroup{ Spacing = 6, - ui:CheckBox{ ID = "UpscaleCB", Text = "Upscale 2x", Checked = DO_UPSCALE, - ToolTip = "Draw every frame at 2x with Real-ESRGAN's anime model, on the GPU on Windows. Without a GPU, Resolve's Super Scale does it.\nIt shows on a timeline bigger than the clip." }, - ui:CheckBox{ ID = "InterpCB", Text = "Interpolate", Checked = DO_INTERPOLATE, - ToolTip = "Draw the missing in-betweens with the RIFE network, on the GPU on Windows. Its first use downloads about 50 MB.\nThe render plays at a third of the clip's speed, ready to time remap.\nOff keeps every drawing on its frame and the clip's length, and draws nothing, to upscale only." }, - ui:CheckBox{ ID = "GrainCB", Text = "Grainy source", Checked = GRAINY, - ToolTip = "The clip has film grain, so held frames all differ a little. Leave off for clean animation, where it can merge fine real changes." }, + Spacing = 8, + + ui:HGroup{ Weight = 0, Spacing = 6, + ui:Label{ ID = "Title", Text = "Upflow", StyleSheet = TITLE, Weight = 0 }, + ui:Label{ ID = "Badge", Text = "2X", StyleSheet = BADGE, Weight = 0, + Alignment = { AlignCenter = true } }, + ui:HGap(0, 1), + ui:Label{ ID = "SubTitle", Text = "v" .. CURRENT_VERSION, StyleSheet = VERSION, Weight = 0, + Alignment = { AlignRight = true, AlignVCenter = true } }, + ui:Button{ ID = "UpdateBtn", Text = "Check for Updates", StyleSheet = LINK, Weight = 0 }, }, - ui:Label{ ID = "RunSection", Text = "RUN", StyleSheet = SECTION }, - ui:Button{ ID = "SmoothBtn", Text = RENDER_LABEL, StyleSheet = START, - MinimumSize = { 0, 38 }, MaximumSize = { 16777215, 38 } }, - ui:Label{ ID = "Progress", Text = "", StyleSheet = PROGRESS }, - ui:Label{ ID = "Status", Text = "Ready.", StyleSheet = CARD, WordWrap = true, - MinimumSize = { 0, 60 } }, - ui:HGroup{ Spacing = 6, - ui:Button{ ID = "CompareBtn", Text = "Show Original", StyleSheet = GHOST, - Enabled = false, - ToolTip = "Switch the smoothed clip off and on to compare it with the original under it." }, - ui:Button{ ID = "FolderBtn", Text = "Show in Folder", StyleSheet = GHOST, - Enabled = false, ToolTip = "Open the folder the render was saved to." }, + ui:Label{ ID = "SourceLabel", Text = "No clip under the playhead.", StyleSheet = CARD, + WordWrap = true, MinimumSize = { 0, 46 }, ToolTip = TIPS.clip }, + + ui:Button{ ID = "AnalyseBtn", Text = "Analyse", StyleSheet = BUTTON, ToolTip = TIPS.detection }, + row("Threshold", ui:HGroup{ Weight = 0.8, Spacing = 8, + ui:Slider{ ID = "ThresholdSlider", Weight = 1, Minimum = 1, Maximum = 9, PageStep = 1, + Value = THRESHOLD, Orientation = "Horizontal" }, + ui:Label{ ID = "ThresholdValue", Text = tostring(THRESHOLD), StyleSheet = FIELD_LABEL, + Weight = 0, MinimumSize = { 10, 0 }, + Alignment = { AlignRight = true, AlignVCenter = true } }, + }, TIPS.threshold), + ui:Label{ ID = "AnalysisLabel", Text = ANALYSIS_HINT, StyleSheet = NOTE, WordWrap = true }, + + ui:Label{ StyleSheet = HAIRLINE, MinimumSize = { 0, 1 }, MaximumSize = { 16777215, 1 } }, + + ui:HGroup{ Weight = 0, Spacing = 8, + ui:Button{ ID = "UpscaleCB", Text = "Upscaling", Checkable = true, Checked = DO_UPSCALE, + StyleSheet = TOGGLE, ToolTip = TIPS.upscale }, + ui:Button{ ID = "InterpCB", Text = "Interpolate", Checkable = true, + Checked = DO_INTERPOLATE, StyleSheet = TOGGLE, ToolTip = TIPS.interp }, }, - ui:HGroup{ Spacing = 6, - ui:Button{ ID = "RefreshStatusBtn", Text = "Refresh Progress", StyleSheet = GHOST }, - ui:Button{ ID = "CancelBtn", Text = "Reset", StyleSheet = GHOST }, + row("Output", ui:ComboBox{ ID = "FormatCombo", Weight = 0.8 }, TIPS.format), + ui:Button{ ID = "SmoothBtn", Text = RENDER_LABEL, StyleSheet = START, ToolTip = TIPS.render, + MinimumSize = { 0, 44 }, MaximumSize = { 16777215, 44 } }, + + ui:Label{ ID = "ProgressBar", Text = "", StyleSheet = bar_style(nil), + MinimumSize = { 0, 4 }, MaximumSize = { 16777215, 4 } }, + ui:Label{ ID = "Progress", Text = "", StyleSheet = PROGRESS, WordWrap = true }, + -- Three lines, as long as a finished render's message runs. Resolve does + -- not grow a wrapped label to fit, so two lines cut it off top and bottom. + ui:Label{ ID = "Status", Text = "Ready.", StyleSheet = NOTE, WordWrap = true, + MinimumSize = { 0, 46 } }, + + ui:HGroup{ Weight = 0, Spacing = 2, + ui:Button{ ID = "CompareBtn", Text = "Show Original", StyleSheet = LINK, Enabled = false, Weight = 0, + ToolTip = "Switch the render off and on to compare it with the original under it." }, + ui:Button{ ID = "FolderBtn", Text = "Show in Folder", StyleSheet = LINK, Enabled = false, Weight = 0, + ToolTip = "Open the folder the render was saved to." }, + ui:HGap(0, 1), + ui:Button{ ID = "RefreshStatusBtn", Text = "Refresh Progress", StyleSheet = LINK, Weight = 0, + ToolTip = "Read how far the run has got, if the panel stopped updating." }, + ui:Button{ ID = "CancelBtn", Text = "Reset", StyleSheet = LINK, Weight = 0, + ToolTip = "Clear a run that stopped without finishing." }, }, - ui:Label{ ID = "Meta", Text = "", StyleSheet = META, WordWrap = true }, - ui:HGroup{ Spacing = 6, - ui:Button{ ID = "UpdateBtn", Text = "\xE2\x86\xBB Check for Updates", StyleSheet = GHOST }, - ui:CheckBox{ ID = "AutoUpdateCB", Text = "Auto-update", Checked = AUTO_UPDATE }, - }, }) + local items = win:GetItems() -for _, label in ipairs(HOLD_LABELS) do items.HoldCombo:AddItem(label) end for _, label in ipairs(FORMAT_LABELS) do items.FormatCombo:AddItem(label) end -items.HoldCombo.CurrentIndex = index_of(HOLDS, BASE_HOLD) items.FormatCombo.CurrentIndex = index_of(FORMATS, FORMAT) -- --------------------------------------------------------------------------- @@ -958,6 +1052,8 @@ local ANALYSING = false local function set_status(msg) items.Status.Text = msg or "" + -- The whole message, for one too long for the box. + items.Status.ToolTip = msg or "" print("[Upflow2x] " .. (msg or "")) end @@ -966,23 +1062,23 @@ end local function set_progress(fraction, stage) if not fraction or (fraction <= 0 and (stage or "") == "") then items.Progress.Text = "" + items.ProgressBar.StyleSheet = bar_style(nil) return end - local width = 24 - local filled = math.floor(fraction * width + 0.5) - if filled > width then filled = width end - local bar = string.rep("\xE2\x96\x88", filled) .. string.rep("\xE2\x96\x91", width - filled) - items.Progress.Text = string.format("%s %d%% %s", bar, math.floor(fraction * 100 + 0.5), - stage or "") + fraction = math.min(1, fraction) + items.ProgressBar.StyleSheet = bar_style(fraction) + items.Progress.Text = string.format("%d%% %s", math.floor(fraction * 100 + 0.5), stage or "") end local function set_busy(busy) RUNNING = busy items.SmoothBtn.Enabled = not busy items.AnalyseBtn.Enabled = not busy - items.RefreshBtn.Enabled = not busy items.UpdateBtn.Enabled = not busy items.SmoothBtn.Text = busy and "Working..." or RENDER_LABEL + items.CancelBtn.Text = busy and "Stop" or "Reset" + items.CancelBtn.ToolTip = busy and "End the run now. Frames it has written stay in its folder." + or "Clear a run that stopped without finishing." end -- Seconds as text that Python's float() reads, whatever the locale. @@ -992,27 +1088,45 @@ end local function range_text(range) if not range then return "Whole file." end - return (string.format("%.2f s to %.2f s of the file, %.2f s long.", - range.start, range.finish, range.finish - range.start) + return (string.format("%.2f s long, from %.2f s of the file.", + range.finish - range.start, range.start) :gsub("(%d),(%d)", "%1.%2")) end -local function refresh_source() +-- The timer reads the clip twice a second, and a tip set again closes under +-- the pointer, so the card is only written when it changes. +local function show_source(text, tip) + if items.SourceLabel.Text ~= text then items.SourceLabel.Text = text end + if items.SourceLabel.ToolTip ~= tip then items.SourceLabel.ToolTip = tip end +end + +-- With keep, a playhead in a gap leaves the last clip, and the card, as they are. +local function refresh_source(keep) local path, err, range, place, item = selected_clip_path() if not path then + if keep and SOURCE_PATH then return false end SOURCE_PATH = nil SOURCE_RANGE = nil SOURCE_PLACE = nil SOURCE_ITEM = nil - items.SourceLabel.Text = err or "No clip under the playhead." + show_source(err or "No clip under the playhead.", TIPS.clip) return false end + -- Another clip, even one cut from the same part of the same file, or + -- another part of the file, makes the last analysis say nothing about it. + local was, at = SOURCE_RANGE, SOURCE_PLACE + if path ~= SOURCE_PATH or (range and range.start) ~= (was and was.start) + or (range and range.finish) ~= (was and was.finish) + or (place and place.item) ~= (at and at.item) + or (place and place.timeline) ~= (at and at.timeline) then + items.AnalysisLabel.Text = ANALYSIS_HINT + items.AnalysisLabel.ToolTip = "" + end SOURCE_PATH = path SOURCE_RANGE = range SOURCE_PLACE = place SOURCE_ITEM = item - items.SourceLabel.Text = short_name(basename(path)) .. "\n" .. range_text(range) - items.Meta.Text = short_path(path, 68) + show_source(short_name(basename(path)) .. "\n" .. range_text(range), TIPS.clip .. "\n\n" .. path) return true end @@ -1102,10 +1216,6 @@ local function current_args(extra) .. " --end-time " .. seconds(SOURCE_RANGE.finish) end end - local hold = HOLDS[(items.HoldCombo.CurrentIndex or 0) + 1] or 0 - if hold and hold > 0 then - args = args .. " --base-hold " .. tostring(hold) - end local fmt = FORMATS[(items.FormatCombo.CurrentIndex or 0) + 1] or "png" args = args .. " --format " .. fmt -- --network-upscale tells the stage this panel skips Super Scale for a @@ -1123,7 +1233,6 @@ local function current_args(extra) else args = args .. " --no-interpolate" end - if items.GrainCB.Checked then args = args .. " --grain" end if THRESHOLD ~= 5 then -- Each step doubles or halves how big a change counts, around the default. args = args .. " --threshold " .. string.format("%.6f", 0.004 * 2 ^ ((THRESHOLD - 5) / 2)):gsub(",", ".") @@ -1443,17 +1552,24 @@ local function apply_status(st) ANALYSING = false local placed, shared = 0, 0 if ANALYSED_ITEM then placed, shared = mark_drawings(ANALYSED_ITEM, st.marks) end - if st.message ~= "" then items.AnalysisLabel.Text = st.message end + local report = st.message if placed > 0 then - items.AnalysisLabel.Text = items.AnalysisLabel.Text .. " Marked " .. placed - .. " frames on the clip." + report = report .. " Marked " .. placed .. " frames on the clip." if shared > 0 then - items.AnalysisLabel.Text = items.AnalysisLabel.Text .. " " .. shared + report = report .. " " .. shared .. (shared == 1 and " more found frame shares a timeline frame with another" or " more found frames share timeline frames with others") .. ", as the clip runs faster than the timeline." end - set_status("Delete a marker to leave its frame out, or add one with M, then press Render.") + local on = st.message:match("[Oo]n (%d)s") + items.AnalysisLabel.Text = "Placed " .. placed .. " markers." + .. (on and (" Animated on " .. on .. "s.") or "") + elseif st.message ~= "" then + items.AnalysisLabel.Text = st.message + end + items.AnalysisLabel.ToolTip = report + if placed > 0 then + set_status("Check the markers, then press Render.") elseif st.marks ~= "" and ANALYSED_ITEM and ANALYSED_ITEM.unreadable then set_status("Analysis complete. Resolve would not say where the clip's frames are, so no markers were placed.") else @@ -1471,10 +1587,10 @@ local function apply_status(st) items.CompareBtn.Text = "Show Original" if ok then set_status(st.message .. " " .. notes) - -- Two lines of status leave no room for a tip as well. + -- Three lines of status leave no room for a tip as well. if hint then items.Meta.Text = hint end elseif where then - -- The status box fits two lines, too few for a path as well. + -- The status box fits three lines, too few for a path as well. items.Meta.Text = short_path(where, 68) print("[Upflow2x] The render is at " .. where) set_status(st.message .. " " .. notes .. " Drag it in from the path below.") @@ -1504,16 +1620,19 @@ local function poll() -- window closed before Python started leaves no file at all. local last = (st and st.updated and st.updated > 0) and st.updated or LAUNCHED_AT local quiet = last and (os.time() - last) or 0 + -- Quiet this long, the button clears the run, so it says Reset. if quiet > STALLED_AFTER and not st then + items.CancelBtn.Text = "Reset" set_status(string.format("Python has not started after %d minutes. Press Reset " .. "and run it again. Log: %s", math.floor(quiet / 60 + 0.5), LOG_PATH)) elseif quiet > STALLED_AFTER then - set_status(string.format("No progress for %d minutes. If the Upflow2x window " - .. "was closed, the render stopped: press Reset and run it again.", - math.floor(quiet / 60 + 0.5))) + items.CancelBtn.Text = "Reset" + set_status(string.format("No progress for %d minutes, so the run has stopped. " + .. "Press Reset and run it again.", math.floor(quiet / 60 + 0.5))) elseif (items.Status.Text or ""):match("^No progress for") or (items.Status.Text or ""):match("^Python has not started") then -- It is reporting again, so the warning no longer holds. + items.CancelBtn.Text = "Stop" set_status("Working. Resolve stays usable while this runs.") end return false @@ -1558,9 +1677,33 @@ local have_timer = pcall(function() timer = ui:Timer({ ID = "PollTimer", Interval = 500 }) end) +-- Where the playhead is, or nil. Cheap enough to read every tick. +local function playhead_spot() + local ok, spot = pcall(function() + local timeline = get_resolve():GetProjectManager():GetCurrentProject():GetCurrentTimeline() + return timeline:GetUniqueId() .. " " .. tostring(timeline:GetCurrentTimecode()) + end) + return ok and spot or nil +end + +-- Between runs the card follows the playhead, so there is nothing to press. +-- During playback the playhead moves every tick, and reading the clip under +-- it each time would cost Resolve, so the card catches up once it stops. +local pick_source +local last_spot = nil local function on_timeout(ev) - if not RUNNING then return end - poll() + if RUNNING then + poll() + return + end + local spot = playhead_spot() + local still = spot == last_spot + last_spot = spot + if not still then return end + local missing = not SOURCE_PATH and items.SourceLabel.Text + if pick_source(true) and missing and items.Status.Text == missing then + set_status("Ready.") + end end if have_timer and timer then @@ -1600,31 +1743,12 @@ function win.On.Eternal2x.Close(ev) disp:ExitLoop() end -function win.On.RefreshBtn.Clicked(ev) - if refresh_source() then - set_status("Ready.") - items.AnalysisLabel.Text = ANALYSIS_HINT - else - set_status(items.SourceLabel.Text) - end -end - -- Work on the clip under the playhead now, not the one there when the panel -- opened. If the playhead has moved off every clip, keep the last one. -local function pick_source() - local previous, label, range, place = SOURCE_PATH, items.SourceLabel.Text, SOURCE_RANGE, - SOURCE_PLACE - local clip = SOURCE_ITEM - if refresh_source() then return true end - if previous then - SOURCE_PATH = previous - SOURCE_RANGE = range - SOURCE_PLACE = place - SOURCE_ITEM = clip - items.SourceLabel.Text = label - return true - end - set_status(items.SourceLabel.Text) +-- With quiet, as the timer calls it, a gap says nothing. +function pick_source(quiet) + if refresh_source(true) or SOURCE_PATH then return true end + if not quiet then set_status(items.SourceLabel.Text) end return false end @@ -1647,8 +1771,9 @@ function win.On.AnalyseBtn.Clicked(ev) STAGE.name = nil set_busy(true) set_progress(0.01, "Starting") - set_status("Analysing. Resolve stays usable while this runs.") + set_status("Analysing...") items.AnalysisLabel.Text = "Analysing..." + items.AnalysisLabel.ToolTip = "" start_run(current_args(" --analyse")) end @@ -1689,10 +1814,10 @@ function win.On.SmoothBtn.Clicked(ev) set_busy(true) set_progress(0.01, "Starting") if #marks > 0 then - set_status("Rendering from the " .. #marks .. " frames marked on the clip. Resolve stays usable.") + set_status("Rendering the " .. #marks .. " marked frames.") start_run(current_args(" --marks-file " .. shell_quote(MARKS_PATH))) else - set_status("No markers on the clip, so Upflow2x finds the drawings. Resolve stays usable.") + set_status("Rendering. No markers, so Upflow2x finds the drawings.") start_run(current_args("")) end end @@ -1741,7 +1866,11 @@ function win.On.FolderBtn.Clicked(ev) shell_quote(LAST_OUTPUT:match("^(.+)/") or ".") .. " >/dev/null 2>&1 &" end print("[Upflow2x] " .. cmd) - execute(cmd) + if is_windows() then + start_script(REVEAL_SCRIPT, cmd) + else + execute(cmd) + end end function win.On.RefreshStatusBtn.Clicked(ev) @@ -1750,6 +1879,8 @@ function win.On.RefreshStatusBtn.Clicked(ev) -- A run this panel started is polled even before Python reports, so one -- that never started is caught. if not read_status() and not RUNNING then + -- Without a timer, this is also how the card catches up. + pick_source(true) set_status("No run in progress.") return end @@ -1758,10 +1889,12 @@ end function win.On.CancelBtn.Clicked(ev) if run_in_progress() then + -- The run sees this at its next frame, then reports Stopped. + local f = io.open(STOP_PATH, "w") + if f then f:close() end set_busy(true) start_polling() - set_status("It is still running, so Reset leaves it alone. If its window was closed, " - .. "Reset works once it has been quiet for 3 minutes.") + set_status(f and "Stopping..." or "Could not stop the run.") return end clear_status() @@ -1778,11 +1911,6 @@ function win.On.ThresholdSlider.ValueChanged(ev) save_conf() end -function win.On.HoldCombo.CurrentIndexChanged(ev) - BASE_HOLD = HOLDS[(items.HoldCombo.CurrentIndex or 0) + 1] or 0 - save_conf() -end - function win.On.FormatCombo.CurrentIndexChanged(ev) FORMAT = FORMATS[(items.FormatCombo.CurrentIndex or 0) + 1] or "png" save_conf() @@ -1798,17 +1926,6 @@ function win.On.InterpCB.Clicked(ev) save_conf() end -function win.On.GrainCB.Clicked(ev) - GRAINY = items.GrainCB.Checked - save_conf() -end - -function win.On.AutoUpdateCB.Clicked(ev) - AUTO_UPDATE = items.AutoUpdateCB.Checked - save_conf() - set_status(AUTO_UPDATE and "Auto-update enabled." or "Auto-update disabled.") -end - local function run_update(auto_mode) if REPO_ROOT == "" then set_status("Plugin folder not configured. Re-run the installer.") @@ -1863,8 +1980,8 @@ if REPO_ROOT == "" then items.Meta.Text = "Plugin folder not configured." set_status("Run Installer/install_eternal2x.py, then restart Resolve.") else - items.Meta.Text = short_path(REPO_ROOT, 68) refresh_source() + start_polling() set_status(SOURCE_PATH and "Ready." or items.SourceLabel.Text) local waiting = read_status() if run_in_progress() then diff --git a/Installer/install_eternal2x.py b/Installer/install_eternal2x.py index 594c472..e20f022 100644 --- a/Installer/install_eternal2x.py +++ b/Installer/install_eternal2x.py @@ -95,8 +95,6 @@ def _pick_python(repo_root: Path) -> str: "output_dir": "", "upscale": "true", "interpolate": "true", - "grain": "false", - "base_hold": "0", "threshold": "5", "format": "png", } diff --git a/Pipeline/dedupe.py b/Pipeline/dedupe.py index 76dd158..2dc8927 100644 --- a/Pipeline/dedupe.py +++ b/Pipeline/dedupe.py @@ -49,7 +49,7 @@ class DedupePlan: # One source frame index per intermediate frame, in order. output_indices: List[int] = field(default_factory=list) # Every frame came out new, but the scores look like grain on held - # frames: worth turning on Grainy source and analysing again. + # frames, so the drawings are best marked by hand. grain_suspected: bool = False # Made from the frames the editor marked, not from detection. Every frame # between two marks is then an in-between, however long the gap. diff --git a/Pipeline/render.py b/Pipeline/render.py index d482849..837aa5c 100644 --- a/Pipeline/render.py +++ b/Pipeline/render.py @@ -15,8 +15,10 @@ from __future__ import annotations +import os import sys from collections import deque +from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path from typing import Callable, Deque, Iterator, List, Optional, Sequence, Tuple @@ -367,6 +369,11 @@ def _iter_plan_frames(cap: cv2.VideoCapture, indices: Sequence[int]) -> Iterator # How many in-betweens are drawn before they are written. BATCH = 8 +# Threads that encode and write PNG frames. A 4K PNG takes about as long to +# encode as the GPU takes to draw and upscale a frame, and OpenCV lets go of +# Python while it encodes, so the two overlap. One core is left for Resolve. +WRITERS = max(1, min(3, (os.cpu_count() or 2) - 1)) + # Encoders to try per container, best quality first. _ENCODERS = { ".mkv": ("FFV1",), # lossless; Resolve refuses FFV1 in AVI @@ -462,6 +469,24 @@ def render_plan( buckets[i].append(p - i) cap = _open(source, frames, progress) + pool = ThreadPoolExecutor(max_workers=WRITERS) if is_sequence else None + # Frames still being written, oldest first. A few at most, so a fast GPU + # can't fill memory with 4K frames waiting for the disk. + pending: Deque[Future] = deque() + + def write_png(frame: np.ndarray, target: Path, number: int) -> None: + # Encode in memory and let Python write the file. cv2.imwrite + # cannot open a non-ASCII path on Windows, so a clip in a folder + # called Épisode 01 would fail on its first frame. + encoded, data = cv2.imencode(".png", frame) + if not encoded: + raise SourceError(f"Could not encode frame {number}.") + try: + target.write_bytes(data.tobytes()) + except OSError as exc: + raise SourceError(f"Could not write {target}: {exc}. Check the disk " + "has space and the folder is writable.") from exc + state = {"writer": None, "written": 0, "width": 0, "height": 0, "upscaler": upscaler, "upscale_note": upscale_note, "last_in": None, "last_out": None} @@ -493,17 +518,10 @@ def emit(frame: np.ndarray) -> None: state["height"], state["width"] = frame.shape[:2] if is_sequence: target = output / f"frame_{state['written']:06d}.png" - # Encode in memory and let Python write the file. cv2.imwrite - # cannot open a non-ASCII path on Windows, so a clip in a folder - # called Épisode 01 would fail on its first frame. - encoded, data = cv2.imencode(".png", frame) - if not encoded: - raise SourceError(f"Could not encode frame {state['written']}.") - try: - target.write_bytes(data.tobytes()) - except OSError as exc: - raise SourceError(f"Could not write {target}: {exc}. Check the disk " - "has space and the folder is writable.") from exc + # A write that failed raises here, on the next frame. + while len(pending) >= 2 * WRITERS: + pending.popleft().result() + pending.append(pool.submit(write_png, frame, target, state["written"])) else: if state["writer"] is None: # Every writer here rounds an odd size down to even without a @@ -522,8 +540,15 @@ def emit(frame: np.ndarray) -> None: # A GPU that gave out mid-render leaves the rest to the CPU, which # takes seconds a frame. A new stage name restarts the time left. on_cpu = not getattr(state["upscaler"], "on_gpu", True) - progress("Rendering, upscaling on the CPU" if on_cpu else "Rendering", - min(1.0, state["written"] / float(len(positions)))) + # The panel shows this as it goes, so it names the work being done. + upscaling = "upscaling on the CPU" if on_cpu else "upscaling" + if state["upscaler"] is None and factor == 1: + stage = "Interpolating" if pair is not None else "Rendering" + elif pair is not None: + stage = "Interpolating and " + upscaling + else: + stage = upscaling.capitalize() + progress(stage, min(1.0, state["written"] / float(len(positions)))) try: indices = plan.output_indices @@ -592,10 +617,15 @@ def change_from(slot: int) -> Optional[float]: prev = frame_at(index) while state["written"] < len(positions): emit(prev) + while pending: + pending.popleft().result() finally: cap.release() if state["writer"] is not None: state["writer"].release() + if pool is not None: + # Even on the way out of an error, no write is left going. + pool.shutdown(wait=True) return RenderResult( output=output, diff --git a/Pipeline/rife.py b/Pipeline/rife.py index a8f30b3..7ed71ea 100644 --- a/Pipeline/rife.py +++ b/Pipeline/rife.py @@ -41,6 +41,17 @@ # multiple of this. The frame is padded by repeating its edge, then cropped. _PAD = 64 +# Asked for a frame close to a drawing, the network hands back the drawing +# itself: on Hero's walk, every t up to 0.1 came back as the first drawing +# exactly and every t from 0.9 as the second, and the motion stayed uneven to +# about 0.2 and from 0.8. Slowed three times, that held each drawing for about +# six extra frames, a pause at every drawing. Between 0.25 and 0.75 it is even, +# so it is only ever asked for those. A frame nearer a drawing is drawn between +# the drawing and the halfway frame, and so on until it falls inside. +_EVEN_FROM = 0.25 +# A millionth of a gap: closer to a drawing than any real frame can be. +_ROUNDING = 1e-6 + ProgressFn = Callable[[str, float], None] @@ -83,7 +94,13 @@ def _pip(cmd: List[str], progress: Optional[ProgressFn]) -> subprocess.Completed return subprocess.CompletedProcess(cmd, proc.returncode, out, err) except subprocess.TimeoutExpired: if progress: - progress("Installing AI runtime", 0.0) + try: + progress("Installing AI runtime", 0.0) + except BaseException: + # Stopped. Cut short, pip could leave this Python half + # installed, so it finishes before the run ends. + proc.communicate() + raise def ensure_runtime(progress: Optional[ProgressFn] = None): @@ -164,6 +181,12 @@ def ensure_model(progress: Optional[ProgressFn] = None, path: Path = MODEL_PATH, class Rife: """One loaded model, reused for every pair of drawings in a render.""" + # The pair being drawn and the halfway frames drawn for it, by where they + # sit between its drawings. A render asks for a pair's in-betweens a few + # at a time, so they are kept until the next pair. + _pair = None + _known = None + def __init__(self, progress: Optional[ProgressFn] = None): ort = ensure_runtime(progress) model = ensure_model(progress) @@ -256,10 +279,32 @@ def interpolate_pair(self, a: np.ndarray, b: np.ndarray, times: List[float], out: List[np.ndarray] = [] for t in times: t = float(min(1.0, max(0.0, t))) - if t <= 0.0: + # This close to a drawing is rounding in the time map, not a frame. + if t <= _ROUNDING: out.append(a.copy()) - elif t >= 1.0: + elif t >= 1.0 - _ROUNDING: out.append(b.copy()) else: - out.append(self._run(a, b, t)) + out.append(self._between(a, b, t)) return out + + def _between(self, a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray: + """The frame at t between a and b, asking the network only for 0.25 to 0.75.""" + if self._pair is None or self._pair[0] is not a or self._pair[1] is not b: + self._pair, self._known = (a, b), {0.0: a, 1.0: b} + known = self._known + lo, hi = 0.0, 1.0 + while True: + local = (t - lo) / (hi - lo) + # Every t strictly inside lands here: each halving doubles its + # distance from the nearer end. A gap of 5000 frames slowed three + # times needs 12 halvings and keeps 23 halfway frames. + if _EVEN_FROM <= local <= 1.0 - _EVEN_FROM: + return self._run(known[lo], known[hi], local) + if hi - lo < 2.0 ** -30: + # Only rounding gets here, where both ends are one picture. + return (known[lo] if local < 0.5 else known[hi]).copy() + mid = (lo + hi) / 2.0 + if mid not in known: + known[mid] = self._run(known[lo], known[hi], 0.5) + lo, hi = (lo, mid) if local < 0.5 else (mid, hi) diff --git a/README.md b/README.md index 5f48b14..afc31a1 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ holds for three times its length. - Finds duplicated frames and works out whether the clip is on 1s, 2s or 3s. It counts a change of colour, a slow fade or a slow pan as motion rather - than a hold. With **Grainy source** ticked, it sees through film grain. + than a hold. - Draws the in-between frames that were never drawn with the RIFE network, run on your own machine, on the GPU on Windows wherever DirectML loads. Moving characters stay in one piece. @@ -109,7 +109,8 @@ move it. 1. Move the playhead over a clip on your timeline. Resolve does not tell scripts which clip is selected, so Upflow2x works on the clip under the - playhead, read again each time you press a button. + playhead. The panel follows the playhead, and in a gap it keeps the + last clip. 2. Open **Workspace → Scripts → Upflow2x**. 3. Click **Analyse**. It reads the clip, tells you what it found, for example `1438 frames, 719 unique drawings (719 held, 50%). Animated on 2s.`, @@ -134,7 +135,7 @@ rendered from the drawings Upflow2x finds on its own. ![The Upflow2x panel in DaVinci Resolve Studio after a render. The rebuilt clip sits on V2, lined up above the original on V1.](https://upflow2x.com/assets/demo/resolve.jpg) The render runs in the background and the panel shows progress and the time -left, so Resolve stays usable while it works. When it finishes, the rebuilt +left, so Resolve stays usable while it works. **Stop** ends it early. When it finishes, the rebuilt clip is imported and placed on the track above the original, starting on the same frame. With Interpolate on it runs three times as long, up to the last marker if the clip has any. It goes on the lowest @@ -161,14 +162,15 @@ Whole file when Resolve does not report one. ### Settings +They are all on the panel, and it remembers them between sessions. Rest the +pointer on one to see what it does. + | Setting | What it does | |---|---| | **Threshold** | How big a change Analyse counts as a new drawing, 1 to 9. Lower catches small moves, like only a mouth or a slow drift. Higher counts only big ones, like the whole shot moving. It does nothing to frames you have marked. | -| **Hold pattern** | Leave on `Auto detect`. Force `On 2s` or `On 3s` if a clip has unusual timing that trips the detector. | | **Output** | `Image sequence` is lossless and the most reliable to import. `MP4` is a single compact file but lossy. `MKV` is a lossless single file. All three are 8-bit RGB: transparency and 10-bit colour in the source are not kept. MP4 and MKV need an even width and height. | -| **Upscale 2x** | Draws every frame at 2x with Real-ESRGAN's anime video model, on the GPU through DirectML, about 0.14 s a 1080p frame on a Radeon. The model ships with Upflow2x. Without a GPU, on a Mac for one, Resolve's Super Scale is applied to the imported clip instead. Either way it only shows on a timeline bigger than the clip, such as 3840x2160 for 1080p footage. A 2x render has four times the pixels. As an image sequence, a 4K frame of anime is about 6 to 7 MB, four to five times its 1080p size. | -| **Interpolate** | On, the RIFE network draws every missing in-between. On Windows it runs on the GPU through DirectML, about 0.13 s an in-between at 2K on a Radeon, and falls back to the CPU, about ten times slower. If its Python already has ONNX Runtime without DirectML, that one is used, on the CPU. Its first run installs ONNX Runtime and downloads the model, about 50 MB, so it needs a connection once; without one, every drawing is kept as drawn and the panel says why. On, the render is three times longer, so it takes three times as long to render and an image sequence takes three times the disk. Off skips the in-betweens, so every drawing comes back on its original frame and the render keeps the clip's length. Use it with Upscale 2x to upscale only. | -| **Grainy source** | For film grain, which makes every held frame differ a little, so they all look new. It raises the threshold above the clip's own grain. Leave it off for clean animation, where it can merge fine real changes. Analyse suggests it when every frame differs in a way that looks like grain. | +| **Upscaling** | Draws every frame at 2x with Real-ESRGAN's anime video model, on the GPU through DirectML, about 0.14 s a 1080p frame on a Radeon. The model ships with Upflow2x. Without a GPU, on a Mac for one, Resolve's Super Scale is applied to the imported clip instead. Either way it only shows on a timeline bigger than the clip, such as 3840x2160 for 1080p footage. A 2x render has four times the pixels. As an image sequence, a 4K frame of anime is about 6 to 7 MB, four to five times its 1080p size. | +| **Interpolate** | On, the RIFE network draws every missing in-between. On Windows it runs on the GPU through DirectML, about 0.13 s an in-between at 2K on a Radeon, and falls back to the CPU, about ten times slower. If its Python already has ONNX Runtime without DirectML, that one is used, on the CPU. Its first run installs ONNX Runtime and downloads the model, about 50 MB, so it needs a connection once; without one, every drawing is kept as drawn and the panel says why. On, the render is three times longer, so it takes three times as long to render and an image sequence takes three times the disk. Off skips the in-betweens, so every drawing comes back on its original frame and the render keeps the clip's length. With Upscaling on, that only upscales. | Settings are remembered between sessions. @@ -220,9 +222,9 @@ afterwards. | Resolve says you need Studio | Since 19.1 the free edition blocks script panels. Upflow2x needs Studio. | | Clicking Upflow2x does nothing, or "Plugin folder not configured" | The plugin folder moved after installing, and Workspace → Console says `UI script not found`. Re-run `python Installer/install_eternal2x.py` from where it is now. | | "Could not reach Resolve" | Open the panel from Workspace → Scripts, not from a terminal. | -| "No clip under the playhead" | Move the playhead over the clip, then press **Use Clip at Playhead**. | +| "No clip under the playhead" | Move the playhead over the clip, and the panel picks it up. | | "No duplicate frames. This clip is already on 1s." | The clip has no duplicated frames. It is live action, or already on 1s. | -| "Every frame differs, but the differences look like grain" | Tick **Grainy source** and analyse again. Heavy grain on a clip with long holds can still hide the duplicates. | +| "Every frame differs, but the differences look like grain" | Mark the drawings with M, then press **Render**. From the command line, `--grain` raises the threshold above the grain. | | "...has a variable frame rate" | Render the clip to a constant frame rate first, from Resolve or with ffmpeg, as the message says. | | Progress seems stuck | Press **Refresh Progress**. If Python stopped, the panel says so and shows its last error. If a run was interrupted, press **Reset**. | | Resolve would not import the render | The render still finished. The panel prints where it is, so drag it into your media pool. Switching Output to `Image sequence` usually avoids this. | diff --git a/Stages/resolve_smooth.py b/Stages/resolve_smooth.py index b8a03f1..b828629 100644 --- a/Stages/resolve_smooth.py +++ b/Stages/resolve_smooth.py @@ -29,6 +29,10 @@ IMPORT_ERROR = None +class Stopped(BaseException): + """The panel's Stop. Not an Exception, so nothing on the way out catches it.""" + + class StatusFile: """Progress the panel can poll while the render runs in the background. @@ -40,6 +44,11 @@ class StatusFile: def __init__(self, path: Optional[str]) -> None: self.path = Path(path) if path else None + # The panel's Stop creates this, and the run ends at its next frame. + self.stop_path = self.path.with_suffix(".stop") if self.path else None + # Set once the result is going into Resolve. Stopped halfway there it + # would be imported but not placed, so from then on the run finishes. + self.committed = False self.stage = "" self.fraction = 0.0 self.message = "" @@ -96,6 +105,11 @@ def write(self, *, done: bool = False, ok: bool = False, # but losing the final write leaves the panel waiting forever. attempts = 100 if done else 3 for attempt in range(attempts): + # A Stop pressed after the last frame, while the writes finished + # or while this waits for the panel to let go of the file, still + # counts: the run reports Stopped, not a result to import. + if done and ok: + self.check_stop() try: temp.replace(self.path) return @@ -103,7 +117,13 @@ def write(self, *, done: bool = False, ok: bool = False, if attempt + 1 < attempts: time.sleep(0.02) + def check_stop(self) -> None: + """Raise Stopped if the panel's Stop was pressed in time.""" + if not self.committed and self.stop_path is not None and self.stop_path.exists(): + raise Stopped() + def progress(self, stage: str, fraction: float) -> None: + self.check_stop() self.stage = stage self.fraction = fraction self.write() @@ -397,10 +417,10 @@ def _run(args, summary: dict) -> int: summary["skipped"] = "no duplicate frames" if plan.grain_suspected: why = ("Every frame differs, but by about the same small amount, as grain " - "makes held frames differ. If the clip is grainy, turn on Grainy " - "source (--grain) and try again.") - short = ("Every frame differs, but the differences look like grain. If the " - "clip is grainy, turn on Grainy source and analyse again.") + "makes held frames differ. If the clip is grainy, mark the drawings " + "with M, or run with --grain and try again.") + short = ("Every frame differs, but the differences look like grain. Mark the " + "drawings with M, then press Render.") print(f"Nothing to do. {why}") summary["ok"] = True status.write(done=True, ok=True, message=short) @@ -424,6 +444,8 @@ def _run(args, summary: dict) -> int: summary["error"] = str(exc) status.write(done=True, ok=False, message=str(exc)) return 2 + # Stopped while the last frames were written: nothing goes into Resolve. + status.check_stop() print(f"Wrote {result.frames_written} frames at {result.fps:.3f} fps.") if result.note: @@ -456,41 +478,50 @@ def _run(args, summary: dict) -> int: status.write(done=True, ok=True, message=_finished(plan, result)) return 0 - media_item, imported = bridge.import_media(project, result.output, - frame_count=result.frames_written, - fps=result.fps) - print(("Imported: " if imported else "Import failed: ") + imported.detail) - summary["import"] = {"ok": imported.ok, "detail": imported.detail} + # Too late to stop from here, or the clip would be left half placed. + status.committed = True + try: + media_item, imported = bridge.import_media(project, result.output, + frame_count=result.frames_written, + fps=result.fps) + print(("Imported: " if imported else "Import failed: ") + imported.detail) + summary["import"] = {"ok": imported.ok, "detail": imported.detail} + + if not imported: + print(f"The render is fine and is waiting at: {result.output}") + print("Drag it into your media pool to finish.") + summary["ok"] = True + status.write(done=True, ok=True, + message=f"Rendered, but Resolve would not import it. " + f"Drag in {result.output.name} to finish.") + return 0 + + if result.upscaled: + summary["upscale"] = {"ok": True, "detail": "Drawn 2x by Real-ESRGAN."} + elif cfg.upscale_factor > 1: + studio = bridge.is_studio(resolve) + if studio is False: + print("Skipping upscale: Super Scale needs DaVinci Resolve Studio.") + summary["upscale"] = {"ok": False, "detail": "not Studio"} + else: + scaled = bridge.set_super_scale(media_item, cfg.upscale_factor) + print(("Upscale: " if scaled else "Upscale not applied: ") + scaled.detail) + summary["upscale"] = {"ok": scaled.ok, "detail": scaled.detail} + + appended = bridge.append_to_timeline(project, media_item) + print(("Timeline: " if appended else "Timeline not updated: ") + appended.detail) + summary["append"] = {"ok": appended.ok, "detail": appended.detail} - if not imported: - print(f"The render is fine and is waiting at: {result.output}") - print("Drag it into your media pool to finish.") summary["ok"] = True - status.write(done=True, ok=True, - message=f"Rendered, but Resolve would not import it. " - f"Drag in {result.output.name} to finish.") + print("Done.") + status.write(done=True, ok=True, message=_finished(plan, result)) return 0 - - if result.upscaled: - summary["upscale"] = {"ok": True, "detail": "Drawn 2x by Real-ESRGAN."} - elif cfg.upscale_factor > 1: - studio = bridge.is_studio(resolve) - if studio is False: - print("Skipping upscale: Super Scale needs DaVinci Resolve Studio.") - summary["upscale"] = {"ok": False, "detail": "not Studio"} - else: - scaled = bridge.set_super_scale(media_item, cfg.upscale_factor) - print(("Upscale: " if scaled else "Upscale not applied: ") + scaled.detail) - summary["upscale"] = {"ok": scaled.ok, "detail": scaled.detail} - - appended = bridge.append_to_timeline(project, media_item) - print(("Timeline: " if appended else "Timeline not updated: ") + appended.detail) - summary["append"] = {"ok": appended.ok, "detail": appended.detail} - - summary["ok"] = True - print("Done.") - status.write(done=True, ok=True, message=_finished(plan, result)) - return 0 + finally: + # However this ends, a Stop that came now was not used, and would end + # the next run. + if status.stop_path is not None: + with contextlib.suppress(OSError): + status.stop_path.unlink() def _run_reporting(args, summary: dict) -> int: @@ -505,6 +536,17 @@ def _run_reporting(args, summary: dict) -> int: return 3 try: return _run(args, summary) + except Stopped: + print("Stopped.") + # It may have stopped after the summary already said it worked. + summary["ok"] = False + summary.pop("import_path", None) + summary["error"] = "Stopped." + status = StatusFile(args.status_file) + with contextlib.suppress(OSError): + status.stop_path.unlink() + status.write(done=True, ok=False, message="Stopped.") + return 1 except Exception as exc: traceback.print_exc() message = f"Upflow2x hit an unexpected error: {exc}" diff --git a/docs/help.html b/docs/help.html index 4aa2303..c60310f 100644 --- a/docs/help.html +++ b/docs/help.html @@ -131,7 +131,7 @@

Using the panel

  1. Put the playhead on a clip

    -

    Move the playhead over the clip on your timeline, then open the panel. It shows the clip it picked up and the part of the file the clip uses. Analyse and Render pick up the clip under the playhead when you press them, and if the playhead sits in a gap, they keep the last clip. Use Clip at Playhead shows you which one that is.

    +

    Move the playhead over the clip on your timeline, then open the panel. It shows the clip it picked up and the part of the file the clip uses. It follows the playhead as you move it, and if the playhead sits in a gap, it keeps the last clip.

  2. Analyse

    @@ -144,8 +144,8 @@

    Check the markers

  3. Render

    -

    Press Render. It keeps every marked frame as drawn and draws every frame between two markers fresh, so a part you leave unmarked never counts. With Interpolate on, the render ends on the last marker, so mark the last drawing you want in it. A clip with no markers at all is rendered from the drawings Upflow2x finds on its own. The render runs in the background with a progress bar and the time left, and Resolve stays usable. The first time, it installs the AI runtime and downloads the model, about 50 MB, so the bar can sit at 0% for a minute or two.

    -

    When it's done, the result is imported, at 2x if Upscale 2x is ticked, and placed above the original, starting on the same frame, at the same frame rate with three frames for every one of the clip's, up to the last marker if it has any, so it plays at a third of the speed, ready to time remap. With Interpolate off it keeps the clip's length. It goes on the lowest track that is empty there, or a new track named Upflow2x, so none of your clips get covered. Only the part of the file you trimmed to is rebuilt.

    +

    Press Render. It keeps every marked frame as drawn and draws every frame between two markers fresh, so a part you leave unmarked never counts. With Interpolate on, the render ends on the last marker, so mark the last drawing you want in it. A clip with no markers at all is rendered from the drawings Upflow2x finds on its own. The render runs in the background with a progress bar and the time left, and Resolve stays usable. Stop ends it early. The first time, it installs the AI runtime and downloads the model, about 50 MB, so the bar can sit at 0% for a minute or two.

    +

    When it's done, the result is imported, at 2x if Upscaling is on, and placed above the original, starting on the same frame, at the same frame rate with three frames for every one of the clip's, up to the last marker if it has any, so it plays at a third of the speed, ready to time remap. With Interpolate off it keeps the clip's length. It goes on the lowest track that is empty there, or a new track named Upflow2x, so none of your clips get covered. Only the part of the file you trimmed to is rebuilt.

    If another timeline is open when it finishes, or the original was deleted or retrimmed, it goes at the end of the timeline instead. An image sequence whose frame rate Resolve won't set stays in the media pool, since it would play at the wrong speed.

  4. @@ -157,15 +157,12 @@

    Compare

    Settings

    -

    The panel remembers them between sessions.

    +

    They are all on the panel, and it remembers them between sessions. Rest the pointer on one to see what it does.

    Threshold
    How big a change Analyse counts as a new drawing, 1 to 9. Lower catches small moves, like only a mouth or a slow drift. Higher counts only big ones, like the whole shot moving. It does nothing to frames you've marked.
    -
    Hold pattern
    Leave it on Auto detect. Force On 2s or On 3s if a clip has unusual timing that trips the detector.
    Output
    Image sequence is lossless and imports most reliably. MP4 is one compact file but lossy. MKV is one lossless file. All three are 8-bit RGB, so transparency and 10-bit colour aren't kept. MP4 and MKV need an even width and height.
    -
    Upscale 2x
    Draws every frame at 2x with Real-ESRGAN's anime video model, on the GPU through DirectML, about 0.14 s a 1080p frame on a Radeon. The model comes with Upflow2x. Without a GPU, on a Mac for one, Resolve's Super Scale is applied to the imported clip instead. Either way it only shows on a timeline bigger than the clip, such as 3840x2160 for 1080p footage. As an image sequence, a 4K frame is about 6 to 7 MB.
    -
    Interpolate
    On, the RIFE network draws every missing in-between. On Windows it runs on the GPU through DirectML, about 0.13 s an in-between at 2K on a Radeon, and falls back to the CPU, which is about ten times slower. On, the render is three times longer, so it takes three times as long to render and an image sequence takes three times the disk. Off, every drawing comes back on its own frame with nothing new drawn, at the clip's length. Use that with Upscale 2x to only upscale.
    -
    Grainy source
    For film grain, which makes every held frame differ a little so they all look new. It raises the threshold above the clip's own grain. Leave it off for clean animation, where it can merge fine real changes. Analyse suggests it when the differences look like grain.
    -
    Auto-update
    Checks for a new version each time the panel opens. Untick it to check only when you press Check for Updates.
    +
    Upscaling
    Draws every frame at 2x with Real-ESRGAN's anime video model, on the GPU through DirectML, about 0.14 s a 1080p frame on a Radeon. The model comes with Upflow2x. Without a GPU, on a Mac for one, Resolve's Super Scale is applied to the imported clip instead. Either way it only shows on a timeline bigger than the clip, such as 3840x2160 for 1080p footage. As an image sequence, a 4K frame is about 6 to 7 MB.
    +
    Interpolate
    On, the RIFE network draws every missing in-between. On Windows it runs on the GPU through DirectML, about 0.13 s an in-between at 2K on a Radeon, and falls back to the CPU, which is about ten times slower. On, the render is three times longer, so it takes three times as long to render and an image sequence takes three times the disk. Off, every drawing comes back on its own frame with nothing new drawn, at the clip's length. With Upscaling on, that only upscales.
    @@ -205,14 +202,14 @@

    Troubleshooting

    "No clip under the playhead"
    -

    Upflow2x works on the clip under the playhead, since Resolve doesn't tell scripts which clip is selected. Move the playhead over the clip, then press Use Clip at Playhead.

    +

    Upflow2x works on the clip under the playhead, since Resolve doesn't tell scripts which clip is selected. Move the playhead over the clip, and the panel picks it up.

    "No duplicate frames. This clip is already on 1s."

    Analyse found nothing to do. The clip has no repeated frames, so there are no held drawings to recover. It's live action or animation already drawn on 1s. Render still slows it to a third of the speed, with two new frames drawn between every pair. With Interpolate off it stops instead, since there is nothing to draw.

    -

    If the panel says instead that every frame differs but the differences look like grain, tick Grainy source and analyse again. Leave it off for clean animation.

    +

    If the panel says instead that every frame differs but the differences look like grain, mark the drawings with M, then press Render.

    @@ -227,13 +224,13 @@

    Troubleshooting

    Rendering is slow on long clips

    Every frame is read and every missing in-between drawn, so the time grows with the length of the clip on your timeline. Only the part of the file the clip uses is rebuilt, so trimming helps, though a cut late in a long file is still decoded up to its start first, shown as Seeking.

    -

    On Windows the in-betweens are drawn on the GPU, and ten seconds of 1080p takes roughly ten to twenty seconds. On the CPU it's about ten times slower. Upscale 2x on the GPU adds about 0.3 to 0.5 s a frame, much of it writing the 4K images. With Grainy source on, Analyse reads the clip twice. Resolve stays usable the whole time.

    +

    On Windows the in-betweens are drawn on the GPU, and ten seconds of 1080p takes roughly ten to twenty seconds. On the CPU it's about ten times slower. Upscaling on the GPU adds about 0.3 to 0.5 s a frame, much of it writing the 4K images. Resolve stays usable the whole time.

    Progress seems stuck
    -

    Press Refresh Progress. If the panel says Python hasn't started, or that there's been no progress for a few minutes, the run stopped, often because its window was closed. Press Reset and run it again.

    +

    Press Refresh Progress. If the panel says Python hasn't started, or that there's been no progress for a few minutes, the run stopped. Press Reset and run it again.

    For anything else, email us the panel's status text and the .eternal2x_last_run.log file from the plugin folder.

    @@ -269,7 +266,7 @@

    Troubleshooting

    Updates

    They arrive on their own.

    Automatically

    -

    Each time the panel opens, Upflow2x checks for a new version. If there is one, it downloads it, checks it against its checksum and applies it. Restart Resolve afterwards to load it. Untick Auto-update to stop this.

    +

    Each time the panel opens, Upflow2x checks for a new version. If there is one, it downloads it, checks it against its checksum and applies it. Restart Resolve afterwards to load it.

    When you ask

    Press Check for Updates at any time. If nothing changed, it says so and leaves your install alone. A failed check never stops you working, and an update that can't finish is undone, so you keep the version you had.

    diff --git a/release/upflow2x-mac/Installer/Eternal2x.lua b/release/upflow2x-mac/Installer/Eternal2x.lua index 5a392e1..73fb427 100644 --- a/release/upflow2x-mac/Installer/Eternal2x.lua +++ b/release/upflow2x-mac/Installer/Eternal2x.lua @@ -197,13 +197,13 @@ local AUTO_UPDATE = parse_bool(conf["auto_update"], true) local OUTPUT_DIR = conf["output_dir"] or "" local DO_UPSCALE = parse_bool(conf["upscale"], true) local DO_INTERPOLATE = parse_bool(conf["interpolate"], true) -local GRAINY = parse_bool(conf["grain"], false) -local BASE_HOLD = tonumber(conf["base_hold"] or "0") or 0 -- How big a change Analyse counts as a new drawing, 1 to 9, 5 the default. local THRESHOLD = math.max(1, math.min(9, math.floor(tonumber(conf["threshold"] or "5") or 5))) local FORMAT = conf["format"] or "png" local STATUS_PATH = join_path(REPO_ROOT ~= "" and REPO_ROOT or root, ".eternal2x_status.json") +-- Stop creates this, and the run ends at its next frame. +local STOP_PATH = join_path(REPO_ROOT ~= "" and REPO_ROOT or root, ".eternal2x_status.stop") -- The frames marked on the clip go to the stage in this file. A long clip's -- list does not fit on a Windows command line. local MARKS_PATH = join_path(REPO_ROOT ~= "" and REPO_ROOT or root, ".eternal2x_marks.txt") @@ -230,9 +230,7 @@ local function save_conf() f:write("output_dir=" .. OUTPUT_DIR .. "\n") f:write("upscale=" .. (DO_UPSCALE and "true" or "false") .. "\n") f:write("interpolate=" .. (DO_INTERPOLATE and "true" or "false") .. "\n") - f:write("grain=" .. (GRAINY and "true" or "false") .. "\n") f:write("threshold=" .. tostring(THRESHOLD) .. "\n") - f:write("base_hold=" .. tostring(BASE_HOLD) .. "\n") f:write("format=" .. FORMAT .. "\n") f:close() end @@ -594,6 +592,7 @@ end -- minimised window through `start`. UTF-16 also carries non-ASCII paths that -- os.execute's ANSI code page cannot. Without LuaJIT's ffi, os.execute runs. local hidden_execute = nil +local hidden_start = nil if is_windows() then pcall(function() local ffi = require("ffi") @@ -640,6 +639,28 @@ if is_windows() then k32.CloseHandle(pi.process) return true, tonumber(code[0]) end + -- Starts `cmd` in `dir` and leaves it running, with no window. + hidden_start = function(cmd, dir) + local function wide(s) + local size = k32.MultiByteToWideChar(65001, 0, s, #s, nil, 0) + if size <= 0 then return nil end + local out = ffi.new("wchar_t[?]", size + 1) + k32.MultiByteToWideChar(65001, 0, s, #s, out, size) + return out + end + local line = wide((os.getenv("ComSpec") or "cmd.exe") .. " /c " .. cmd) + local cwd = wide(dir) + if not line or not cwd then return false end + local si = ffi.new("E2X_STARTUPINFOW") + si.cb = ffi.sizeof(si) + local pi = ffi.new("E2X_PROCESS_INFORMATION") + if k32.CreateProcessW(nil, line, nil, nil, 0, 0x08000000, nil, cwd, si, pi) == 0 then + return false + end + k32.CloseHandle(pi.thread) + k32.CloseHandle(pi.process) + return true + end end) end @@ -659,11 +680,25 @@ local function run_blocking(module_name, args, log) return (ok == true or ok == 0) end +-- Runs a script in the plugin folder by name, as a render does. With no +-- window, there is none to close by mistake, which would end the run without +-- a word. Where that cannot start, `fallback` opens a minimised one. +local function start_script(script, fallback) + if hidden_start then + local dir = (REPO_ROOT ~= "" and REPO_ROOT or root):gsub("/", "\\") + local ok, started = pcall(hidden_start, ".\\" .. basename(script), dir) + if ok and started then return true end + end + local ok = execute(fallback) + return (ok == true or ok == 0) +end + -- True once the run is on its way. Python reports from then on. local function run_background(module_name, args) local cmd = build_command(module_name, args, true) if not cmd then return false end print("[Upflow2x] " .. cmd) + if is_windows() then return start_script(RUN_SCRIPT, cmd) end local ok = execute(cmd) return (ok == true or ok == 0) end @@ -765,6 +800,7 @@ end local function clear_status() os.remove(STATUS_PATH) os.remove(EXIT_PATH) + os.remove(STOP_PATH) end -- The exit code of the last background run, or nil while it is still going. @@ -783,14 +819,10 @@ end local ui = fu.UIManager local disp = bmd.UIDispatcher(ui) --- No "On 1s": planning on 1s gives every frame its own slot, so it would --- change nothing. A saved 1 from an older version falls back to Auto detect. -local HOLDS = { 0, 2, 3 } -local HOLD_LABELS = { "Auto detect", "On 2s", "On 3s" } -- No AVI: Resolve refuses its lossless FFV1 there, so that file is MKV. A -- saved avi from an older version falls back to the image sequence. local FORMATS = { "png", "mp4", "mkv" } -local FORMAT_LABELS = { "Image sequence (lossless)", "MP4 (compact)", "MKV (lossless)" } +local FORMAT_LABELS = { "Image sequence", "MP4", "MKV" } local function index_of(list, value) for i, v in ipairs(list) do @@ -799,137 +831,199 @@ local function index_of(list, value) return 0 end +-- Resolve's own greys, so the panel sits beside the Inspector without +-- standing out, and the amber from upflow2x.com for what is pressed or on. +local C = { + panel = "#28282e", field = "#1d1d21", raise = "#37363c", hover = "#423f47", + line = "#45444b", hair = "#35343a", + text = "#dcdcd8", soft = "#a3a2a8", mute = "#7a7b80", + amber = "#f2a93b", amber_hover = "#f6bd63", amber_down = "#d9932c", on_amber = "#17171a", +} +-- "$name" in a style sheet becomes that colour. +local function themed(sheet) + return (sheet:gsub("%$([%w_]+)", C)) +end + -- Resolve's UI Manager ignores #name selectors in a window's style sheet, so -- every widget that looks different from the rest carries its own. -local TITLE = "QLabel { font-size: 20px; font-weight: 700; color: #ededf4; }" -local SUBTITLE = "QLabel { color: #6e6e82; font-size: 11px; padding-bottom: 4px; }" -local SECTION = [[QLabel { color: #9a9ab0; font-size: 10px; font-weight: 700; - padding-top: 8px; letter-spacing: 0.6px; }]] -local CARD = [[QLabel { background-color: #121216; border: 1px solid #1e1e26; - border-radius: 6px; padding: 6px 9px; color: #c8c8d6; font-size: 11px; }]] -local META = "QLabel { color: #5c5c70; font-size: 10px; }" -local PROGRESS = "QLabel { color: #7c6fef; font-size: 11px; font-family: monospace; }" -local START = [[ - QPushButton { background-color: #7c6fef; border: 1px solid #9b90f5; border-radius: 7px; - color: #0b0b0f; font-size: 13px; font-weight: 700; min-height: 34px; } - QPushButton:hover { background-color: #9b90f5; } - QPushButton:disabled { background-color: #2a2740; color: #6a6a80; border-color: #2a2740; } -]] -local GHOST = [[ - QPushButton { background-color: #121216; border: 1px solid #1e1e26; border-radius: 6px; - min-height: 22px; padding: 3px 10px; font-size: 11px; font-weight: 500; color: #b4b4c6; } - QPushButton:hover { background-color: #1a1a22; border-color: #7c6fef; color: #e2e2ea; } - QPushButton:disabled { background-color: #0f0f13; border-color: #18181f; color: #4a4a5a; } -]] - -local RENDER_LABEL = "\xE2\x96\xB2 Render" -local ANALYSIS_HINT = "Analyse marks the drawings on the clip. Or mark the frames yourself with M. Render uses the marked frames." +local TITLE = themed("QLabel { font-size: 17px; font-weight: 600; color: $text; }") +local BADGE = themed([[QLabel { background-color: $amber; color: $on_amber; border-radius: 4px; + padding: 1px 5px; font-family: Consolas, Menlo, monospace; font-size: 10px; font-weight: 700; }]]) +local VERSION = themed("QLabel { color: $mute; font-size: 11px; }") +local HAIRLINE = themed("QLabel { background-color: $hair; }") +local INFO = themed([[QLabel { color: $soft; border: 1px solid $line; border-radius: 8px; + font-family: Georgia, serif; font-size: 10px; font-weight: 700; }]]) +local CARD = themed([[QLabel { background-color: $field; border: 1px solid $hair; + border-radius: 6px; padding: 7px 10px; color: $text; font-size: 12px; }]]) +local NOTE = themed("QLabel { color: $soft; font-size: 11px; }") +local FIELD_LABEL = themed("QLabel { color: $soft; font-size: 12px; }") +local META = themed("QLabel { color: $mute; font-size: 10px; }") +local PROGRESS = themed("QLabel { color: $soft; font-size: 11px; font-family: Consolas, Menlo, monospace; }") +local START = themed([[ + QPushButton { background-color: $amber; border: none; border-radius: 8px; + color: $on_amber; font-size: 15px; font-weight: 700; } + QPushButton:hover { background-color: $amber_hover; } + QPushButton:pressed { background-color: $amber_down; } + QPushButton:disabled { background-color: $raise; color: $mute; } +]]) +-- On or off at a glance: the whole tile lights up when it is on. They are +-- checkable buttons, since a checkbox only takes a click on its dot and words. +local TOGGLE = themed([[ + QPushButton { background-color: $raise; border: 1px solid $line; border-radius: 8px; + padding: 0px 12px; min-height: 34px; font-size: 13px; color: $soft; } + QPushButton:hover { background-color: $hover; } + QPushButton:checked { border-color: $amber; color: $text; background-color: rgba(242, 169, 59, 40); } + QPushButton:checked:hover { background-color: rgba(242, 169, 59, 64); } +]]) +local BUTTON = themed([[ + QPushButton { background-color: $raise; border: 1px solid $line; border-radius: 6px; + min-height: 30px; padding: 3px 10px; font-size: 12px; color: $text; } + QPushButton:hover { background-color: $hover; } + QPushButton:pressed { background-color: $field; } + QPushButton:disabled { background-color: $panel; border-color: $hair; color: $mute; } +]]) +local LINK = themed([[ + QPushButton { background-color: transparent; border: none; padding: 2px 4px; + min-height: 18px; font-size: 11px; color: $soft; } + QPushButton:hover { color: $amber; } + QPushButton:disabled { color: $mute; } +]]) +local WINDOW = themed([[ + QWidget { background-color: $panel; color: $text; font-size: 12px; } + QComboBox { + background-color: $field; border: 1px solid $hair; border-radius: 5px; + padding: 4px 8px; min-height: 18px; color: $text; + } + QComboBox:hover { border-color: $line; } + QComboBox QAbstractItemView { + background-color: $field; border: 1px solid $line; outline: none; + selection-background-color: $amber; selection-color: $on_amber; + } + QSlider::groove:horizontal { height: 3px; border-radius: 1px; background-color: $field; } + QSlider::sub-page:horizontal { border-radius: 1px; background-color: $amber; } + QSlider::handle:horizontal { + width: 12px; height: 12px; margin: -5px 0; border-radius: 6px; background-color: $text; + } + QSlider::handle:horizontal:hover { background-color: #ffffff; } + QToolTip { background-color: $field; color: $text; border: 1px solid $line; padding: 5px; } +]]) +-- A thin bar, amber up to how far the run has got. Empty, it keeps its +-- space but shows nothing, so the panel doesn't jump when a run starts. +local function bar_style(fraction) + if not fraction then + return "QLabel { background-color: transparent; }" + end + local f = math.max(0.001, math.min(0.998, fraction)) + -- Qt reads only a decimal point, and some locales format with a comma. + local function stop(value) return (string.format("%.3f", value):gsub(",", ".")) end + return themed(string.format([[QLabel { border-radius: 2px; background-color: qlineargradient( + x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 $amber, stop: %s $amber, + stop: %s $field, stop: 1 $field); }]], stop(f), stop(f + 0.001))) +end + +local RENDER_LABEL = "\xE2\x96\xB6 Render" +local ANALYSIS_HINT = "Mark the drawings with M, or let Analyse do it." + +-- What each part does, shown when the pointer rests on it, so the panel +-- itself only needs names. +local TIPS = { + clip = "Upflow2x works on the clip under the playhead, since Resolve doesn't tell scripts which clip is selected. This box follows the playhead. With the playhead in a gap, it keeps the last clip.", + detection = "Analyse finds the real drawings and puts a marker on each one. Delete a marker to leave its frame out, or add one with M. Render uses the marked frames, and finds the drawings itself on a clip with none.", + threshold = "How big a change Analyse counts as a new drawing. Lower catches small moves, like only a mouth. Higher counts only big ones.", + render = "Render draws on your GPU and puts the result on the track above the clip. Resolve stays usable while it runs.", + upscale = "Draw every frame at 2x with Real-ESRGAN's anime model, on the GPU on Windows. Without a GPU, Resolve's Super Scale does it.\nIt shows on a timeline bigger than the clip.", + interp = "Draw the missing in-betweens with the RIFE network, on the GPU on Windows. Its first use downloads about 50 MB.\nThe render plays at a third of the clip's speed, ready to time remap.\nOff keeps every drawing on its frame and the clip's length, and draws nothing, to upscale only.", + format = "Image sequence is lossless and imports most reliably. MP4 is one small file but lossy, and MKV is one lossless file. All are 8-bit RGB.", +} + +local function info(tip) + return ui:Label{ Text = "i", ToolTip = tip, StyleSheet = INFO, Weight = 0, + Alignment = { AlignCenter = true }, + MinimumSize = { 16, 16 }, MaximumSize = { 16, 16 } } +end + +-- Name, control, (i): the same three columns on every row. +local function row(name, control, tip) + return ui:HGroup{ Weight = 0, Spacing = 8, + ui:Label{ Text = name, StyleSheet = FIELD_LABEL, Weight = 0.2, + Alignment = { AlignLeft = true, AlignVCenter = true } }, + control, + info(tip), + } +end +-- Top to bottom in the order you use it: the clip, Analyse and its +-- threshold, the two switches and the output, then Render. All in one +-- window, so nothing opens on top of it. local win = disp:AddWindow({ ID = "Eternal2x", WindowTitle = "Upflow2x v" .. CURRENT_VERSION, - Geometry = { 100, 100, 460, 700 }, - StyleSheet = [[ - QWidget { background-color: #0b0b0f; color: #e2e2ea; font-size: 12px; } - QPushButton { - background-color: #16161e; border: 1px solid #28283a; border-radius: 7px; - min-height: 32px; padding: 6px 14px; font-weight: 600; color: #e2e2ea; - } - QPushButton:hover { background-color: #1e1e28; border-color: #7c6fef; } - QPushButton:pressed { background-color: #121218; } - QPushButton:disabled { color: #4a4a5a; border-color: #1e1e26; background-color: #101014; } - QComboBox { - background-color: #16161e; border: 1px solid #28283a; border-radius: 6px; - padding: 5px 8px; min-height: 24px; color: #e2e2ea; - } - QComboBox:hover { border-color: #7c6fef; } - QComboBox QAbstractItemView { - background-color: #16161e; border: 1px solid #28283a; - selection-background-color: #7c6fef; selection-color: #0b0b0f; - } - QCheckBox { color: #c8c8d6; font-size: 11px; spacing: 7px; } - QCheckBox::indicator { - width: 15px; height: 15px; border-radius: 3px; - border: 1px solid #28283a; background: #121216; - } - QCheckBox::indicator:checked { background: #7c6fef; border-color: #9b90f5; } - ]], + Geometry = { 100, 100, 380, 506 }, + StyleSheet = WINDOW, }, ui:VGroup{ - Spacing = 2, - - ui:Label{ ID = "Title", Text = "Upflow2x", StyleSheet = TITLE }, - ui:Label{ ID = "SubTitle", StyleSheet = SUBTITLE, - Text = "Smooth 2x for hand-drawn animation \xC2\xB7 v" .. CURRENT_VERSION }, - - ui:Label{ ID = "SourceSection", Text = "SOURCE CLIP", StyleSheet = SECTION }, - ui:Label{ ID = "SourceLabel", Text = "No clip under the playhead.", StyleSheet = CARD, - WordWrap = true, MinimumSize = { 0, 46 } }, - ui:HGroup{ Spacing = 6, - ui:Button{ ID = "RefreshBtn", Text = "Use Clip at Playhead", StyleSheet = GHOST, - ToolTip = "Read the clip under the playhead. Resolve does not tell scripts which clip is selected." }, - ui:Button{ ID = "AnalyseBtn", Text = "Analyse", StyleSheet = GHOST, - ToolTip = "Find the drawings and mark them on the clip. Delete a marker to leave a frame out, or add your own with M." }, - }, - ui:Label{ ID = "AnalysisLabel", Text = ANALYSIS_HINT, - StyleSheet = CARD, WordWrap = true, MinimumSize = { 0, 46 } }, - - ui:Label{ ID = "SettingsSection", Text = "SETTINGS", StyleSheet = SECTION }, - ui:HGroup{ Spacing = 6, - ui:Label{ Text = "Threshold", Weight = 0.4 }, - ui:Slider{ ID = "ThresholdSlider", Weight = 0.5, Minimum = 1, Maximum = 9, - Value = THRESHOLD, Orientation = "Horizontal", - ToolTip = "How big a change Analyse counts as a new drawing. Lower catches small moves, like only a mouth. Higher counts only big ones." }, - ui:Label{ ID = "ThresholdValue", Text = tostring(THRESHOLD), Weight = 0.1 }, - }, - ui:HGroup{ Spacing = 6, - ui:Label{ Text = "Hold pattern", Weight = 0.4 }, - ui:ComboBox{ ID = "HoldCombo", Weight = 0.6, - ToolTip = "Leave on Auto detect unless a clip's timing trips the detector." }, - }, - ui:HGroup{ Spacing = 6, - ui:Label{ Text = "Output", Weight = 0.4 }, - ui:ComboBox{ ID = "FormatCombo", Weight = 0.6, - ToolTip = "Image sequence is lossless and imports most reliably. All outputs are 8-bit RGB." }, - }, - ui:HGroup{ Spacing = 6, - ui:CheckBox{ ID = "UpscaleCB", Text = "Upscale 2x", Checked = DO_UPSCALE, - ToolTip = "Draw every frame at 2x with Real-ESRGAN's anime model, on the GPU on Windows. Without a GPU, Resolve's Super Scale does it.\nIt shows on a timeline bigger than the clip." }, - ui:CheckBox{ ID = "InterpCB", Text = "Interpolate", Checked = DO_INTERPOLATE, - ToolTip = "Draw the missing in-betweens with the RIFE network, on the GPU on Windows. Its first use downloads about 50 MB.\nThe render plays at a third of the clip's speed, ready to time remap.\nOff keeps every drawing on its frame and the clip's length, and draws nothing, to upscale only." }, - ui:CheckBox{ ID = "GrainCB", Text = "Grainy source", Checked = GRAINY, - ToolTip = "The clip has film grain, so held frames all differ a little. Leave off for clean animation, where it can merge fine real changes." }, + Spacing = 8, + + ui:HGroup{ Weight = 0, Spacing = 6, + ui:Label{ ID = "Title", Text = "Upflow", StyleSheet = TITLE, Weight = 0 }, + ui:Label{ ID = "Badge", Text = "2X", StyleSheet = BADGE, Weight = 0, + Alignment = { AlignCenter = true } }, + ui:HGap(0, 1), + ui:Label{ ID = "SubTitle", Text = "v" .. CURRENT_VERSION, StyleSheet = VERSION, Weight = 0, + Alignment = { AlignRight = true, AlignVCenter = true } }, + ui:Button{ ID = "UpdateBtn", Text = "Check for Updates", StyleSheet = LINK, Weight = 0 }, }, - ui:Label{ ID = "RunSection", Text = "RUN", StyleSheet = SECTION }, - ui:Button{ ID = "SmoothBtn", Text = RENDER_LABEL, StyleSheet = START, - MinimumSize = { 0, 38 }, MaximumSize = { 16777215, 38 } }, - ui:Label{ ID = "Progress", Text = "", StyleSheet = PROGRESS }, - ui:Label{ ID = "Status", Text = "Ready.", StyleSheet = CARD, WordWrap = true, - MinimumSize = { 0, 60 } }, - ui:HGroup{ Spacing = 6, - ui:Button{ ID = "CompareBtn", Text = "Show Original", StyleSheet = GHOST, - Enabled = false, - ToolTip = "Switch the smoothed clip off and on to compare it with the original under it." }, - ui:Button{ ID = "FolderBtn", Text = "Show in Folder", StyleSheet = GHOST, - Enabled = false, ToolTip = "Open the folder the render was saved to." }, + ui:Label{ ID = "SourceLabel", Text = "No clip under the playhead.", StyleSheet = CARD, + WordWrap = true, MinimumSize = { 0, 46 }, ToolTip = TIPS.clip }, + + ui:Button{ ID = "AnalyseBtn", Text = "Analyse", StyleSheet = BUTTON, ToolTip = TIPS.detection }, + row("Threshold", ui:HGroup{ Weight = 0.8, Spacing = 8, + ui:Slider{ ID = "ThresholdSlider", Weight = 1, Minimum = 1, Maximum = 9, PageStep = 1, + Value = THRESHOLD, Orientation = "Horizontal" }, + ui:Label{ ID = "ThresholdValue", Text = tostring(THRESHOLD), StyleSheet = FIELD_LABEL, + Weight = 0, MinimumSize = { 10, 0 }, + Alignment = { AlignRight = true, AlignVCenter = true } }, + }, TIPS.threshold), + ui:Label{ ID = "AnalysisLabel", Text = ANALYSIS_HINT, StyleSheet = NOTE, WordWrap = true }, + + ui:Label{ StyleSheet = HAIRLINE, MinimumSize = { 0, 1 }, MaximumSize = { 16777215, 1 } }, + + ui:HGroup{ Weight = 0, Spacing = 8, + ui:Button{ ID = "UpscaleCB", Text = "Upscaling", Checkable = true, Checked = DO_UPSCALE, + StyleSheet = TOGGLE, ToolTip = TIPS.upscale }, + ui:Button{ ID = "InterpCB", Text = "Interpolate", Checkable = true, + Checked = DO_INTERPOLATE, StyleSheet = TOGGLE, ToolTip = TIPS.interp }, }, - ui:HGroup{ Spacing = 6, - ui:Button{ ID = "RefreshStatusBtn", Text = "Refresh Progress", StyleSheet = GHOST }, - ui:Button{ ID = "CancelBtn", Text = "Reset", StyleSheet = GHOST }, + row("Output", ui:ComboBox{ ID = "FormatCombo", Weight = 0.8 }, TIPS.format), + ui:Button{ ID = "SmoothBtn", Text = RENDER_LABEL, StyleSheet = START, ToolTip = TIPS.render, + MinimumSize = { 0, 44 }, MaximumSize = { 16777215, 44 } }, + + ui:Label{ ID = "ProgressBar", Text = "", StyleSheet = bar_style(nil), + MinimumSize = { 0, 4 }, MaximumSize = { 16777215, 4 } }, + ui:Label{ ID = "Progress", Text = "", StyleSheet = PROGRESS, WordWrap = true }, + -- Three lines, as long as a finished render's message runs. Resolve does + -- not grow a wrapped label to fit, so two lines cut it off top and bottom. + ui:Label{ ID = "Status", Text = "Ready.", StyleSheet = NOTE, WordWrap = true, + MinimumSize = { 0, 46 } }, + + ui:HGroup{ Weight = 0, Spacing = 2, + ui:Button{ ID = "CompareBtn", Text = "Show Original", StyleSheet = LINK, Enabled = false, Weight = 0, + ToolTip = "Switch the render off and on to compare it with the original under it." }, + ui:Button{ ID = "FolderBtn", Text = "Show in Folder", StyleSheet = LINK, Enabled = false, Weight = 0, + ToolTip = "Open the folder the render was saved to." }, + ui:HGap(0, 1), + ui:Button{ ID = "RefreshStatusBtn", Text = "Refresh Progress", StyleSheet = LINK, Weight = 0, + ToolTip = "Read how far the run has got, if the panel stopped updating." }, + ui:Button{ ID = "CancelBtn", Text = "Reset", StyleSheet = LINK, Weight = 0, + ToolTip = "Clear a run that stopped without finishing." }, }, - ui:Label{ ID = "Meta", Text = "", StyleSheet = META, WordWrap = true }, - ui:HGroup{ Spacing = 6, - ui:Button{ ID = "UpdateBtn", Text = "\xE2\x86\xBB Check for Updates", StyleSheet = GHOST }, - ui:CheckBox{ ID = "AutoUpdateCB", Text = "Auto-update", Checked = AUTO_UPDATE }, - }, }) + local items = win:GetItems() -for _, label in ipairs(HOLD_LABELS) do items.HoldCombo:AddItem(label) end for _, label in ipairs(FORMAT_LABELS) do items.FormatCombo:AddItem(label) end -items.HoldCombo.CurrentIndex = index_of(HOLDS, BASE_HOLD) items.FormatCombo.CurrentIndex = index_of(FORMATS, FORMAT) -- --------------------------------------------------------------------------- @@ -958,6 +1052,8 @@ local ANALYSING = false local function set_status(msg) items.Status.Text = msg or "" + -- The whole message, for one too long for the box. + items.Status.ToolTip = msg or "" print("[Upflow2x] " .. (msg or "")) end @@ -966,23 +1062,23 @@ end local function set_progress(fraction, stage) if not fraction or (fraction <= 0 and (stage or "") == "") then items.Progress.Text = "" + items.ProgressBar.StyleSheet = bar_style(nil) return end - local width = 24 - local filled = math.floor(fraction * width + 0.5) - if filled > width then filled = width end - local bar = string.rep("\xE2\x96\x88", filled) .. string.rep("\xE2\x96\x91", width - filled) - items.Progress.Text = string.format("%s %d%% %s", bar, math.floor(fraction * 100 + 0.5), - stage or "") + fraction = math.min(1, fraction) + items.ProgressBar.StyleSheet = bar_style(fraction) + items.Progress.Text = string.format("%d%% %s", math.floor(fraction * 100 + 0.5), stage or "") end local function set_busy(busy) RUNNING = busy items.SmoothBtn.Enabled = not busy items.AnalyseBtn.Enabled = not busy - items.RefreshBtn.Enabled = not busy items.UpdateBtn.Enabled = not busy items.SmoothBtn.Text = busy and "Working..." or RENDER_LABEL + items.CancelBtn.Text = busy and "Stop" or "Reset" + items.CancelBtn.ToolTip = busy and "End the run now. Frames it has written stay in its folder." + or "Clear a run that stopped without finishing." end -- Seconds as text that Python's float() reads, whatever the locale. @@ -992,27 +1088,45 @@ end local function range_text(range) if not range then return "Whole file." end - return (string.format("%.2f s to %.2f s of the file, %.2f s long.", - range.start, range.finish, range.finish - range.start) + return (string.format("%.2f s long, from %.2f s of the file.", + range.finish - range.start, range.start) :gsub("(%d),(%d)", "%1.%2")) end -local function refresh_source() +-- The timer reads the clip twice a second, and a tip set again closes under +-- the pointer, so the card is only written when it changes. +local function show_source(text, tip) + if items.SourceLabel.Text ~= text then items.SourceLabel.Text = text end + if items.SourceLabel.ToolTip ~= tip then items.SourceLabel.ToolTip = tip end +end + +-- With keep, a playhead in a gap leaves the last clip, and the card, as they are. +local function refresh_source(keep) local path, err, range, place, item = selected_clip_path() if not path then + if keep and SOURCE_PATH then return false end SOURCE_PATH = nil SOURCE_RANGE = nil SOURCE_PLACE = nil SOURCE_ITEM = nil - items.SourceLabel.Text = err or "No clip under the playhead." + show_source(err or "No clip under the playhead.", TIPS.clip) return false end + -- Another clip, even one cut from the same part of the same file, or + -- another part of the file, makes the last analysis say nothing about it. + local was, at = SOURCE_RANGE, SOURCE_PLACE + if path ~= SOURCE_PATH or (range and range.start) ~= (was and was.start) + or (range and range.finish) ~= (was and was.finish) + or (place and place.item) ~= (at and at.item) + or (place and place.timeline) ~= (at and at.timeline) then + items.AnalysisLabel.Text = ANALYSIS_HINT + items.AnalysisLabel.ToolTip = "" + end SOURCE_PATH = path SOURCE_RANGE = range SOURCE_PLACE = place SOURCE_ITEM = item - items.SourceLabel.Text = short_name(basename(path)) .. "\n" .. range_text(range) - items.Meta.Text = short_path(path, 68) + show_source(short_name(basename(path)) .. "\n" .. range_text(range), TIPS.clip .. "\n\n" .. path) return true end @@ -1102,10 +1216,6 @@ local function current_args(extra) .. " --end-time " .. seconds(SOURCE_RANGE.finish) end end - local hold = HOLDS[(items.HoldCombo.CurrentIndex or 0) + 1] or 0 - if hold and hold > 0 then - args = args .. " --base-hold " .. tostring(hold) - end local fmt = FORMATS[(items.FormatCombo.CurrentIndex or 0) + 1] or "png" args = args .. " --format " .. fmt -- --network-upscale tells the stage this panel skips Super Scale for a @@ -1123,7 +1233,6 @@ local function current_args(extra) else args = args .. " --no-interpolate" end - if items.GrainCB.Checked then args = args .. " --grain" end if THRESHOLD ~= 5 then -- Each step doubles or halves how big a change counts, around the default. args = args .. " --threshold " .. string.format("%.6f", 0.004 * 2 ^ ((THRESHOLD - 5) / 2)):gsub(",", ".") @@ -1443,17 +1552,24 @@ local function apply_status(st) ANALYSING = false local placed, shared = 0, 0 if ANALYSED_ITEM then placed, shared = mark_drawings(ANALYSED_ITEM, st.marks) end - if st.message ~= "" then items.AnalysisLabel.Text = st.message end + local report = st.message if placed > 0 then - items.AnalysisLabel.Text = items.AnalysisLabel.Text .. " Marked " .. placed - .. " frames on the clip." + report = report .. " Marked " .. placed .. " frames on the clip." if shared > 0 then - items.AnalysisLabel.Text = items.AnalysisLabel.Text .. " " .. shared + report = report .. " " .. shared .. (shared == 1 and " more found frame shares a timeline frame with another" or " more found frames share timeline frames with others") .. ", as the clip runs faster than the timeline." end - set_status("Delete a marker to leave its frame out, or add one with M, then press Render.") + local on = st.message:match("[Oo]n (%d)s") + items.AnalysisLabel.Text = "Placed " .. placed .. " markers." + .. (on and (" Animated on " .. on .. "s.") or "") + elseif st.message ~= "" then + items.AnalysisLabel.Text = st.message + end + items.AnalysisLabel.ToolTip = report + if placed > 0 then + set_status("Check the markers, then press Render.") elseif st.marks ~= "" and ANALYSED_ITEM and ANALYSED_ITEM.unreadable then set_status("Analysis complete. Resolve would not say where the clip's frames are, so no markers were placed.") else @@ -1471,10 +1587,10 @@ local function apply_status(st) items.CompareBtn.Text = "Show Original" if ok then set_status(st.message .. " " .. notes) - -- Two lines of status leave no room for a tip as well. + -- Three lines of status leave no room for a tip as well. if hint then items.Meta.Text = hint end elseif where then - -- The status box fits two lines, too few for a path as well. + -- The status box fits three lines, too few for a path as well. items.Meta.Text = short_path(where, 68) print("[Upflow2x] The render is at " .. where) set_status(st.message .. " " .. notes .. " Drag it in from the path below.") @@ -1504,16 +1620,19 @@ local function poll() -- window closed before Python started leaves no file at all. local last = (st and st.updated and st.updated > 0) and st.updated or LAUNCHED_AT local quiet = last and (os.time() - last) or 0 + -- Quiet this long, the button clears the run, so it says Reset. if quiet > STALLED_AFTER and not st then + items.CancelBtn.Text = "Reset" set_status(string.format("Python has not started after %d minutes. Press Reset " .. "and run it again. Log: %s", math.floor(quiet / 60 + 0.5), LOG_PATH)) elseif quiet > STALLED_AFTER then - set_status(string.format("No progress for %d minutes. If the Upflow2x window " - .. "was closed, the render stopped: press Reset and run it again.", - math.floor(quiet / 60 + 0.5))) + items.CancelBtn.Text = "Reset" + set_status(string.format("No progress for %d minutes, so the run has stopped. " + .. "Press Reset and run it again.", math.floor(quiet / 60 + 0.5))) elseif (items.Status.Text or ""):match("^No progress for") or (items.Status.Text or ""):match("^Python has not started") then -- It is reporting again, so the warning no longer holds. + items.CancelBtn.Text = "Stop" set_status("Working. Resolve stays usable while this runs.") end return false @@ -1558,9 +1677,33 @@ local have_timer = pcall(function() timer = ui:Timer({ ID = "PollTimer", Interval = 500 }) end) +-- Where the playhead is, or nil. Cheap enough to read every tick. +local function playhead_spot() + local ok, spot = pcall(function() + local timeline = get_resolve():GetProjectManager():GetCurrentProject():GetCurrentTimeline() + return timeline:GetUniqueId() .. " " .. tostring(timeline:GetCurrentTimecode()) + end) + return ok and spot or nil +end + +-- Between runs the card follows the playhead, so there is nothing to press. +-- During playback the playhead moves every tick, and reading the clip under +-- it each time would cost Resolve, so the card catches up once it stops. +local pick_source +local last_spot = nil local function on_timeout(ev) - if not RUNNING then return end - poll() + if RUNNING then + poll() + return + end + local spot = playhead_spot() + local still = spot == last_spot + last_spot = spot + if not still then return end + local missing = not SOURCE_PATH and items.SourceLabel.Text + if pick_source(true) and missing and items.Status.Text == missing then + set_status("Ready.") + end end if have_timer and timer then @@ -1600,31 +1743,12 @@ function win.On.Eternal2x.Close(ev) disp:ExitLoop() end -function win.On.RefreshBtn.Clicked(ev) - if refresh_source() then - set_status("Ready.") - items.AnalysisLabel.Text = ANALYSIS_HINT - else - set_status(items.SourceLabel.Text) - end -end - -- Work on the clip under the playhead now, not the one there when the panel -- opened. If the playhead has moved off every clip, keep the last one. -local function pick_source() - local previous, label, range, place = SOURCE_PATH, items.SourceLabel.Text, SOURCE_RANGE, - SOURCE_PLACE - local clip = SOURCE_ITEM - if refresh_source() then return true end - if previous then - SOURCE_PATH = previous - SOURCE_RANGE = range - SOURCE_PLACE = place - SOURCE_ITEM = clip - items.SourceLabel.Text = label - return true - end - set_status(items.SourceLabel.Text) +-- With quiet, as the timer calls it, a gap says nothing. +function pick_source(quiet) + if refresh_source(true) or SOURCE_PATH then return true end + if not quiet then set_status(items.SourceLabel.Text) end return false end @@ -1647,8 +1771,9 @@ function win.On.AnalyseBtn.Clicked(ev) STAGE.name = nil set_busy(true) set_progress(0.01, "Starting") - set_status("Analysing. Resolve stays usable while this runs.") + set_status("Analysing...") items.AnalysisLabel.Text = "Analysing..." + items.AnalysisLabel.ToolTip = "" start_run(current_args(" --analyse")) end @@ -1689,10 +1814,10 @@ function win.On.SmoothBtn.Clicked(ev) set_busy(true) set_progress(0.01, "Starting") if #marks > 0 then - set_status("Rendering from the " .. #marks .. " frames marked on the clip. Resolve stays usable.") + set_status("Rendering the " .. #marks .. " marked frames.") start_run(current_args(" --marks-file " .. shell_quote(MARKS_PATH))) else - set_status("No markers on the clip, so Upflow2x finds the drawings. Resolve stays usable.") + set_status("Rendering. No markers, so Upflow2x finds the drawings.") start_run(current_args("")) end end @@ -1741,7 +1866,11 @@ function win.On.FolderBtn.Clicked(ev) shell_quote(LAST_OUTPUT:match("^(.+)/") or ".") .. " >/dev/null 2>&1 &" end print("[Upflow2x] " .. cmd) - execute(cmd) + if is_windows() then + start_script(REVEAL_SCRIPT, cmd) + else + execute(cmd) + end end function win.On.RefreshStatusBtn.Clicked(ev) @@ -1750,6 +1879,8 @@ function win.On.RefreshStatusBtn.Clicked(ev) -- A run this panel started is polled even before Python reports, so one -- that never started is caught. if not read_status() and not RUNNING then + -- Without a timer, this is also how the card catches up. + pick_source(true) set_status("No run in progress.") return end @@ -1758,10 +1889,12 @@ end function win.On.CancelBtn.Clicked(ev) if run_in_progress() then + -- The run sees this at its next frame, then reports Stopped. + local f = io.open(STOP_PATH, "w") + if f then f:close() end set_busy(true) start_polling() - set_status("It is still running, so Reset leaves it alone. If its window was closed, " - .. "Reset works once it has been quiet for 3 minutes.") + set_status(f and "Stopping..." or "Could not stop the run.") return end clear_status() @@ -1778,11 +1911,6 @@ function win.On.ThresholdSlider.ValueChanged(ev) save_conf() end -function win.On.HoldCombo.CurrentIndexChanged(ev) - BASE_HOLD = HOLDS[(items.HoldCombo.CurrentIndex or 0) + 1] or 0 - save_conf() -end - function win.On.FormatCombo.CurrentIndexChanged(ev) FORMAT = FORMATS[(items.FormatCombo.CurrentIndex or 0) + 1] or "png" save_conf() @@ -1798,17 +1926,6 @@ function win.On.InterpCB.Clicked(ev) save_conf() end -function win.On.GrainCB.Clicked(ev) - GRAINY = items.GrainCB.Checked - save_conf() -end - -function win.On.AutoUpdateCB.Clicked(ev) - AUTO_UPDATE = items.AutoUpdateCB.Checked - save_conf() - set_status(AUTO_UPDATE and "Auto-update enabled." or "Auto-update disabled.") -end - local function run_update(auto_mode) if REPO_ROOT == "" then set_status("Plugin folder not configured. Re-run the installer.") @@ -1863,8 +1980,8 @@ if REPO_ROOT == "" then items.Meta.Text = "Plugin folder not configured." set_status("Run Installer/install_eternal2x.py, then restart Resolve.") else - items.Meta.Text = short_path(REPO_ROOT, 68) refresh_source() + start_polling() set_status(SOURCE_PATH and "Ready." or items.SourceLabel.Text) local waiting = read_status() if run_in_progress() then diff --git a/release/upflow2x-mac/Installer/install_eternal2x.py b/release/upflow2x-mac/Installer/install_eternal2x.py index 594c472..e20f022 100644 --- a/release/upflow2x-mac/Installer/install_eternal2x.py +++ b/release/upflow2x-mac/Installer/install_eternal2x.py @@ -95,8 +95,6 @@ def _pick_python(repo_root: Path) -> str: "output_dir": "", "upscale": "true", "interpolate": "true", - "grain": "false", - "base_hold": "0", "threshold": "5", "format": "png", } diff --git a/release/upflow2x-mac/Pipeline/dedupe.py b/release/upflow2x-mac/Pipeline/dedupe.py index 76dd158..2dc8927 100644 --- a/release/upflow2x-mac/Pipeline/dedupe.py +++ b/release/upflow2x-mac/Pipeline/dedupe.py @@ -49,7 +49,7 @@ class DedupePlan: # One source frame index per intermediate frame, in order. output_indices: List[int] = field(default_factory=list) # Every frame came out new, but the scores look like grain on held - # frames: worth turning on Grainy source and analysing again. + # frames, so the drawings are best marked by hand. grain_suspected: bool = False # Made from the frames the editor marked, not from detection. Every frame # between two marks is then an in-between, however long the gap. diff --git a/release/upflow2x-mac/Pipeline/render.py b/release/upflow2x-mac/Pipeline/render.py index d482849..837aa5c 100644 --- a/release/upflow2x-mac/Pipeline/render.py +++ b/release/upflow2x-mac/Pipeline/render.py @@ -15,8 +15,10 @@ from __future__ import annotations +import os import sys from collections import deque +from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path from typing import Callable, Deque, Iterator, List, Optional, Sequence, Tuple @@ -367,6 +369,11 @@ def _iter_plan_frames(cap: cv2.VideoCapture, indices: Sequence[int]) -> Iterator # How many in-betweens are drawn before they are written. BATCH = 8 +# Threads that encode and write PNG frames. A 4K PNG takes about as long to +# encode as the GPU takes to draw and upscale a frame, and OpenCV lets go of +# Python while it encodes, so the two overlap. One core is left for Resolve. +WRITERS = max(1, min(3, (os.cpu_count() or 2) - 1)) + # Encoders to try per container, best quality first. _ENCODERS = { ".mkv": ("FFV1",), # lossless; Resolve refuses FFV1 in AVI @@ -462,6 +469,24 @@ def render_plan( buckets[i].append(p - i) cap = _open(source, frames, progress) + pool = ThreadPoolExecutor(max_workers=WRITERS) if is_sequence else None + # Frames still being written, oldest first. A few at most, so a fast GPU + # can't fill memory with 4K frames waiting for the disk. + pending: Deque[Future] = deque() + + def write_png(frame: np.ndarray, target: Path, number: int) -> None: + # Encode in memory and let Python write the file. cv2.imwrite + # cannot open a non-ASCII path on Windows, so a clip in a folder + # called Épisode 01 would fail on its first frame. + encoded, data = cv2.imencode(".png", frame) + if not encoded: + raise SourceError(f"Could not encode frame {number}.") + try: + target.write_bytes(data.tobytes()) + except OSError as exc: + raise SourceError(f"Could not write {target}: {exc}. Check the disk " + "has space and the folder is writable.") from exc + state = {"writer": None, "written": 0, "width": 0, "height": 0, "upscaler": upscaler, "upscale_note": upscale_note, "last_in": None, "last_out": None} @@ -493,17 +518,10 @@ def emit(frame: np.ndarray) -> None: state["height"], state["width"] = frame.shape[:2] if is_sequence: target = output / f"frame_{state['written']:06d}.png" - # Encode in memory and let Python write the file. cv2.imwrite - # cannot open a non-ASCII path on Windows, so a clip in a folder - # called Épisode 01 would fail on its first frame. - encoded, data = cv2.imencode(".png", frame) - if not encoded: - raise SourceError(f"Could not encode frame {state['written']}.") - try: - target.write_bytes(data.tobytes()) - except OSError as exc: - raise SourceError(f"Could not write {target}: {exc}. Check the disk " - "has space and the folder is writable.") from exc + # A write that failed raises here, on the next frame. + while len(pending) >= 2 * WRITERS: + pending.popleft().result() + pending.append(pool.submit(write_png, frame, target, state["written"])) else: if state["writer"] is None: # Every writer here rounds an odd size down to even without a @@ -522,8 +540,15 @@ def emit(frame: np.ndarray) -> None: # A GPU that gave out mid-render leaves the rest to the CPU, which # takes seconds a frame. A new stage name restarts the time left. on_cpu = not getattr(state["upscaler"], "on_gpu", True) - progress("Rendering, upscaling on the CPU" if on_cpu else "Rendering", - min(1.0, state["written"] / float(len(positions)))) + # The panel shows this as it goes, so it names the work being done. + upscaling = "upscaling on the CPU" if on_cpu else "upscaling" + if state["upscaler"] is None and factor == 1: + stage = "Interpolating" if pair is not None else "Rendering" + elif pair is not None: + stage = "Interpolating and " + upscaling + else: + stage = upscaling.capitalize() + progress(stage, min(1.0, state["written"] / float(len(positions)))) try: indices = plan.output_indices @@ -592,10 +617,15 @@ def change_from(slot: int) -> Optional[float]: prev = frame_at(index) while state["written"] < len(positions): emit(prev) + while pending: + pending.popleft().result() finally: cap.release() if state["writer"] is not None: state["writer"].release() + if pool is not None: + # Even on the way out of an error, no write is left going. + pool.shutdown(wait=True) return RenderResult( output=output, diff --git a/release/upflow2x-mac/Pipeline/rife.py b/release/upflow2x-mac/Pipeline/rife.py index a8f30b3..7ed71ea 100644 --- a/release/upflow2x-mac/Pipeline/rife.py +++ b/release/upflow2x-mac/Pipeline/rife.py @@ -41,6 +41,17 @@ # multiple of this. The frame is padded by repeating its edge, then cropped. _PAD = 64 +# Asked for a frame close to a drawing, the network hands back the drawing +# itself: on Hero's walk, every t up to 0.1 came back as the first drawing +# exactly and every t from 0.9 as the second, and the motion stayed uneven to +# about 0.2 and from 0.8. Slowed three times, that held each drawing for about +# six extra frames, a pause at every drawing. Between 0.25 and 0.75 it is even, +# so it is only ever asked for those. A frame nearer a drawing is drawn between +# the drawing and the halfway frame, and so on until it falls inside. +_EVEN_FROM = 0.25 +# A millionth of a gap: closer to a drawing than any real frame can be. +_ROUNDING = 1e-6 + ProgressFn = Callable[[str, float], None] @@ -83,7 +94,13 @@ def _pip(cmd: List[str], progress: Optional[ProgressFn]) -> subprocess.Completed return subprocess.CompletedProcess(cmd, proc.returncode, out, err) except subprocess.TimeoutExpired: if progress: - progress("Installing AI runtime", 0.0) + try: + progress("Installing AI runtime", 0.0) + except BaseException: + # Stopped. Cut short, pip could leave this Python half + # installed, so it finishes before the run ends. + proc.communicate() + raise def ensure_runtime(progress: Optional[ProgressFn] = None): @@ -164,6 +181,12 @@ def ensure_model(progress: Optional[ProgressFn] = None, path: Path = MODEL_PATH, class Rife: """One loaded model, reused for every pair of drawings in a render.""" + # The pair being drawn and the halfway frames drawn for it, by where they + # sit between its drawings. A render asks for a pair's in-betweens a few + # at a time, so they are kept until the next pair. + _pair = None + _known = None + def __init__(self, progress: Optional[ProgressFn] = None): ort = ensure_runtime(progress) model = ensure_model(progress) @@ -256,10 +279,32 @@ def interpolate_pair(self, a: np.ndarray, b: np.ndarray, times: List[float], out: List[np.ndarray] = [] for t in times: t = float(min(1.0, max(0.0, t))) - if t <= 0.0: + # This close to a drawing is rounding in the time map, not a frame. + if t <= _ROUNDING: out.append(a.copy()) - elif t >= 1.0: + elif t >= 1.0 - _ROUNDING: out.append(b.copy()) else: - out.append(self._run(a, b, t)) + out.append(self._between(a, b, t)) return out + + def _between(self, a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray: + """The frame at t between a and b, asking the network only for 0.25 to 0.75.""" + if self._pair is None or self._pair[0] is not a or self._pair[1] is not b: + self._pair, self._known = (a, b), {0.0: a, 1.0: b} + known = self._known + lo, hi = 0.0, 1.0 + while True: + local = (t - lo) / (hi - lo) + # Every t strictly inside lands here: each halving doubles its + # distance from the nearer end. A gap of 5000 frames slowed three + # times needs 12 halvings and keeps 23 halfway frames. + if _EVEN_FROM <= local <= 1.0 - _EVEN_FROM: + return self._run(known[lo], known[hi], local) + if hi - lo < 2.0 ** -30: + # Only rounding gets here, where both ends are one picture. + return (known[lo] if local < 0.5 else known[hi]).copy() + mid = (lo + hi) / 2.0 + if mid not in known: + known[mid] = self._run(known[lo], known[hi], 0.5) + lo, hi = (lo, mid) if local < 0.5 else (mid, hi) diff --git a/release/upflow2x-mac/README.md b/release/upflow2x-mac/README.md index 5f48b14..afc31a1 100644 --- a/release/upflow2x-mac/README.md +++ b/release/upflow2x-mac/README.md @@ -35,7 +35,7 @@ holds for three times its length. - Finds duplicated frames and works out whether the clip is on 1s, 2s or 3s. It counts a change of colour, a slow fade or a slow pan as motion rather - than a hold. With **Grainy source** ticked, it sees through film grain. + than a hold. - Draws the in-between frames that were never drawn with the RIFE network, run on your own machine, on the GPU on Windows wherever DirectML loads. Moving characters stay in one piece. @@ -109,7 +109,8 @@ move it. 1. Move the playhead over a clip on your timeline. Resolve does not tell scripts which clip is selected, so Upflow2x works on the clip under the - playhead, read again each time you press a button. + playhead. The panel follows the playhead, and in a gap it keeps the + last clip. 2. Open **Workspace → Scripts → Upflow2x**. 3. Click **Analyse**. It reads the clip, tells you what it found, for example `1438 frames, 719 unique drawings (719 held, 50%). Animated on 2s.`, @@ -134,7 +135,7 @@ rendered from the drawings Upflow2x finds on its own. ![The Upflow2x panel in DaVinci Resolve Studio after a render. The rebuilt clip sits on V2, lined up above the original on V1.](https://upflow2x.com/assets/demo/resolve.jpg) The render runs in the background and the panel shows progress and the time -left, so Resolve stays usable while it works. When it finishes, the rebuilt +left, so Resolve stays usable while it works. **Stop** ends it early. When it finishes, the rebuilt clip is imported and placed on the track above the original, starting on the same frame. With Interpolate on it runs three times as long, up to the last marker if the clip has any. It goes on the lowest @@ -161,14 +162,15 @@ Whole file when Resolve does not report one. ### Settings +They are all on the panel, and it remembers them between sessions. Rest the +pointer on one to see what it does. + | Setting | What it does | |---|---| | **Threshold** | How big a change Analyse counts as a new drawing, 1 to 9. Lower catches small moves, like only a mouth or a slow drift. Higher counts only big ones, like the whole shot moving. It does nothing to frames you have marked. | -| **Hold pattern** | Leave on `Auto detect`. Force `On 2s` or `On 3s` if a clip has unusual timing that trips the detector. | | **Output** | `Image sequence` is lossless and the most reliable to import. `MP4` is a single compact file but lossy. `MKV` is a lossless single file. All three are 8-bit RGB: transparency and 10-bit colour in the source are not kept. MP4 and MKV need an even width and height. | -| **Upscale 2x** | Draws every frame at 2x with Real-ESRGAN's anime video model, on the GPU through DirectML, about 0.14 s a 1080p frame on a Radeon. The model ships with Upflow2x. Without a GPU, on a Mac for one, Resolve's Super Scale is applied to the imported clip instead. Either way it only shows on a timeline bigger than the clip, such as 3840x2160 for 1080p footage. A 2x render has four times the pixels. As an image sequence, a 4K frame of anime is about 6 to 7 MB, four to five times its 1080p size. | -| **Interpolate** | On, the RIFE network draws every missing in-between. On Windows it runs on the GPU through DirectML, about 0.13 s an in-between at 2K on a Radeon, and falls back to the CPU, about ten times slower. If its Python already has ONNX Runtime without DirectML, that one is used, on the CPU. Its first run installs ONNX Runtime and downloads the model, about 50 MB, so it needs a connection once; without one, every drawing is kept as drawn and the panel says why. On, the render is three times longer, so it takes three times as long to render and an image sequence takes three times the disk. Off skips the in-betweens, so every drawing comes back on its original frame and the render keeps the clip's length. Use it with Upscale 2x to upscale only. | -| **Grainy source** | For film grain, which makes every held frame differ a little, so they all look new. It raises the threshold above the clip's own grain. Leave it off for clean animation, where it can merge fine real changes. Analyse suggests it when every frame differs in a way that looks like grain. | +| **Upscaling** | Draws every frame at 2x with Real-ESRGAN's anime video model, on the GPU through DirectML, about 0.14 s a 1080p frame on a Radeon. The model ships with Upflow2x. Without a GPU, on a Mac for one, Resolve's Super Scale is applied to the imported clip instead. Either way it only shows on a timeline bigger than the clip, such as 3840x2160 for 1080p footage. A 2x render has four times the pixels. As an image sequence, a 4K frame of anime is about 6 to 7 MB, four to five times its 1080p size. | +| **Interpolate** | On, the RIFE network draws every missing in-between. On Windows it runs on the GPU through DirectML, about 0.13 s an in-between at 2K on a Radeon, and falls back to the CPU, about ten times slower. If its Python already has ONNX Runtime without DirectML, that one is used, on the CPU. Its first run installs ONNX Runtime and downloads the model, about 50 MB, so it needs a connection once; without one, every drawing is kept as drawn and the panel says why. On, the render is three times longer, so it takes three times as long to render and an image sequence takes three times the disk. Off skips the in-betweens, so every drawing comes back on its original frame and the render keeps the clip's length. With Upscaling on, that only upscales. | Settings are remembered between sessions. @@ -220,9 +222,9 @@ afterwards. | Resolve says you need Studio | Since 19.1 the free edition blocks script panels. Upflow2x needs Studio. | | Clicking Upflow2x does nothing, or "Plugin folder not configured" | The plugin folder moved after installing, and Workspace → Console says `UI script not found`. Re-run `python Installer/install_eternal2x.py` from where it is now. | | "Could not reach Resolve" | Open the panel from Workspace → Scripts, not from a terminal. | -| "No clip under the playhead" | Move the playhead over the clip, then press **Use Clip at Playhead**. | +| "No clip under the playhead" | Move the playhead over the clip, and the panel picks it up. | | "No duplicate frames. This clip is already on 1s." | The clip has no duplicated frames. It is live action, or already on 1s. | -| "Every frame differs, but the differences look like grain" | Tick **Grainy source** and analyse again. Heavy grain on a clip with long holds can still hide the duplicates. | +| "Every frame differs, but the differences look like grain" | Mark the drawings with M, then press **Render**. From the command line, `--grain` raises the threshold above the grain. | | "...has a variable frame rate" | Render the clip to a constant frame rate first, from Resolve or with ffmpeg, as the message says. | | Progress seems stuck | Press **Refresh Progress**. If Python stopped, the panel says so and shows its last error. If a run was interrupted, press **Reset**. | | Resolve would not import the render | The render still finished. The panel prints where it is, so drag it into your media pool. Switching Output to `Image sequence` usually avoids this. | diff --git a/release/upflow2x-mac/Stages/resolve_smooth.py b/release/upflow2x-mac/Stages/resolve_smooth.py index b8a03f1..b828629 100644 --- a/release/upflow2x-mac/Stages/resolve_smooth.py +++ b/release/upflow2x-mac/Stages/resolve_smooth.py @@ -29,6 +29,10 @@ IMPORT_ERROR = None +class Stopped(BaseException): + """The panel's Stop. Not an Exception, so nothing on the way out catches it.""" + + class StatusFile: """Progress the panel can poll while the render runs in the background. @@ -40,6 +44,11 @@ class StatusFile: def __init__(self, path: Optional[str]) -> None: self.path = Path(path) if path else None + # The panel's Stop creates this, and the run ends at its next frame. + self.stop_path = self.path.with_suffix(".stop") if self.path else None + # Set once the result is going into Resolve. Stopped halfway there it + # would be imported but not placed, so from then on the run finishes. + self.committed = False self.stage = "" self.fraction = 0.0 self.message = "" @@ -96,6 +105,11 @@ def write(self, *, done: bool = False, ok: bool = False, # but losing the final write leaves the panel waiting forever. attempts = 100 if done else 3 for attempt in range(attempts): + # A Stop pressed after the last frame, while the writes finished + # or while this waits for the panel to let go of the file, still + # counts: the run reports Stopped, not a result to import. + if done and ok: + self.check_stop() try: temp.replace(self.path) return @@ -103,7 +117,13 @@ def write(self, *, done: bool = False, ok: bool = False, if attempt + 1 < attempts: time.sleep(0.02) + def check_stop(self) -> None: + """Raise Stopped if the panel's Stop was pressed in time.""" + if not self.committed and self.stop_path is not None and self.stop_path.exists(): + raise Stopped() + def progress(self, stage: str, fraction: float) -> None: + self.check_stop() self.stage = stage self.fraction = fraction self.write() @@ -397,10 +417,10 @@ def _run(args, summary: dict) -> int: summary["skipped"] = "no duplicate frames" if plan.grain_suspected: why = ("Every frame differs, but by about the same small amount, as grain " - "makes held frames differ. If the clip is grainy, turn on Grainy " - "source (--grain) and try again.") - short = ("Every frame differs, but the differences look like grain. If the " - "clip is grainy, turn on Grainy source and analyse again.") + "makes held frames differ. If the clip is grainy, mark the drawings " + "with M, or run with --grain and try again.") + short = ("Every frame differs, but the differences look like grain. Mark the " + "drawings with M, then press Render.") print(f"Nothing to do. {why}") summary["ok"] = True status.write(done=True, ok=True, message=short) @@ -424,6 +444,8 @@ def _run(args, summary: dict) -> int: summary["error"] = str(exc) status.write(done=True, ok=False, message=str(exc)) return 2 + # Stopped while the last frames were written: nothing goes into Resolve. + status.check_stop() print(f"Wrote {result.frames_written} frames at {result.fps:.3f} fps.") if result.note: @@ -456,41 +478,50 @@ def _run(args, summary: dict) -> int: status.write(done=True, ok=True, message=_finished(plan, result)) return 0 - media_item, imported = bridge.import_media(project, result.output, - frame_count=result.frames_written, - fps=result.fps) - print(("Imported: " if imported else "Import failed: ") + imported.detail) - summary["import"] = {"ok": imported.ok, "detail": imported.detail} + # Too late to stop from here, or the clip would be left half placed. + status.committed = True + try: + media_item, imported = bridge.import_media(project, result.output, + frame_count=result.frames_written, + fps=result.fps) + print(("Imported: " if imported else "Import failed: ") + imported.detail) + summary["import"] = {"ok": imported.ok, "detail": imported.detail} + + if not imported: + print(f"The render is fine and is waiting at: {result.output}") + print("Drag it into your media pool to finish.") + summary["ok"] = True + status.write(done=True, ok=True, + message=f"Rendered, but Resolve would not import it. " + f"Drag in {result.output.name} to finish.") + return 0 + + if result.upscaled: + summary["upscale"] = {"ok": True, "detail": "Drawn 2x by Real-ESRGAN."} + elif cfg.upscale_factor > 1: + studio = bridge.is_studio(resolve) + if studio is False: + print("Skipping upscale: Super Scale needs DaVinci Resolve Studio.") + summary["upscale"] = {"ok": False, "detail": "not Studio"} + else: + scaled = bridge.set_super_scale(media_item, cfg.upscale_factor) + print(("Upscale: " if scaled else "Upscale not applied: ") + scaled.detail) + summary["upscale"] = {"ok": scaled.ok, "detail": scaled.detail} + + appended = bridge.append_to_timeline(project, media_item) + print(("Timeline: " if appended else "Timeline not updated: ") + appended.detail) + summary["append"] = {"ok": appended.ok, "detail": appended.detail} - if not imported: - print(f"The render is fine and is waiting at: {result.output}") - print("Drag it into your media pool to finish.") summary["ok"] = True - status.write(done=True, ok=True, - message=f"Rendered, but Resolve would not import it. " - f"Drag in {result.output.name} to finish.") + print("Done.") + status.write(done=True, ok=True, message=_finished(plan, result)) return 0 - - if result.upscaled: - summary["upscale"] = {"ok": True, "detail": "Drawn 2x by Real-ESRGAN."} - elif cfg.upscale_factor > 1: - studio = bridge.is_studio(resolve) - if studio is False: - print("Skipping upscale: Super Scale needs DaVinci Resolve Studio.") - summary["upscale"] = {"ok": False, "detail": "not Studio"} - else: - scaled = bridge.set_super_scale(media_item, cfg.upscale_factor) - print(("Upscale: " if scaled else "Upscale not applied: ") + scaled.detail) - summary["upscale"] = {"ok": scaled.ok, "detail": scaled.detail} - - appended = bridge.append_to_timeline(project, media_item) - print(("Timeline: " if appended else "Timeline not updated: ") + appended.detail) - summary["append"] = {"ok": appended.ok, "detail": appended.detail} - - summary["ok"] = True - print("Done.") - status.write(done=True, ok=True, message=_finished(plan, result)) - return 0 + finally: + # However this ends, a Stop that came now was not used, and would end + # the next run. + if status.stop_path is not None: + with contextlib.suppress(OSError): + status.stop_path.unlink() def _run_reporting(args, summary: dict) -> int: @@ -505,6 +536,17 @@ def _run_reporting(args, summary: dict) -> int: return 3 try: return _run(args, summary) + except Stopped: + print("Stopped.") + # It may have stopped after the summary already said it worked. + summary["ok"] = False + summary.pop("import_path", None) + summary["error"] = "Stopped." + status = StatusFile(args.status_file) + with contextlib.suppress(OSError): + status.stop_path.unlink() + status.write(done=True, ok=False, message="Stopped.") + return 1 except Exception as exc: traceback.print_exc() message = f"Upflow2x hit an unexpected error: {exc}" diff --git a/release/upflow2x-win/Installer/Eternal2x.lua b/release/upflow2x-win/Installer/Eternal2x.lua index 5a392e1..73fb427 100644 --- a/release/upflow2x-win/Installer/Eternal2x.lua +++ b/release/upflow2x-win/Installer/Eternal2x.lua @@ -197,13 +197,13 @@ local AUTO_UPDATE = parse_bool(conf["auto_update"], true) local OUTPUT_DIR = conf["output_dir"] or "" local DO_UPSCALE = parse_bool(conf["upscale"], true) local DO_INTERPOLATE = parse_bool(conf["interpolate"], true) -local GRAINY = parse_bool(conf["grain"], false) -local BASE_HOLD = tonumber(conf["base_hold"] or "0") or 0 -- How big a change Analyse counts as a new drawing, 1 to 9, 5 the default. local THRESHOLD = math.max(1, math.min(9, math.floor(tonumber(conf["threshold"] or "5") or 5))) local FORMAT = conf["format"] or "png" local STATUS_PATH = join_path(REPO_ROOT ~= "" and REPO_ROOT or root, ".eternal2x_status.json") +-- Stop creates this, and the run ends at its next frame. +local STOP_PATH = join_path(REPO_ROOT ~= "" and REPO_ROOT or root, ".eternal2x_status.stop") -- The frames marked on the clip go to the stage in this file. A long clip's -- list does not fit on a Windows command line. local MARKS_PATH = join_path(REPO_ROOT ~= "" and REPO_ROOT or root, ".eternal2x_marks.txt") @@ -230,9 +230,7 @@ local function save_conf() f:write("output_dir=" .. OUTPUT_DIR .. "\n") f:write("upscale=" .. (DO_UPSCALE and "true" or "false") .. "\n") f:write("interpolate=" .. (DO_INTERPOLATE and "true" or "false") .. "\n") - f:write("grain=" .. (GRAINY and "true" or "false") .. "\n") f:write("threshold=" .. tostring(THRESHOLD) .. "\n") - f:write("base_hold=" .. tostring(BASE_HOLD) .. "\n") f:write("format=" .. FORMAT .. "\n") f:close() end @@ -594,6 +592,7 @@ end -- minimised window through `start`. UTF-16 also carries non-ASCII paths that -- os.execute's ANSI code page cannot. Without LuaJIT's ffi, os.execute runs. local hidden_execute = nil +local hidden_start = nil if is_windows() then pcall(function() local ffi = require("ffi") @@ -640,6 +639,28 @@ if is_windows() then k32.CloseHandle(pi.process) return true, tonumber(code[0]) end + -- Starts `cmd` in `dir` and leaves it running, with no window. + hidden_start = function(cmd, dir) + local function wide(s) + local size = k32.MultiByteToWideChar(65001, 0, s, #s, nil, 0) + if size <= 0 then return nil end + local out = ffi.new("wchar_t[?]", size + 1) + k32.MultiByteToWideChar(65001, 0, s, #s, out, size) + return out + end + local line = wide((os.getenv("ComSpec") or "cmd.exe") .. " /c " .. cmd) + local cwd = wide(dir) + if not line or not cwd then return false end + local si = ffi.new("E2X_STARTUPINFOW") + si.cb = ffi.sizeof(si) + local pi = ffi.new("E2X_PROCESS_INFORMATION") + if k32.CreateProcessW(nil, line, nil, nil, 0, 0x08000000, nil, cwd, si, pi) == 0 then + return false + end + k32.CloseHandle(pi.thread) + k32.CloseHandle(pi.process) + return true + end end) end @@ -659,11 +680,25 @@ local function run_blocking(module_name, args, log) return (ok == true or ok == 0) end +-- Runs a script in the plugin folder by name, as a render does. With no +-- window, there is none to close by mistake, which would end the run without +-- a word. Where that cannot start, `fallback` opens a minimised one. +local function start_script(script, fallback) + if hidden_start then + local dir = (REPO_ROOT ~= "" and REPO_ROOT or root):gsub("/", "\\") + local ok, started = pcall(hidden_start, ".\\" .. basename(script), dir) + if ok and started then return true end + end + local ok = execute(fallback) + return (ok == true or ok == 0) +end + -- True once the run is on its way. Python reports from then on. local function run_background(module_name, args) local cmd = build_command(module_name, args, true) if not cmd then return false end print("[Upflow2x] " .. cmd) + if is_windows() then return start_script(RUN_SCRIPT, cmd) end local ok = execute(cmd) return (ok == true or ok == 0) end @@ -765,6 +800,7 @@ end local function clear_status() os.remove(STATUS_PATH) os.remove(EXIT_PATH) + os.remove(STOP_PATH) end -- The exit code of the last background run, or nil while it is still going. @@ -783,14 +819,10 @@ end local ui = fu.UIManager local disp = bmd.UIDispatcher(ui) --- No "On 1s": planning on 1s gives every frame its own slot, so it would --- change nothing. A saved 1 from an older version falls back to Auto detect. -local HOLDS = { 0, 2, 3 } -local HOLD_LABELS = { "Auto detect", "On 2s", "On 3s" } -- No AVI: Resolve refuses its lossless FFV1 there, so that file is MKV. A -- saved avi from an older version falls back to the image sequence. local FORMATS = { "png", "mp4", "mkv" } -local FORMAT_LABELS = { "Image sequence (lossless)", "MP4 (compact)", "MKV (lossless)" } +local FORMAT_LABELS = { "Image sequence", "MP4", "MKV" } local function index_of(list, value) for i, v in ipairs(list) do @@ -799,137 +831,199 @@ local function index_of(list, value) return 0 end +-- Resolve's own greys, so the panel sits beside the Inspector without +-- standing out, and the amber from upflow2x.com for what is pressed or on. +local C = { + panel = "#28282e", field = "#1d1d21", raise = "#37363c", hover = "#423f47", + line = "#45444b", hair = "#35343a", + text = "#dcdcd8", soft = "#a3a2a8", mute = "#7a7b80", + amber = "#f2a93b", amber_hover = "#f6bd63", amber_down = "#d9932c", on_amber = "#17171a", +} +-- "$name" in a style sheet becomes that colour. +local function themed(sheet) + return (sheet:gsub("%$([%w_]+)", C)) +end + -- Resolve's UI Manager ignores #name selectors in a window's style sheet, so -- every widget that looks different from the rest carries its own. -local TITLE = "QLabel { font-size: 20px; font-weight: 700; color: #ededf4; }" -local SUBTITLE = "QLabel { color: #6e6e82; font-size: 11px; padding-bottom: 4px; }" -local SECTION = [[QLabel { color: #9a9ab0; font-size: 10px; font-weight: 700; - padding-top: 8px; letter-spacing: 0.6px; }]] -local CARD = [[QLabel { background-color: #121216; border: 1px solid #1e1e26; - border-radius: 6px; padding: 6px 9px; color: #c8c8d6; font-size: 11px; }]] -local META = "QLabel { color: #5c5c70; font-size: 10px; }" -local PROGRESS = "QLabel { color: #7c6fef; font-size: 11px; font-family: monospace; }" -local START = [[ - QPushButton { background-color: #7c6fef; border: 1px solid #9b90f5; border-radius: 7px; - color: #0b0b0f; font-size: 13px; font-weight: 700; min-height: 34px; } - QPushButton:hover { background-color: #9b90f5; } - QPushButton:disabled { background-color: #2a2740; color: #6a6a80; border-color: #2a2740; } -]] -local GHOST = [[ - QPushButton { background-color: #121216; border: 1px solid #1e1e26; border-radius: 6px; - min-height: 22px; padding: 3px 10px; font-size: 11px; font-weight: 500; color: #b4b4c6; } - QPushButton:hover { background-color: #1a1a22; border-color: #7c6fef; color: #e2e2ea; } - QPushButton:disabled { background-color: #0f0f13; border-color: #18181f; color: #4a4a5a; } -]] - -local RENDER_LABEL = "\xE2\x96\xB2 Render" -local ANALYSIS_HINT = "Analyse marks the drawings on the clip. Or mark the frames yourself with M. Render uses the marked frames." +local TITLE = themed("QLabel { font-size: 17px; font-weight: 600; color: $text; }") +local BADGE = themed([[QLabel { background-color: $amber; color: $on_amber; border-radius: 4px; + padding: 1px 5px; font-family: Consolas, Menlo, monospace; font-size: 10px; font-weight: 700; }]]) +local VERSION = themed("QLabel { color: $mute; font-size: 11px; }") +local HAIRLINE = themed("QLabel { background-color: $hair; }") +local INFO = themed([[QLabel { color: $soft; border: 1px solid $line; border-radius: 8px; + font-family: Georgia, serif; font-size: 10px; font-weight: 700; }]]) +local CARD = themed([[QLabel { background-color: $field; border: 1px solid $hair; + border-radius: 6px; padding: 7px 10px; color: $text; font-size: 12px; }]]) +local NOTE = themed("QLabel { color: $soft; font-size: 11px; }") +local FIELD_LABEL = themed("QLabel { color: $soft; font-size: 12px; }") +local META = themed("QLabel { color: $mute; font-size: 10px; }") +local PROGRESS = themed("QLabel { color: $soft; font-size: 11px; font-family: Consolas, Menlo, monospace; }") +local START = themed([[ + QPushButton { background-color: $amber; border: none; border-radius: 8px; + color: $on_amber; font-size: 15px; font-weight: 700; } + QPushButton:hover { background-color: $amber_hover; } + QPushButton:pressed { background-color: $amber_down; } + QPushButton:disabled { background-color: $raise; color: $mute; } +]]) +-- On or off at a glance: the whole tile lights up when it is on. They are +-- checkable buttons, since a checkbox only takes a click on its dot and words. +local TOGGLE = themed([[ + QPushButton { background-color: $raise; border: 1px solid $line; border-radius: 8px; + padding: 0px 12px; min-height: 34px; font-size: 13px; color: $soft; } + QPushButton:hover { background-color: $hover; } + QPushButton:checked { border-color: $amber; color: $text; background-color: rgba(242, 169, 59, 40); } + QPushButton:checked:hover { background-color: rgba(242, 169, 59, 64); } +]]) +local BUTTON = themed([[ + QPushButton { background-color: $raise; border: 1px solid $line; border-radius: 6px; + min-height: 30px; padding: 3px 10px; font-size: 12px; color: $text; } + QPushButton:hover { background-color: $hover; } + QPushButton:pressed { background-color: $field; } + QPushButton:disabled { background-color: $panel; border-color: $hair; color: $mute; } +]]) +local LINK = themed([[ + QPushButton { background-color: transparent; border: none; padding: 2px 4px; + min-height: 18px; font-size: 11px; color: $soft; } + QPushButton:hover { color: $amber; } + QPushButton:disabled { color: $mute; } +]]) +local WINDOW = themed([[ + QWidget { background-color: $panel; color: $text; font-size: 12px; } + QComboBox { + background-color: $field; border: 1px solid $hair; border-radius: 5px; + padding: 4px 8px; min-height: 18px; color: $text; + } + QComboBox:hover { border-color: $line; } + QComboBox QAbstractItemView { + background-color: $field; border: 1px solid $line; outline: none; + selection-background-color: $amber; selection-color: $on_amber; + } + QSlider::groove:horizontal { height: 3px; border-radius: 1px; background-color: $field; } + QSlider::sub-page:horizontal { border-radius: 1px; background-color: $amber; } + QSlider::handle:horizontal { + width: 12px; height: 12px; margin: -5px 0; border-radius: 6px; background-color: $text; + } + QSlider::handle:horizontal:hover { background-color: #ffffff; } + QToolTip { background-color: $field; color: $text; border: 1px solid $line; padding: 5px; } +]]) +-- A thin bar, amber up to how far the run has got. Empty, it keeps its +-- space but shows nothing, so the panel doesn't jump when a run starts. +local function bar_style(fraction) + if not fraction then + return "QLabel { background-color: transparent; }" + end + local f = math.max(0.001, math.min(0.998, fraction)) + -- Qt reads only a decimal point, and some locales format with a comma. + local function stop(value) return (string.format("%.3f", value):gsub(",", ".")) end + return themed(string.format([[QLabel { border-radius: 2px; background-color: qlineargradient( + x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 $amber, stop: %s $amber, + stop: %s $field, stop: 1 $field); }]], stop(f), stop(f + 0.001))) +end + +local RENDER_LABEL = "\xE2\x96\xB6 Render" +local ANALYSIS_HINT = "Mark the drawings with M, or let Analyse do it." + +-- What each part does, shown when the pointer rests on it, so the panel +-- itself only needs names. +local TIPS = { + clip = "Upflow2x works on the clip under the playhead, since Resolve doesn't tell scripts which clip is selected. This box follows the playhead. With the playhead in a gap, it keeps the last clip.", + detection = "Analyse finds the real drawings and puts a marker on each one. Delete a marker to leave its frame out, or add one with M. Render uses the marked frames, and finds the drawings itself on a clip with none.", + threshold = "How big a change Analyse counts as a new drawing. Lower catches small moves, like only a mouth. Higher counts only big ones.", + render = "Render draws on your GPU and puts the result on the track above the clip. Resolve stays usable while it runs.", + upscale = "Draw every frame at 2x with Real-ESRGAN's anime model, on the GPU on Windows. Without a GPU, Resolve's Super Scale does it.\nIt shows on a timeline bigger than the clip.", + interp = "Draw the missing in-betweens with the RIFE network, on the GPU on Windows. Its first use downloads about 50 MB.\nThe render plays at a third of the clip's speed, ready to time remap.\nOff keeps every drawing on its frame and the clip's length, and draws nothing, to upscale only.", + format = "Image sequence is lossless and imports most reliably. MP4 is one small file but lossy, and MKV is one lossless file. All are 8-bit RGB.", +} + +local function info(tip) + return ui:Label{ Text = "i", ToolTip = tip, StyleSheet = INFO, Weight = 0, + Alignment = { AlignCenter = true }, + MinimumSize = { 16, 16 }, MaximumSize = { 16, 16 } } +end + +-- Name, control, (i): the same three columns on every row. +local function row(name, control, tip) + return ui:HGroup{ Weight = 0, Spacing = 8, + ui:Label{ Text = name, StyleSheet = FIELD_LABEL, Weight = 0.2, + Alignment = { AlignLeft = true, AlignVCenter = true } }, + control, + info(tip), + } +end +-- Top to bottom in the order you use it: the clip, Analyse and its +-- threshold, the two switches and the output, then Render. All in one +-- window, so nothing opens on top of it. local win = disp:AddWindow({ ID = "Eternal2x", WindowTitle = "Upflow2x v" .. CURRENT_VERSION, - Geometry = { 100, 100, 460, 700 }, - StyleSheet = [[ - QWidget { background-color: #0b0b0f; color: #e2e2ea; font-size: 12px; } - QPushButton { - background-color: #16161e; border: 1px solid #28283a; border-radius: 7px; - min-height: 32px; padding: 6px 14px; font-weight: 600; color: #e2e2ea; - } - QPushButton:hover { background-color: #1e1e28; border-color: #7c6fef; } - QPushButton:pressed { background-color: #121218; } - QPushButton:disabled { color: #4a4a5a; border-color: #1e1e26; background-color: #101014; } - QComboBox { - background-color: #16161e; border: 1px solid #28283a; border-radius: 6px; - padding: 5px 8px; min-height: 24px; color: #e2e2ea; - } - QComboBox:hover { border-color: #7c6fef; } - QComboBox QAbstractItemView { - background-color: #16161e; border: 1px solid #28283a; - selection-background-color: #7c6fef; selection-color: #0b0b0f; - } - QCheckBox { color: #c8c8d6; font-size: 11px; spacing: 7px; } - QCheckBox::indicator { - width: 15px; height: 15px; border-radius: 3px; - border: 1px solid #28283a; background: #121216; - } - QCheckBox::indicator:checked { background: #7c6fef; border-color: #9b90f5; } - ]], + Geometry = { 100, 100, 380, 506 }, + StyleSheet = WINDOW, }, ui:VGroup{ - Spacing = 2, - - ui:Label{ ID = "Title", Text = "Upflow2x", StyleSheet = TITLE }, - ui:Label{ ID = "SubTitle", StyleSheet = SUBTITLE, - Text = "Smooth 2x for hand-drawn animation \xC2\xB7 v" .. CURRENT_VERSION }, - - ui:Label{ ID = "SourceSection", Text = "SOURCE CLIP", StyleSheet = SECTION }, - ui:Label{ ID = "SourceLabel", Text = "No clip under the playhead.", StyleSheet = CARD, - WordWrap = true, MinimumSize = { 0, 46 } }, - ui:HGroup{ Spacing = 6, - ui:Button{ ID = "RefreshBtn", Text = "Use Clip at Playhead", StyleSheet = GHOST, - ToolTip = "Read the clip under the playhead. Resolve does not tell scripts which clip is selected." }, - ui:Button{ ID = "AnalyseBtn", Text = "Analyse", StyleSheet = GHOST, - ToolTip = "Find the drawings and mark them on the clip. Delete a marker to leave a frame out, or add your own with M." }, - }, - ui:Label{ ID = "AnalysisLabel", Text = ANALYSIS_HINT, - StyleSheet = CARD, WordWrap = true, MinimumSize = { 0, 46 } }, - - ui:Label{ ID = "SettingsSection", Text = "SETTINGS", StyleSheet = SECTION }, - ui:HGroup{ Spacing = 6, - ui:Label{ Text = "Threshold", Weight = 0.4 }, - ui:Slider{ ID = "ThresholdSlider", Weight = 0.5, Minimum = 1, Maximum = 9, - Value = THRESHOLD, Orientation = "Horizontal", - ToolTip = "How big a change Analyse counts as a new drawing. Lower catches small moves, like only a mouth. Higher counts only big ones." }, - ui:Label{ ID = "ThresholdValue", Text = tostring(THRESHOLD), Weight = 0.1 }, - }, - ui:HGroup{ Spacing = 6, - ui:Label{ Text = "Hold pattern", Weight = 0.4 }, - ui:ComboBox{ ID = "HoldCombo", Weight = 0.6, - ToolTip = "Leave on Auto detect unless a clip's timing trips the detector." }, - }, - ui:HGroup{ Spacing = 6, - ui:Label{ Text = "Output", Weight = 0.4 }, - ui:ComboBox{ ID = "FormatCombo", Weight = 0.6, - ToolTip = "Image sequence is lossless and imports most reliably. All outputs are 8-bit RGB." }, - }, - ui:HGroup{ Spacing = 6, - ui:CheckBox{ ID = "UpscaleCB", Text = "Upscale 2x", Checked = DO_UPSCALE, - ToolTip = "Draw every frame at 2x with Real-ESRGAN's anime model, on the GPU on Windows. Without a GPU, Resolve's Super Scale does it.\nIt shows on a timeline bigger than the clip." }, - ui:CheckBox{ ID = "InterpCB", Text = "Interpolate", Checked = DO_INTERPOLATE, - ToolTip = "Draw the missing in-betweens with the RIFE network, on the GPU on Windows. Its first use downloads about 50 MB.\nThe render plays at a third of the clip's speed, ready to time remap.\nOff keeps every drawing on its frame and the clip's length, and draws nothing, to upscale only." }, - ui:CheckBox{ ID = "GrainCB", Text = "Grainy source", Checked = GRAINY, - ToolTip = "The clip has film grain, so held frames all differ a little. Leave off for clean animation, where it can merge fine real changes." }, + Spacing = 8, + + ui:HGroup{ Weight = 0, Spacing = 6, + ui:Label{ ID = "Title", Text = "Upflow", StyleSheet = TITLE, Weight = 0 }, + ui:Label{ ID = "Badge", Text = "2X", StyleSheet = BADGE, Weight = 0, + Alignment = { AlignCenter = true } }, + ui:HGap(0, 1), + ui:Label{ ID = "SubTitle", Text = "v" .. CURRENT_VERSION, StyleSheet = VERSION, Weight = 0, + Alignment = { AlignRight = true, AlignVCenter = true } }, + ui:Button{ ID = "UpdateBtn", Text = "Check for Updates", StyleSheet = LINK, Weight = 0 }, }, - ui:Label{ ID = "RunSection", Text = "RUN", StyleSheet = SECTION }, - ui:Button{ ID = "SmoothBtn", Text = RENDER_LABEL, StyleSheet = START, - MinimumSize = { 0, 38 }, MaximumSize = { 16777215, 38 } }, - ui:Label{ ID = "Progress", Text = "", StyleSheet = PROGRESS }, - ui:Label{ ID = "Status", Text = "Ready.", StyleSheet = CARD, WordWrap = true, - MinimumSize = { 0, 60 } }, - ui:HGroup{ Spacing = 6, - ui:Button{ ID = "CompareBtn", Text = "Show Original", StyleSheet = GHOST, - Enabled = false, - ToolTip = "Switch the smoothed clip off and on to compare it with the original under it." }, - ui:Button{ ID = "FolderBtn", Text = "Show in Folder", StyleSheet = GHOST, - Enabled = false, ToolTip = "Open the folder the render was saved to." }, + ui:Label{ ID = "SourceLabel", Text = "No clip under the playhead.", StyleSheet = CARD, + WordWrap = true, MinimumSize = { 0, 46 }, ToolTip = TIPS.clip }, + + ui:Button{ ID = "AnalyseBtn", Text = "Analyse", StyleSheet = BUTTON, ToolTip = TIPS.detection }, + row("Threshold", ui:HGroup{ Weight = 0.8, Spacing = 8, + ui:Slider{ ID = "ThresholdSlider", Weight = 1, Minimum = 1, Maximum = 9, PageStep = 1, + Value = THRESHOLD, Orientation = "Horizontal" }, + ui:Label{ ID = "ThresholdValue", Text = tostring(THRESHOLD), StyleSheet = FIELD_LABEL, + Weight = 0, MinimumSize = { 10, 0 }, + Alignment = { AlignRight = true, AlignVCenter = true } }, + }, TIPS.threshold), + ui:Label{ ID = "AnalysisLabel", Text = ANALYSIS_HINT, StyleSheet = NOTE, WordWrap = true }, + + ui:Label{ StyleSheet = HAIRLINE, MinimumSize = { 0, 1 }, MaximumSize = { 16777215, 1 } }, + + ui:HGroup{ Weight = 0, Spacing = 8, + ui:Button{ ID = "UpscaleCB", Text = "Upscaling", Checkable = true, Checked = DO_UPSCALE, + StyleSheet = TOGGLE, ToolTip = TIPS.upscale }, + ui:Button{ ID = "InterpCB", Text = "Interpolate", Checkable = true, + Checked = DO_INTERPOLATE, StyleSheet = TOGGLE, ToolTip = TIPS.interp }, }, - ui:HGroup{ Spacing = 6, - ui:Button{ ID = "RefreshStatusBtn", Text = "Refresh Progress", StyleSheet = GHOST }, - ui:Button{ ID = "CancelBtn", Text = "Reset", StyleSheet = GHOST }, + row("Output", ui:ComboBox{ ID = "FormatCombo", Weight = 0.8 }, TIPS.format), + ui:Button{ ID = "SmoothBtn", Text = RENDER_LABEL, StyleSheet = START, ToolTip = TIPS.render, + MinimumSize = { 0, 44 }, MaximumSize = { 16777215, 44 } }, + + ui:Label{ ID = "ProgressBar", Text = "", StyleSheet = bar_style(nil), + MinimumSize = { 0, 4 }, MaximumSize = { 16777215, 4 } }, + ui:Label{ ID = "Progress", Text = "", StyleSheet = PROGRESS, WordWrap = true }, + -- Three lines, as long as a finished render's message runs. Resolve does + -- not grow a wrapped label to fit, so two lines cut it off top and bottom. + ui:Label{ ID = "Status", Text = "Ready.", StyleSheet = NOTE, WordWrap = true, + MinimumSize = { 0, 46 } }, + + ui:HGroup{ Weight = 0, Spacing = 2, + ui:Button{ ID = "CompareBtn", Text = "Show Original", StyleSheet = LINK, Enabled = false, Weight = 0, + ToolTip = "Switch the render off and on to compare it with the original under it." }, + ui:Button{ ID = "FolderBtn", Text = "Show in Folder", StyleSheet = LINK, Enabled = false, Weight = 0, + ToolTip = "Open the folder the render was saved to." }, + ui:HGap(0, 1), + ui:Button{ ID = "RefreshStatusBtn", Text = "Refresh Progress", StyleSheet = LINK, Weight = 0, + ToolTip = "Read how far the run has got, if the panel stopped updating." }, + ui:Button{ ID = "CancelBtn", Text = "Reset", StyleSheet = LINK, Weight = 0, + ToolTip = "Clear a run that stopped without finishing." }, }, - ui:Label{ ID = "Meta", Text = "", StyleSheet = META, WordWrap = true }, - ui:HGroup{ Spacing = 6, - ui:Button{ ID = "UpdateBtn", Text = "\xE2\x86\xBB Check for Updates", StyleSheet = GHOST }, - ui:CheckBox{ ID = "AutoUpdateCB", Text = "Auto-update", Checked = AUTO_UPDATE }, - }, }) + local items = win:GetItems() -for _, label in ipairs(HOLD_LABELS) do items.HoldCombo:AddItem(label) end for _, label in ipairs(FORMAT_LABELS) do items.FormatCombo:AddItem(label) end -items.HoldCombo.CurrentIndex = index_of(HOLDS, BASE_HOLD) items.FormatCombo.CurrentIndex = index_of(FORMATS, FORMAT) -- --------------------------------------------------------------------------- @@ -958,6 +1052,8 @@ local ANALYSING = false local function set_status(msg) items.Status.Text = msg or "" + -- The whole message, for one too long for the box. + items.Status.ToolTip = msg or "" print("[Upflow2x] " .. (msg or "")) end @@ -966,23 +1062,23 @@ end local function set_progress(fraction, stage) if not fraction or (fraction <= 0 and (stage or "") == "") then items.Progress.Text = "" + items.ProgressBar.StyleSheet = bar_style(nil) return end - local width = 24 - local filled = math.floor(fraction * width + 0.5) - if filled > width then filled = width end - local bar = string.rep("\xE2\x96\x88", filled) .. string.rep("\xE2\x96\x91", width - filled) - items.Progress.Text = string.format("%s %d%% %s", bar, math.floor(fraction * 100 + 0.5), - stage or "") + fraction = math.min(1, fraction) + items.ProgressBar.StyleSheet = bar_style(fraction) + items.Progress.Text = string.format("%d%% %s", math.floor(fraction * 100 + 0.5), stage or "") end local function set_busy(busy) RUNNING = busy items.SmoothBtn.Enabled = not busy items.AnalyseBtn.Enabled = not busy - items.RefreshBtn.Enabled = not busy items.UpdateBtn.Enabled = not busy items.SmoothBtn.Text = busy and "Working..." or RENDER_LABEL + items.CancelBtn.Text = busy and "Stop" or "Reset" + items.CancelBtn.ToolTip = busy and "End the run now. Frames it has written stay in its folder." + or "Clear a run that stopped without finishing." end -- Seconds as text that Python's float() reads, whatever the locale. @@ -992,27 +1088,45 @@ end local function range_text(range) if not range then return "Whole file." end - return (string.format("%.2f s to %.2f s of the file, %.2f s long.", - range.start, range.finish, range.finish - range.start) + return (string.format("%.2f s long, from %.2f s of the file.", + range.finish - range.start, range.start) :gsub("(%d),(%d)", "%1.%2")) end -local function refresh_source() +-- The timer reads the clip twice a second, and a tip set again closes under +-- the pointer, so the card is only written when it changes. +local function show_source(text, tip) + if items.SourceLabel.Text ~= text then items.SourceLabel.Text = text end + if items.SourceLabel.ToolTip ~= tip then items.SourceLabel.ToolTip = tip end +end + +-- With keep, a playhead in a gap leaves the last clip, and the card, as they are. +local function refresh_source(keep) local path, err, range, place, item = selected_clip_path() if not path then + if keep and SOURCE_PATH then return false end SOURCE_PATH = nil SOURCE_RANGE = nil SOURCE_PLACE = nil SOURCE_ITEM = nil - items.SourceLabel.Text = err or "No clip under the playhead." + show_source(err or "No clip under the playhead.", TIPS.clip) return false end + -- Another clip, even one cut from the same part of the same file, or + -- another part of the file, makes the last analysis say nothing about it. + local was, at = SOURCE_RANGE, SOURCE_PLACE + if path ~= SOURCE_PATH or (range and range.start) ~= (was and was.start) + or (range and range.finish) ~= (was and was.finish) + or (place and place.item) ~= (at and at.item) + or (place and place.timeline) ~= (at and at.timeline) then + items.AnalysisLabel.Text = ANALYSIS_HINT + items.AnalysisLabel.ToolTip = "" + end SOURCE_PATH = path SOURCE_RANGE = range SOURCE_PLACE = place SOURCE_ITEM = item - items.SourceLabel.Text = short_name(basename(path)) .. "\n" .. range_text(range) - items.Meta.Text = short_path(path, 68) + show_source(short_name(basename(path)) .. "\n" .. range_text(range), TIPS.clip .. "\n\n" .. path) return true end @@ -1102,10 +1216,6 @@ local function current_args(extra) .. " --end-time " .. seconds(SOURCE_RANGE.finish) end end - local hold = HOLDS[(items.HoldCombo.CurrentIndex or 0) + 1] or 0 - if hold and hold > 0 then - args = args .. " --base-hold " .. tostring(hold) - end local fmt = FORMATS[(items.FormatCombo.CurrentIndex or 0) + 1] or "png" args = args .. " --format " .. fmt -- --network-upscale tells the stage this panel skips Super Scale for a @@ -1123,7 +1233,6 @@ local function current_args(extra) else args = args .. " --no-interpolate" end - if items.GrainCB.Checked then args = args .. " --grain" end if THRESHOLD ~= 5 then -- Each step doubles or halves how big a change counts, around the default. args = args .. " --threshold " .. string.format("%.6f", 0.004 * 2 ^ ((THRESHOLD - 5) / 2)):gsub(",", ".") @@ -1443,17 +1552,24 @@ local function apply_status(st) ANALYSING = false local placed, shared = 0, 0 if ANALYSED_ITEM then placed, shared = mark_drawings(ANALYSED_ITEM, st.marks) end - if st.message ~= "" then items.AnalysisLabel.Text = st.message end + local report = st.message if placed > 0 then - items.AnalysisLabel.Text = items.AnalysisLabel.Text .. " Marked " .. placed - .. " frames on the clip." + report = report .. " Marked " .. placed .. " frames on the clip." if shared > 0 then - items.AnalysisLabel.Text = items.AnalysisLabel.Text .. " " .. shared + report = report .. " " .. shared .. (shared == 1 and " more found frame shares a timeline frame with another" or " more found frames share timeline frames with others") .. ", as the clip runs faster than the timeline." end - set_status("Delete a marker to leave its frame out, or add one with M, then press Render.") + local on = st.message:match("[Oo]n (%d)s") + items.AnalysisLabel.Text = "Placed " .. placed .. " markers." + .. (on and (" Animated on " .. on .. "s.") or "") + elseif st.message ~= "" then + items.AnalysisLabel.Text = st.message + end + items.AnalysisLabel.ToolTip = report + if placed > 0 then + set_status("Check the markers, then press Render.") elseif st.marks ~= "" and ANALYSED_ITEM and ANALYSED_ITEM.unreadable then set_status("Analysis complete. Resolve would not say where the clip's frames are, so no markers were placed.") else @@ -1471,10 +1587,10 @@ local function apply_status(st) items.CompareBtn.Text = "Show Original" if ok then set_status(st.message .. " " .. notes) - -- Two lines of status leave no room for a tip as well. + -- Three lines of status leave no room for a tip as well. if hint then items.Meta.Text = hint end elseif where then - -- The status box fits two lines, too few for a path as well. + -- The status box fits three lines, too few for a path as well. items.Meta.Text = short_path(where, 68) print("[Upflow2x] The render is at " .. where) set_status(st.message .. " " .. notes .. " Drag it in from the path below.") @@ -1504,16 +1620,19 @@ local function poll() -- window closed before Python started leaves no file at all. local last = (st and st.updated and st.updated > 0) and st.updated or LAUNCHED_AT local quiet = last and (os.time() - last) or 0 + -- Quiet this long, the button clears the run, so it says Reset. if quiet > STALLED_AFTER and not st then + items.CancelBtn.Text = "Reset" set_status(string.format("Python has not started after %d minutes. Press Reset " .. "and run it again. Log: %s", math.floor(quiet / 60 + 0.5), LOG_PATH)) elseif quiet > STALLED_AFTER then - set_status(string.format("No progress for %d minutes. If the Upflow2x window " - .. "was closed, the render stopped: press Reset and run it again.", - math.floor(quiet / 60 + 0.5))) + items.CancelBtn.Text = "Reset" + set_status(string.format("No progress for %d minutes, so the run has stopped. " + .. "Press Reset and run it again.", math.floor(quiet / 60 + 0.5))) elseif (items.Status.Text or ""):match("^No progress for") or (items.Status.Text or ""):match("^Python has not started") then -- It is reporting again, so the warning no longer holds. + items.CancelBtn.Text = "Stop" set_status("Working. Resolve stays usable while this runs.") end return false @@ -1558,9 +1677,33 @@ local have_timer = pcall(function() timer = ui:Timer({ ID = "PollTimer", Interval = 500 }) end) +-- Where the playhead is, or nil. Cheap enough to read every tick. +local function playhead_spot() + local ok, spot = pcall(function() + local timeline = get_resolve():GetProjectManager():GetCurrentProject():GetCurrentTimeline() + return timeline:GetUniqueId() .. " " .. tostring(timeline:GetCurrentTimecode()) + end) + return ok and spot or nil +end + +-- Between runs the card follows the playhead, so there is nothing to press. +-- During playback the playhead moves every tick, and reading the clip under +-- it each time would cost Resolve, so the card catches up once it stops. +local pick_source +local last_spot = nil local function on_timeout(ev) - if not RUNNING then return end - poll() + if RUNNING then + poll() + return + end + local spot = playhead_spot() + local still = spot == last_spot + last_spot = spot + if not still then return end + local missing = not SOURCE_PATH and items.SourceLabel.Text + if pick_source(true) and missing and items.Status.Text == missing then + set_status("Ready.") + end end if have_timer and timer then @@ -1600,31 +1743,12 @@ function win.On.Eternal2x.Close(ev) disp:ExitLoop() end -function win.On.RefreshBtn.Clicked(ev) - if refresh_source() then - set_status("Ready.") - items.AnalysisLabel.Text = ANALYSIS_HINT - else - set_status(items.SourceLabel.Text) - end -end - -- Work on the clip under the playhead now, not the one there when the panel -- opened. If the playhead has moved off every clip, keep the last one. -local function pick_source() - local previous, label, range, place = SOURCE_PATH, items.SourceLabel.Text, SOURCE_RANGE, - SOURCE_PLACE - local clip = SOURCE_ITEM - if refresh_source() then return true end - if previous then - SOURCE_PATH = previous - SOURCE_RANGE = range - SOURCE_PLACE = place - SOURCE_ITEM = clip - items.SourceLabel.Text = label - return true - end - set_status(items.SourceLabel.Text) +-- With quiet, as the timer calls it, a gap says nothing. +function pick_source(quiet) + if refresh_source(true) or SOURCE_PATH then return true end + if not quiet then set_status(items.SourceLabel.Text) end return false end @@ -1647,8 +1771,9 @@ function win.On.AnalyseBtn.Clicked(ev) STAGE.name = nil set_busy(true) set_progress(0.01, "Starting") - set_status("Analysing. Resolve stays usable while this runs.") + set_status("Analysing...") items.AnalysisLabel.Text = "Analysing..." + items.AnalysisLabel.ToolTip = "" start_run(current_args(" --analyse")) end @@ -1689,10 +1814,10 @@ function win.On.SmoothBtn.Clicked(ev) set_busy(true) set_progress(0.01, "Starting") if #marks > 0 then - set_status("Rendering from the " .. #marks .. " frames marked on the clip. Resolve stays usable.") + set_status("Rendering the " .. #marks .. " marked frames.") start_run(current_args(" --marks-file " .. shell_quote(MARKS_PATH))) else - set_status("No markers on the clip, so Upflow2x finds the drawings. Resolve stays usable.") + set_status("Rendering. No markers, so Upflow2x finds the drawings.") start_run(current_args("")) end end @@ -1741,7 +1866,11 @@ function win.On.FolderBtn.Clicked(ev) shell_quote(LAST_OUTPUT:match("^(.+)/") or ".") .. " >/dev/null 2>&1 &" end print("[Upflow2x] " .. cmd) - execute(cmd) + if is_windows() then + start_script(REVEAL_SCRIPT, cmd) + else + execute(cmd) + end end function win.On.RefreshStatusBtn.Clicked(ev) @@ -1750,6 +1879,8 @@ function win.On.RefreshStatusBtn.Clicked(ev) -- A run this panel started is polled even before Python reports, so one -- that never started is caught. if not read_status() and not RUNNING then + -- Without a timer, this is also how the card catches up. + pick_source(true) set_status("No run in progress.") return end @@ -1758,10 +1889,12 @@ end function win.On.CancelBtn.Clicked(ev) if run_in_progress() then + -- The run sees this at its next frame, then reports Stopped. + local f = io.open(STOP_PATH, "w") + if f then f:close() end set_busy(true) start_polling() - set_status("It is still running, so Reset leaves it alone. If its window was closed, " - .. "Reset works once it has been quiet for 3 minutes.") + set_status(f and "Stopping..." or "Could not stop the run.") return end clear_status() @@ -1778,11 +1911,6 @@ function win.On.ThresholdSlider.ValueChanged(ev) save_conf() end -function win.On.HoldCombo.CurrentIndexChanged(ev) - BASE_HOLD = HOLDS[(items.HoldCombo.CurrentIndex or 0) + 1] or 0 - save_conf() -end - function win.On.FormatCombo.CurrentIndexChanged(ev) FORMAT = FORMATS[(items.FormatCombo.CurrentIndex or 0) + 1] or "png" save_conf() @@ -1798,17 +1926,6 @@ function win.On.InterpCB.Clicked(ev) save_conf() end -function win.On.GrainCB.Clicked(ev) - GRAINY = items.GrainCB.Checked - save_conf() -end - -function win.On.AutoUpdateCB.Clicked(ev) - AUTO_UPDATE = items.AutoUpdateCB.Checked - save_conf() - set_status(AUTO_UPDATE and "Auto-update enabled." or "Auto-update disabled.") -end - local function run_update(auto_mode) if REPO_ROOT == "" then set_status("Plugin folder not configured. Re-run the installer.") @@ -1863,8 +1980,8 @@ if REPO_ROOT == "" then items.Meta.Text = "Plugin folder not configured." set_status("Run Installer/install_eternal2x.py, then restart Resolve.") else - items.Meta.Text = short_path(REPO_ROOT, 68) refresh_source() + start_polling() set_status(SOURCE_PATH and "Ready." or items.SourceLabel.Text) local waiting = read_status() if run_in_progress() then diff --git a/release/upflow2x-win/Installer/install_eternal2x.py b/release/upflow2x-win/Installer/install_eternal2x.py index 594c472..e20f022 100644 --- a/release/upflow2x-win/Installer/install_eternal2x.py +++ b/release/upflow2x-win/Installer/install_eternal2x.py @@ -95,8 +95,6 @@ def _pick_python(repo_root: Path) -> str: "output_dir": "", "upscale": "true", "interpolate": "true", - "grain": "false", - "base_hold": "0", "threshold": "5", "format": "png", } diff --git a/release/upflow2x-win/Pipeline/dedupe.py b/release/upflow2x-win/Pipeline/dedupe.py index 76dd158..2dc8927 100644 --- a/release/upflow2x-win/Pipeline/dedupe.py +++ b/release/upflow2x-win/Pipeline/dedupe.py @@ -49,7 +49,7 @@ class DedupePlan: # One source frame index per intermediate frame, in order. output_indices: List[int] = field(default_factory=list) # Every frame came out new, but the scores look like grain on held - # frames: worth turning on Grainy source and analysing again. + # frames, so the drawings are best marked by hand. grain_suspected: bool = False # Made from the frames the editor marked, not from detection. Every frame # between two marks is then an in-between, however long the gap. diff --git a/release/upflow2x-win/Pipeline/render.py b/release/upflow2x-win/Pipeline/render.py index d482849..837aa5c 100644 --- a/release/upflow2x-win/Pipeline/render.py +++ b/release/upflow2x-win/Pipeline/render.py @@ -15,8 +15,10 @@ from __future__ import annotations +import os import sys from collections import deque +from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path from typing import Callable, Deque, Iterator, List, Optional, Sequence, Tuple @@ -367,6 +369,11 @@ def _iter_plan_frames(cap: cv2.VideoCapture, indices: Sequence[int]) -> Iterator # How many in-betweens are drawn before they are written. BATCH = 8 +# Threads that encode and write PNG frames. A 4K PNG takes about as long to +# encode as the GPU takes to draw and upscale a frame, and OpenCV lets go of +# Python while it encodes, so the two overlap. One core is left for Resolve. +WRITERS = max(1, min(3, (os.cpu_count() or 2) - 1)) + # Encoders to try per container, best quality first. _ENCODERS = { ".mkv": ("FFV1",), # lossless; Resolve refuses FFV1 in AVI @@ -462,6 +469,24 @@ def render_plan( buckets[i].append(p - i) cap = _open(source, frames, progress) + pool = ThreadPoolExecutor(max_workers=WRITERS) if is_sequence else None + # Frames still being written, oldest first. A few at most, so a fast GPU + # can't fill memory with 4K frames waiting for the disk. + pending: Deque[Future] = deque() + + def write_png(frame: np.ndarray, target: Path, number: int) -> None: + # Encode in memory and let Python write the file. cv2.imwrite + # cannot open a non-ASCII path on Windows, so a clip in a folder + # called Épisode 01 would fail on its first frame. + encoded, data = cv2.imencode(".png", frame) + if not encoded: + raise SourceError(f"Could not encode frame {number}.") + try: + target.write_bytes(data.tobytes()) + except OSError as exc: + raise SourceError(f"Could not write {target}: {exc}. Check the disk " + "has space and the folder is writable.") from exc + state = {"writer": None, "written": 0, "width": 0, "height": 0, "upscaler": upscaler, "upscale_note": upscale_note, "last_in": None, "last_out": None} @@ -493,17 +518,10 @@ def emit(frame: np.ndarray) -> None: state["height"], state["width"] = frame.shape[:2] if is_sequence: target = output / f"frame_{state['written']:06d}.png" - # Encode in memory and let Python write the file. cv2.imwrite - # cannot open a non-ASCII path on Windows, so a clip in a folder - # called Épisode 01 would fail on its first frame. - encoded, data = cv2.imencode(".png", frame) - if not encoded: - raise SourceError(f"Could not encode frame {state['written']}.") - try: - target.write_bytes(data.tobytes()) - except OSError as exc: - raise SourceError(f"Could not write {target}: {exc}. Check the disk " - "has space and the folder is writable.") from exc + # A write that failed raises here, on the next frame. + while len(pending) >= 2 * WRITERS: + pending.popleft().result() + pending.append(pool.submit(write_png, frame, target, state["written"])) else: if state["writer"] is None: # Every writer here rounds an odd size down to even without a @@ -522,8 +540,15 @@ def emit(frame: np.ndarray) -> None: # A GPU that gave out mid-render leaves the rest to the CPU, which # takes seconds a frame. A new stage name restarts the time left. on_cpu = not getattr(state["upscaler"], "on_gpu", True) - progress("Rendering, upscaling on the CPU" if on_cpu else "Rendering", - min(1.0, state["written"] / float(len(positions)))) + # The panel shows this as it goes, so it names the work being done. + upscaling = "upscaling on the CPU" if on_cpu else "upscaling" + if state["upscaler"] is None and factor == 1: + stage = "Interpolating" if pair is not None else "Rendering" + elif pair is not None: + stage = "Interpolating and " + upscaling + else: + stage = upscaling.capitalize() + progress(stage, min(1.0, state["written"] / float(len(positions)))) try: indices = plan.output_indices @@ -592,10 +617,15 @@ def change_from(slot: int) -> Optional[float]: prev = frame_at(index) while state["written"] < len(positions): emit(prev) + while pending: + pending.popleft().result() finally: cap.release() if state["writer"] is not None: state["writer"].release() + if pool is not None: + # Even on the way out of an error, no write is left going. + pool.shutdown(wait=True) return RenderResult( output=output, diff --git a/release/upflow2x-win/Pipeline/rife.py b/release/upflow2x-win/Pipeline/rife.py index a8f30b3..7ed71ea 100644 --- a/release/upflow2x-win/Pipeline/rife.py +++ b/release/upflow2x-win/Pipeline/rife.py @@ -41,6 +41,17 @@ # multiple of this. The frame is padded by repeating its edge, then cropped. _PAD = 64 +# Asked for a frame close to a drawing, the network hands back the drawing +# itself: on Hero's walk, every t up to 0.1 came back as the first drawing +# exactly and every t from 0.9 as the second, and the motion stayed uneven to +# about 0.2 and from 0.8. Slowed three times, that held each drawing for about +# six extra frames, a pause at every drawing. Between 0.25 and 0.75 it is even, +# so it is only ever asked for those. A frame nearer a drawing is drawn between +# the drawing and the halfway frame, and so on until it falls inside. +_EVEN_FROM = 0.25 +# A millionth of a gap: closer to a drawing than any real frame can be. +_ROUNDING = 1e-6 + ProgressFn = Callable[[str, float], None] @@ -83,7 +94,13 @@ def _pip(cmd: List[str], progress: Optional[ProgressFn]) -> subprocess.Completed return subprocess.CompletedProcess(cmd, proc.returncode, out, err) except subprocess.TimeoutExpired: if progress: - progress("Installing AI runtime", 0.0) + try: + progress("Installing AI runtime", 0.0) + except BaseException: + # Stopped. Cut short, pip could leave this Python half + # installed, so it finishes before the run ends. + proc.communicate() + raise def ensure_runtime(progress: Optional[ProgressFn] = None): @@ -164,6 +181,12 @@ def ensure_model(progress: Optional[ProgressFn] = None, path: Path = MODEL_PATH, class Rife: """One loaded model, reused for every pair of drawings in a render.""" + # The pair being drawn and the halfway frames drawn for it, by where they + # sit between its drawings. A render asks for a pair's in-betweens a few + # at a time, so they are kept until the next pair. + _pair = None + _known = None + def __init__(self, progress: Optional[ProgressFn] = None): ort = ensure_runtime(progress) model = ensure_model(progress) @@ -256,10 +279,32 @@ def interpolate_pair(self, a: np.ndarray, b: np.ndarray, times: List[float], out: List[np.ndarray] = [] for t in times: t = float(min(1.0, max(0.0, t))) - if t <= 0.0: + # This close to a drawing is rounding in the time map, not a frame. + if t <= _ROUNDING: out.append(a.copy()) - elif t >= 1.0: + elif t >= 1.0 - _ROUNDING: out.append(b.copy()) else: - out.append(self._run(a, b, t)) + out.append(self._between(a, b, t)) return out + + def _between(self, a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray: + """The frame at t between a and b, asking the network only for 0.25 to 0.75.""" + if self._pair is None or self._pair[0] is not a or self._pair[1] is not b: + self._pair, self._known = (a, b), {0.0: a, 1.0: b} + known = self._known + lo, hi = 0.0, 1.0 + while True: + local = (t - lo) / (hi - lo) + # Every t strictly inside lands here: each halving doubles its + # distance from the nearer end. A gap of 5000 frames slowed three + # times needs 12 halvings and keeps 23 halfway frames. + if _EVEN_FROM <= local <= 1.0 - _EVEN_FROM: + return self._run(known[lo], known[hi], local) + if hi - lo < 2.0 ** -30: + # Only rounding gets here, where both ends are one picture. + return (known[lo] if local < 0.5 else known[hi]).copy() + mid = (lo + hi) / 2.0 + if mid not in known: + known[mid] = self._run(known[lo], known[hi], 0.5) + lo, hi = (lo, mid) if local < 0.5 else (mid, hi) diff --git a/release/upflow2x-win/README.md b/release/upflow2x-win/README.md index 5f48b14..afc31a1 100644 --- a/release/upflow2x-win/README.md +++ b/release/upflow2x-win/README.md @@ -35,7 +35,7 @@ holds for three times its length. - Finds duplicated frames and works out whether the clip is on 1s, 2s or 3s. It counts a change of colour, a slow fade or a slow pan as motion rather - than a hold. With **Grainy source** ticked, it sees through film grain. + than a hold. - Draws the in-between frames that were never drawn with the RIFE network, run on your own machine, on the GPU on Windows wherever DirectML loads. Moving characters stay in one piece. @@ -109,7 +109,8 @@ move it. 1. Move the playhead over a clip on your timeline. Resolve does not tell scripts which clip is selected, so Upflow2x works on the clip under the - playhead, read again each time you press a button. + playhead. The panel follows the playhead, and in a gap it keeps the + last clip. 2. Open **Workspace → Scripts → Upflow2x**. 3. Click **Analyse**. It reads the clip, tells you what it found, for example `1438 frames, 719 unique drawings (719 held, 50%). Animated on 2s.`, @@ -134,7 +135,7 @@ rendered from the drawings Upflow2x finds on its own. ![The Upflow2x panel in DaVinci Resolve Studio after a render. The rebuilt clip sits on V2, lined up above the original on V1.](https://upflow2x.com/assets/demo/resolve.jpg) The render runs in the background and the panel shows progress and the time -left, so Resolve stays usable while it works. When it finishes, the rebuilt +left, so Resolve stays usable while it works. **Stop** ends it early. When it finishes, the rebuilt clip is imported and placed on the track above the original, starting on the same frame. With Interpolate on it runs three times as long, up to the last marker if the clip has any. It goes on the lowest @@ -161,14 +162,15 @@ Whole file when Resolve does not report one. ### Settings +They are all on the panel, and it remembers them between sessions. Rest the +pointer on one to see what it does. + | Setting | What it does | |---|---| | **Threshold** | How big a change Analyse counts as a new drawing, 1 to 9. Lower catches small moves, like only a mouth or a slow drift. Higher counts only big ones, like the whole shot moving. It does nothing to frames you have marked. | -| **Hold pattern** | Leave on `Auto detect`. Force `On 2s` or `On 3s` if a clip has unusual timing that trips the detector. | | **Output** | `Image sequence` is lossless and the most reliable to import. `MP4` is a single compact file but lossy. `MKV` is a lossless single file. All three are 8-bit RGB: transparency and 10-bit colour in the source are not kept. MP4 and MKV need an even width and height. | -| **Upscale 2x** | Draws every frame at 2x with Real-ESRGAN's anime video model, on the GPU through DirectML, about 0.14 s a 1080p frame on a Radeon. The model ships with Upflow2x. Without a GPU, on a Mac for one, Resolve's Super Scale is applied to the imported clip instead. Either way it only shows on a timeline bigger than the clip, such as 3840x2160 for 1080p footage. A 2x render has four times the pixels. As an image sequence, a 4K frame of anime is about 6 to 7 MB, four to five times its 1080p size. | -| **Interpolate** | On, the RIFE network draws every missing in-between. On Windows it runs on the GPU through DirectML, about 0.13 s an in-between at 2K on a Radeon, and falls back to the CPU, about ten times slower. If its Python already has ONNX Runtime without DirectML, that one is used, on the CPU. Its first run installs ONNX Runtime and downloads the model, about 50 MB, so it needs a connection once; without one, every drawing is kept as drawn and the panel says why. On, the render is three times longer, so it takes three times as long to render and an image sequence takes three times the disk. Off skips the in-betweens, so every drawing comes back on its original frame and the render keeps the clip's length. Use it with Upscale 2x to upscale only. | -| **Grainy source** | For film grain, which makes every held frame differ a little, so they all look new. It raises the threshold above the clip's own grain. Leave it off for clean animation, where it can merge fine real changes. Analyse suggests it when every frame differs in a way that looks like grain. | +| **Upscaling** | Draws every frame at 2x with Real-ESRGAN's anime video model, on the GPU through DirectML, about 0.14 s a 1080p frame on a Radeon. The model ships with Upflow2x. Without a GPU, on a Mac for one, Resolve's Super Scale is applied to the imported clip instead. Either way it only shows on a timeline bigger than the clip, such as 3840x2160 for 1080p footage. A 2x render has four times the pixels. As an image sequence, a 4K frame of anime is about 6 to 7 MB, four to five times its 1080p size. | +| **Interpolate** | On, the RIFE network draws every missing in-between. On Windows it runs on the GPU through DirectML, about 0.13 s an in-between at 2K on a Radeon, and falls back to the CPU, about ten times slower. If its Python already has ONNX Runtime without DirectML, that one is used, on the CPU. Its first run installs ONNX Runtime and downloads the model, about 50 MB, so it needs a connection once; without one, every drawing is kept as drawn and the panel says why. On, the render is three times longer, so it takes three times as long to render and an image sequence takes three times the disk. Off skips the in-betweens, so every drawing comes back on its original frame and the render keeps the clip's length. With Upscaling on, that only upscales. | Settings are remembered between sessions. @@ -220,9 +222,9 @@ afterwards. | Resolve says you need Studio | Since 19.1 the free edition blocks script panels. Upflow2x needs Studio. | | Clicking Upflow2x does nothing, or "Plugin folder not configured" | The plugin folder moved after installing, and Workspace → Console says `UI script not found`. Re-run `python Installer/install_eternal2x.py` from where it is now. | | "Could not reach Resolve" | Open the panel from Workspace → Scripts, not from a terminal. | -| "No clip under the playhead" | Move the playhead over the clip, then press **Use Clip at Playhead**. | +| "No clip under the playhead" | Move the playhead over the clip, and the panel picks it up. | | "No duplicate frames. This clip is already on 1s." | The clip has no duplicated frames. It is live action, or already on 1s. | -| "Every frame differs, but the differences look like grain" | Tick **Grainy source** and analyse again. Heavy grain on a clip with long holds can still hide the duplicates. | +| "Every frame differs, but the differences look like grain" | Mark the drawings with M, then press **Render**. From the command line, `--grain` raises the threshold above the grain. | | "...has a variable frame rate" | Render the clip to a constant frame rate first, from Resolve or with ffmpeg, as the message says. | | Progress seems stuck | Press **Refresh Progress**. If Python stopped, the panel says so and shows its last error. If a run was interrupted, press **Reset**. | | Resolve would not import the render | The render still finished. The panel prints where it is, so drag it into your media pool. Switching Output to `Image sequence` usually avoids this. | diff --git a/release/upflow2x-win/Stages/resolve_smooth.py b/release/upflow2x-win/Stages/resolve_smooth.py index b8a03f1..b828629 100644 --- a/release/upflow2x-win/Stages/resolve_smooth.py +++ b/release/upflow2x-win/Stages/resolve_smooth.py @@ -29,6 +29,10 @@ IMPORT_ERROR = None +class Stopped(BaseException): + """The panel's Stop. Not an Exception, so nothing on the way out catches it.""" + + class StatusFile: """Progress the panel can poll while the render runs in the background. @@ -40,6 +44,11 @@ class StatusFile: def __init__(self, path: Optional[str]) -> None: self.path = Path(path) if path else None + # The panel's Stop creates this, and the run ends at its next frame. + self.stop_path = self.path.with_suffix(".stop") if self.path else None + # Set once the result is going into Resolve. Stopped halfway there it + # would be imported but not placed, so from then on the run finishes. + self.committed = False self.stage = "" self.fraction = 0.0 self.message = "" @@ -96,6 +105,11 @@ def write(self, *, done: bool = False, ok: bool = False, # but losing the final write leaves the panel waiting forever. attempts = 100 if done else 3 for attempt in range(attempts): + # A Stop pressed after the last frame, while the writes finished + # or while this waits for the panel to let go of the file, still + # counts: the run reports Stopped, not a result to import. + if done and ok: + self.check_stop() try: temp.replace(self.path) return @@ -103,7 +117,13 @@ def write(self, *, done: bool = False, ok: bool = False, if attempt + 1 < attempts: time.sleep(0.02) + def check_stop(self) -> None: + """Raise Stopped if the panel's Stop was pressed in time.""" + if not self.committed and self.stop_path is not None and self.stop_path.exists(): + raise Stopped() + def progress(self, stage: str, fraction: float) -> None: + self.check_stop() self.stage = stage self.fraction = fraction self.write() @@ -397,10 +417,10 @@ def _run(args, summary: dict) -> int: summary["skipped"] = "no duplicate frames" if plan.grain_suspected: why = ("Every frame differs, but by about the same small amount, as grain " - "makes held frames differ. If the clip is grainy, turn on Grainy " - "source (--grain) and try again.") - short = ("Every frame differs, but the differences look like grain. If the " - "clip is grainy, turn on Grainy source and analyse again.") + "makes held frames differ. If the clip is grainy, mark the drawings " + "with M, or run with --grain and try again.") + short = ("Every frame differs, but the differences look like grain. Mark the " + "drawings with M, then press Render.") print(f"Nothing to do. {why}") summary["ok"] = True status.write(done=True, ok=True, message=short) @@ -424,6 +444,8 @@ def _run(args, summary: dict) -> int: summary["error"] = str(exc) status.write(done=True, ok=False, message=str(exc)) return 2 + # Stopped while the last frames were written: nothing goes into Resolve. + status.check_stop() print(f"Wrote {result.frames_written} frames at {result.fps:.3f} fps.") if result.note: @@ -456,41 +478,50 @@ def _run(args, summary: dict) -> int: status.write(done=True, ok=True, message=_finished(plan, result)) return 0 - media_item, imported = bridge.import_media(project, result.output, - frame_count=result.frames_written, - fps=result.fps) - print(("Imported: " if imported else "Import failed: ") + imported.detail) - summary["import"] = {"ok": imported.ok, "detail": imported.detail} + # Too late to stop from here, or the clip would be left half placed. + status.committed = True + try: + media_item, imported = bridge.import_media(project, result.output, + frame_count=result.frames_written, + fps=result.fps) + print(("Imported: " if imported else "Import failed: ") + imported.detail) + summary["import"] = {"ok": imported.ok, "detail": imported.detail} + + if not imported: + print(f"The render is fine and is waiting at: {result.output}") + print("Drag it into your media pool to finish.") + summary["ok"] = True + status.write(done=True, ok=True, + message=f"Rendered, but Resolve would not import it. " + f"Drag in {result.output.name} to finish.") + return 0 + + if result.upscaled: + summary["upscale"] = {"ok": True, "detail": "Drawn 2x by Real-ESRGAN."} + elif cfg.upscale_factor > 1: + studio = bridge.is_studio(resolve) + if studio is False: + print("Skipping upscale: Super Scale needs DaVinci Resolve Studio.") + summary["upscale"] = {"ok": False, "detail": "not Studio"} + else: + scaled = bridge.set_super_scale(media_item, cfg.upscale_factor) + print(("Upscale: " if scaled else "Upscale not applied: ") + scaled.detail) + summary["upscale"] = {"ok": scaled.ok, "detail": scaled.detail} + + appended = bridge.append_to_timeline(project, media_item) + print(("Timeline: " if appended else "Timeline not updated: ") + appended.detail) + summary["append"] = {"ok": appended.ok, "detail": appended.detail} - if not imported: - print(f"The render is fine and is waiting at: {result.output}") - print("Drag it into your media pool to finish.") summary["ok"] = True - status.write(done=True, ok=True, - message=f"Rendered, but Resolve would not import it. " - f"Drag in {result.output.name} to finish.") + print("Done.") + status.write(done=True, ok=True, message=_finished(plan, result)) return 0 - - if result.upscaled: - summary["upscale"] = {"ok": True, "detail": "Drawn 2x by Real-ESRGAN."} - elif cfg.upscale_factor > 1: - studio = bridge.is_studio(resolve) - if studio is False: - print("Skipping upscale: Super Scale needs DaVinci Resolve Studio.") - summary["upscale"] = {"ok": False, "detail": "not Studio"} - else: - scaled = bridge.set_super_scale(media_item, cfg.upscale_factor) - print(("Upscale: " if scaled else "Upscale not applied: ") + scaled.detail) - summary["upscale"] = {"ok": scaled.ok, "detail": scaled.detail} - - appended = bridge.append_to_timeline(project, media_item) - print(("Timeline: " if appended else "Timeline not updated: ") + appended.detail) - summary["append"] = {"ok": appended.ok, "detail": appended.detail} - - summary["ok"] = True - print("Done.") - status.write(done=True, ok=True, message=_finished(plan, result)) - return 0 + finally: + # However this ends, a Stop that came now was not used, and would end + # the next run. + if status.stop_path is not None: + with contextlib.suppress(OSError): + status.stop_path.unlink() def _run_reporting(args, summary: dict) -> int: @@ -505,6 +536,17 @@ def _run_reporting(args, summary: dict) -> int: return 3 try: return _run(args, summary) + except Stopped: + print("Stopped.") + # It may have stopped after the summary already said it worked. + summary["ok"] = False + summary.pop("import_path", None) + summary["error"] = "Stopped." + status = StatusFile(args.status_file) + with contextlib.suppress(OSError): + status.stop_path.unlink() + status.write(done=True, ok=False, message="Stopped.") + return 1 except Exception as exc: traceback.print_exc() message = f"Upflow2x hit an unexpected error: {exc}" diff --git a/tests/lua_harness.lua b/tests/lua_harness.lua index 208d35d..603c809 100644 --- a/tests/lua_harness.lua +++ b/tests/lua_harness.lua @@ -51,9 +51,12 @@ end -- UIManager: ui:VGroup{...}, ui:Label{...} etc. all just build descriptors. local ui = setmetatable({}, { __index = function(_, kind) - return function(_self, spec) - local w = new_widget(spec or {}) + return function(_self, spec, ...) + -- ui:VGap(size) and ui:HGap(size, weight) take numbers. + local gap = type(spec) ~= "table" and { spec, ... } or nil + local w = new_widget(gap and {} or spec or {}) w.__kind = kind + w.__gap = gap return w end end, @@ -75,6 +78,9 @@ local dispatcher = {} dispatcher.__index = dispatcher function dispatcher:AddWindow(attrs, layout) + -- The panel's first window is H.window. Each window keeps its own + -- widgets and handlers, as in Resolve, and H.widgets lists them all. + local first = H.window local win = { __attrs = attrs, __layout = layout, @@ -84,6 +90,16 @@ function dispatcher:AddWindow(attrs, layout) } local items = {} collect(layout, items) + win.__items = items + if first then + for id, w in pairs(items) do H.widgets[id] = w end + H.windows[attrs.ID] = win + function win:GetItems() return items end + function win:Show() self.__shown = true end + function win:Hide() self.__shown = false end + return win + end + H.windows = { [attrs.ID] = win } function win:GetItems() return items end function win:Show() self.__shown = true end function win:Hide() self.__shown = false end @@ -91,13 +107,14 @@ function dispatcher:AddWindow(attrs, layout) if child and child.ID then items[child.ID] = child end table.insert(self.__extra, child) end - H.widgets = items + H.widgets = {} + for id, w in pairs(items) do H.widgets[id] = w end H.window = win return win end function dispatcher:RunLoop() end -function dispatcher:ExitLoop() end +function dispatcher:ExitLoop() H.exits = (H.exits or 0) + 1 end local bmd = {} function bmd.UIDispatcher(_ui) @@ -202,7 +219,7 @@ function H.make_resolve(clip_file_path) item.GetStart = function(_self) return H.src_span[1] end item.GetEnd = function(_self) return H.src_span[2] end item.GetDuration = function(_self) return H.src_span[2] - H.src_span[1] end - item.GetUniqueId = function(_self) return "source-item" end + item.GetUniqueId = function(_self) return H.item_id or "source-item" end item.GetTrackTypeAndIndex = function(_self) return resolve_list({ "video", H.item_track or 1 }) end @@ -413,6 +430,7 @@ end function H.load(script_path, clip_file_path) H.commands = {} H.prints = {} + H.window = nil _G.fu = { UIManager = ui } _G.bmd = bmd @@ -446,8 +464,16 @@ end -- Inspection helpers used from the Python tests --------------------------- +-- The handlers of the window that holds this control, or that is it. +local function on(id) + for _, win in pairs(H.windows) do + if win.__items[id] or win.__attrs.ID == id then return win.On[id] end + end + return {} +end + function H.fire(id, event) - local handler = H.window.On[id] and H.window.On[id][event] + local handler = on(id)[event] if not handler then error("no " .. tostring(event) .. " handler for " .. tostring(id)) end @@ -462,7 +488,7 @@ function H.fire(id, event) end function H.click(id) - local handler = H.window.On[id] and H.window.On[id].Clicked + local handler = on(id).Clicked if not handler then error("no Clicked handler for " .. tostring(id)) end local real_execute = os.execute local real_print = print @@ -476,19 +502,19 @@ end function H.set_slider(id, value) H.widgets[id].Value = value - local handler = H.window.On[id] and H.window.On[id].ValueChanged + local handler = on(id).ValueChanged if handler then handler({}) end end function H.set_combo(id, index) H.widgets[id].CurrentIndex = index - local handler = H.window.On[id] and H.window.On[id].CurrentIndexChanged + local handler = on(id).CurrentIndexChanged if handler then handler({}) end end function H.set_checkbox(id, value) H.widgets[id].Checked = value - local handler = H.window.On[id] and H.window.On[id].Clicked + local handler = on(id).Clicked if handler then handler({}) end end @@ -537,9 +563,11 @@ function H.widget_ids() end function H.handler_ids() local ids = {} - for id, events in pairs(H.window.On) do - for ev, _ in pairs(events) do - table.insert(ids, id .. "." .. ev) + for _, win in pairs(H.windows) do + for id, events in pairs(win.On) do + for ev, _ in pairs(events) do + table.insert(ids, id .. "." .. ev) + end end end table.sort(ids) diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 489c388..42bddbf 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -127,7 +127,9 @@ def test_analyse_reports_the_real_pattern(installation): H.click("AnalyseBtn") finish(H) - summary = H.widgets["AnalysisLabel"].Text + assert H.widgets["AnalysisLabel"].Text == "Placed 12 markers. Animated on 2s." + # The whole report is in the line's tip. + summary = H.widgets["AnalysisLabel"].ToolTip assert "12 unique drawings" in summary, f"panel showed: {summary!r}" assert "on 2s" in summary # The drawings it found are marked on the clip, ready to edit. @@ -227,14 +229,6 @@ def test_settings_chosen_in_the_panel_reach_the_render(installation, read_frames assert H.property_of("Super Scale") is None, "Super Scale was applied anyway" -def test_forcing_a_hold_pattern_reaches_the_render(installation): - H, _src = installation(holds=(2,) * 12) - H.set_combo("HoldCombo", 2) # On 3s - H.click("AnalyseBtn") - finish(H) - assert "on 3s" in H.widgets["AnalysisLabel"].Text - - def test_a_second_run_does_not_overwrite_the_first(installation): # Two full renders, so keep the clip short. H, src = installation(holds=(2,) * 4) diff --git a/tests/test_installer.py b/tests/test_installer.py index 8726b59..0d80fea 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -450,15 +450,10 @@ def test_config_has_no_byte_order_mark(tmp_path): def test_panel_defaults_are_values_the_stage_accepts(tmp_path): - from Stages.resolve_smooth import FORMATS, build_parser + from Stages.resolve_smooth import FORMATS conf = read_conf(inst.write_config(tmp_path, Path("/plugin"), "/usr/bin/python3")) assert conf["format"] in FORMATS - hold_choices = None - for action in build_parser()._actions: - if "--base-hold" in action.option_strings: - hold_choices = action.type - assert hold_choices(conf["base_hold"]) == 0 def test_the_private_python_can_import_the_plugin(): diff --git a/tests/test_interpolate.py b/tests/test_interpolate.py index 9a5ca8a..04a6c00 100644 --- a/tests/test_interpolate.py +++ b/tests/test_interpolate.py @@ -63,6 +63,76 @@ def test_many_times_in_one_call(texture): assert all(f.dtype == np.uint8 for f in out) +class SnappingRife(StandInRife): + """Like the network: asked for a frame close to a drawing, it hands the + drawing back. Counts what it is asked for.""" + + def __init__(self): + super().__init__() + self.asked = [] + + def _run(self, a, b, t): + self.asked.append(t) + if t <= 0.1: + return a.copy() + if t >= 0.9: + return b.copy() + return super()._run(a, b, t) + + +SLOWED = [k / 24 for k in range(1, 24)] # a gap of 8 frames, slowed 3 times + + +def test_no_in_between_is_a_copy_of_a_drawing(texture): + """Each drawing was held for a few extra frames, a pause at every one.""" + b = shifted(texture, 12) + for t, frame in zip(SLOWED, SnappingRife().interpolate_pair(texture, b, SLOWED)): + assert not np.array_equal(frame, texture), f"t={t:.3f} is the first drawing" + assert not np.array_equal(frame, b), f"t={t:.3f} is the second drawing" + + +def test_the_network_is_only_asked_for_the_middle_of_a_pair(texture): + rife = SnappingRife() + rife.interpolate_pair(texture, shifted(texture, 12), SLOWED) + assert all(0.25 <= t <= 0.75 for t in rife.asked), rife.asked + + +def test_a_long_gap_never_asks_the_network_for_its_ends(texture): + """600 frames between two markers, slowed 3 times, start at t = 1/1800. A + blend that close to a drawing rounds to it, so what counts is that the + network is never asked for its snapping ends.""" + times = [1 / 1800, 2 / 1800, 1 - 2 / 1800, 1 - 1 / 1800] + rife = SnappingRife() + rife.interpolate_pair(texture, shifted(texture, 12), times) + assert all(0.25 <= t <= 0.75 for t in rife.asked), rife.asked + + +def test_rounding_in_the_time_map_is_not_drawn(texture): + """7.000000000000001 in the time map is drawing 7, not an in-between.""" + b = shifted(texture, 12) + rife = SnappingRife() + first, last = rife.interpolate_pair(texture, b, [8.881784197001252e-16, 1 - 1e-12]) + assert np.array_equal(first, texture) and np.array_equal(last, b) + assert rife.asked == [] + + +def test_a_pair_asked_for_a_few_at_a_time_draws_its_halfway_frames_once(texture): + b = shifted(texture, 12) + rife = SnappingRife() + for at in range(0, len(SLOWED), 8): + rife.interpolate_pair(texture, b, SLOWED[at:at + 8]) + # 0.5, 0.25, 0.125, 0.75 and 0.875, then one frame for each time. + assert len(rife.asked) == len(SLOWED) + 5 + + +def test_in_betweens_near_a_drawing_still_move_evenly(texture): + """With a plain blend standing in, each frame should sit where it was asked.""" + b = shifted(texture, 12) + for t, frame in zip(SLOWED, SnappingRife().interpolate_pair(texture, b, SLOWED)): + want = cv2.addWeighted(texture, 1.0 - t, b, t, 0) + assert np.abs(frame.astype(int) - want).max() <= 2, f"t={t:.3f}" + + # -------------------------------------------------------------------------- # stills and cuts: the failure modes that matter # -------------------------------------------------------------------------- diff --git a/tests/test_lua_ui.py b/tests/test_lua_ui.py index e45962c..0f993cb 100644 --- a/tests/test_lua_ui.py +++ b/tests/test_lua_ui.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import re import shutil import subprocess import sys @@ -63,6 +64,12 @@ def direct(tmp_path, repo_root): return root +def follow(H): + """The card reads the clip once the playhead has stayed put for a tick.""" + H.fire("PollTimer", "Timeout") + H.fire("PollTimer", "Timeout") + + def load(lua, script: Path, clip="/media/shot.mov"): # Forward slashes: a Windows path interpolated into Lua source turns # backslashes into escape sequences, and "D:\a\..." fails to parse. @@ -87,9 +94,9 @@ def test_panel_loads(lua, direct): def test_every_control_is_present(lua, direct): H = load(lua, ui_script(direct)) ids = set(H.widget_ids().split(",")) - for expected in ("SmoothBtn", "AnalyseBtn", "RefreshBtn", "RefreshStatusBtn", - "CancelBtn", "UpdateBtn", "HoldCombo", "FormatCombo", - "UpscaleCB", "InterpCB", "GrainCB", "AutoUpdateCB", "Status", + for expected in ("SmoothBtn", "AnalyseBtn", "RefreshStatusBtn", + "CancelBtn", "UpdateBtn", "FormatCombo", + "UpscaleCB", "InterpCB", "Status", "Progress", "SourceLabel", "AnalysisLabel", "CompareBtn", "FolderBtn"): assert expected in ids, f"missing {expected}; have {sorted(ids)}" @@ -97,11 +104,11 @@ def test_every_control_is_present(lua, direct): def test_every_control_has_a_handler(lua, direct): H = load(lua, ui_script(direct)) handlers = set(H.handler_ids().split(",")) - for expected in ("SmoothBtn.Clicked", "AnalyseBtn.Clicked", "RefreshBtn.Clicked", + for expected in ("SmoothBtn.Clicked", "AnalyseBtn.Clicked", "PollTimer.Timeout", "RefreshStatusBtn.Clicked", "CancelBtn.Clicked", "UpdateBtn.Clicked", - "HoldCombo.CurrentIndexChanged", "UpscaleCB.Clicked", - "InterpCB.Clicked", "GrainCB.Clicked", "AutoUpdateCB.Clicked", "CompareBtn.Clicked", + "UpscaleCB.Clicked", + "InterpCB.Clicked", "CompareBtn.Clicked", "FolderBtn.Clicked"): assert expected in handlers, f"no handler for {expected}" @@ -158,10 +165,12 @@ def test_every_render_plays_at_a_third_of_the_speed(lua, direct): assert "--slow-motion" not in H.last_command() -def test_hold_pattern_choices_are_offered(lua, direct): +def test_everything_is_in_one_window(lua, direct): + """A second window opened on top of the panel and had to be closed.""" H = load(lua, ui_script(direct)) - labels = H.combo_items("HoldCombo").split(",") - assert labels == ["Auto detect", "On 2s", "On 3s"] + assert list(H.windows.keys()) == ["Eternal2x"] + for control in ("ThresholdSlider", "FormatCombo", "UpdateBtn", "SubTitle"): + assert control in H.window.__items, f"{control} is not on the panel" def test_the_version_is_shown(lua, direct): @@ -328,18 +337,15 @@ def test_interpolate_off_reaches_the_command(lua, direct): assert "--no-interpolate" in H.last_command() -def test_hold_pattern_selection_reaches_the_command(lua, direct): - H = load(lua, ui_script(direct)) - H.set_combo("HoldCombo", 1) # On 2s - H.click("AnalyseBtn") - assert "--base-hold 2" in H.last_command() - - -def test_auto_hold_pattern_sends_no_override(lua, direct): +def test_a_hold_pattern_or_grain_saved_before_is_not_sent(lua, direct): + """The panel no longer offers either, so a value an older version saved + would steer every run with no way to change it.""" + conf = direct / "Installer" / "Eternal2x.conf" + conf.write_text(conf.read_text() + "base_hold=3\ngrain=true\n") H = load(lua, ui_script(direct)) - H.set_combo("HoldCombo", 0) # Auto detect H.click("AnalyseBtn") assert "--base-hold" not in H.last_command() + assert "--grain" not in H.last_command() def test_unchecking_upscale_reaches_the_command(lua, direct): @@ -356,22 +362,6 @@ def test_unchecking_interpolate_reaches_the_command(lua, direct): assert "--no-interpolate" in H.last_command() -def test_grainy_source_is_off_unless_ticked(lua, direct): - H = load(lua, ui_script(direct)) - H.click("AnalyseBtn") - assert "--grain" not in H.last_command() - H.set_checkbox("GrainCB", True) - H.click("AnalyseBtn") - assert "--grain" in H.last_command() - - -def test_grainy_source_is_remembered(lua, direct): - H = load(lua, ui_script(direct)) - H.set_checkbox("GrainCB", True) - conf = (direct / "Installer" / "Eternal2x.conf").read_text() - assert "grain=true" in conf - H = load(lua, ui_script(direct)) - assert H.widgets["GrainCB"].Checked is True def test_defaults_send_no_opt_out_flags(lua, direct): @@ -383,7 +373,7 @@ def test_defaults_send_no_opt_out_flags(lua, direct): def test_a_clip_path_with_spaces_is_quoted(lua, direct): H = load(lua, ui_script(direct), clip="/media/My Footage/shot 01.mov") - assert "My Footage" in H.widgets["Meta"].Text + assert "My Footage" in H.widgets["SourceLabel"].ToolTip def test_every_module_the_panel_calls_exists(lua, direct, repo_root): @@ -420,7 +410,7 @@ def test_the_selected_clip_is_shown_on_open(lua, direct): def test_refresh_picks_up_the_selection(lua, direct): H = load(lua, ui_script(direct), clip="/media/shot.mov") - H.click("RefreshBtn") + follow(H) assert "shot.mov" in H.widgets["SourceLabel"].Text assert "Ready" in H.status() @@ -450,14 +440,47 @@ def test_a_stage_at_zero_percent_is_still_named(lua, direct): assert "Installing AI runtime" in H.widgets["Progress"].Text +def bar_fill(H): + """How far the amber runs in the progress bar's gradient, or None.""" + stops = re.findall(r"stop: ([\d.]+) ", H.widgets["ProgressBar"].StyleSheet) + return float(stops[1]) if len(stops) > 1 else None + + def test_progress_bar_fills_as_it_goes(lua, direct): H = load(lua, ui_script(direct)) filled = [] for fraction in (0.1, 0.9): write_status(direct, fraction=fraction) H.click("RefreshStatusBtn") - filled.append(H.widgets["Progress"].Text.count("█")) - assert filled[1] > filled[0], "the progress bar did not grow" + filled.append(bar_fill(H)) + assert filled == [0.1, 0.9], "the progress bar did not grow" + + +def test_the_progress_bar_fills_under_a_decimal_comma_locale(lua, direct): + """Qt drops a gradient whose stops read 0,420.""" + for name in ("de_DE.UTF-8", "de_DE", "German_Germany.1252"): + if lua.eval(f'os.setlocale("{name}", "numeric")'): + break + else: + pytest.skip("no decimal comma locale here") + try: + assert lua.eval('string.format("%.1f", 0.5)') == "0,5" + H = load(lua, ui_script(direct)) + write_status(direct, fraction=0.42) + H.click("RefreshStatusBtn") + assert bar_fill(H) == 0.42 + finally: + lua.eval('os.setlocale("C", "numeric")') + + +def test_the_progress_bar_is_empty_until_a_run_starts(lua, direct): + H = load(lua, ui_script(direct)) + assert bar_fill(H) is None + H.click("SmoothBtn") + assert bar_fill(H) is not None + (direct / ".eternal2x_exit").write_text("1\n") + H.click("CancelBtn") + assert bar_fill(H) is None def test_completion_message_is_surfaced(lua, direct): @@ -531,11 +554,9 @@ def test_refreshing_with_no_run_says_so(lua, direct): def test_settings_are_saved(lua, direct): H = load(lua, ui_script(direct)) - H.set_combo("HoldCombo", 2) # On 3s H.set_checkbox("UpscaleCB", False) conf = (direct / "Installer" / "Eternal2x.conf").read_text() - assert "base_hold=3" in conf assert "upscale=false" in conf @@ -560,20 +581,19 @@ def test_saving_settings_keeps_the_rest_of_the_config(lua, direct): def test_saved_settings_are_restored_next_time(lua, direct): H = load(lua, ui_script(direct)) - H.set_combo("HoldCombo", 1) # On 2s - H.set_checkbox("GrainCB", True) + H.set_checkbox("UpscaleCB", False) H2 = load(lua, ui_script(direct)) H2.click("AnalyseBtn") - cmd = H2.last_command() - assert "--grain" in cmd - assert "--base-hold 2" in cmd + assert "--no-upscale" in H2.last_command() -def test_auto_update_toggle_persists(lua, direct): +def test_a_config_that_turned_updates_off_keeps_them_off(lua, direct): + """The panel has no box for it now, but someone who turned it off before + keeps their choice.""" H = load(lua, ui_script(direct)) - H.set_checkbox("AutoUpdateCB", True) - assert "auto_update=true" in (direct / "Installer" / "Eternal2x.conf").read_text() + H.set_checkbox("UpscaleCB", False) + assert "auto_update=false" in (direct / "Installer" / "Eternal2x.conf").read_text() # -------------------------------------------------------------------------- @@ -604,7 +624,7 @@ def test_a_background_run_does_not_block(lua, direct): H = load(lua, ui_script(direct)) H.click("SmoothBtn") assert not H.enabled("SmoothBtn"), "the panel should show it is busy" - assert "Resolve stays usable" in H.status() + assert "Rendering" in H.status() # -------------------------------------------------------------------------- @@ -662,6 +682,16 @@ def test_the_render_lines_up_on_a_new_track_above_the_original(lua, direct): assert "V2 above the original" in H.status() +def test_a_finished_render_message_fits_the_status_box(lua, direct): + """In Resolve its three lines were cut off at the top and bottom of a + two-line box.""" + H = load(lua, ui_script(direct)) + finish_render(H, direct, message="Rendered 25 drawings into 337 smooth frames.") + status = H.widgets["Status"] + assert status.MinimumSize[2] >= 3 * 15, "the box is shorter than three lines of 11 px text" + assert status.ToolTip == status.Text, "a longer message has no other way to be read" + + def test_a_free_track_above_is_used_as_it_is(lua, direct): H = load(lua, ui_script(direct)) lua.execute("local H = ...; H.tracks[2] = {}; H.tracks[3] = {}", H) @@ -690,7 +720,7 @@ def test_a_sequence_is_placed_with_its_own_length(lua, direct): def test_without_the_placement_calls_it_goes_at_the_end(lua, direct): H = load(lua, ui_script(direct)) H.no_place_api = True - H.click("RefreshBtn") + follow(H) finish_render(H, direct) assert len(H.placements) == 0 assert H.appended == 1 @@ -777,7 +807,7 @@ def _with_source(lua, direct, **fields): sets = "; ".join(f"H.{k} = {str(v).lower() if isinstance(v, bool) else repr(v)}" for k, v in fields.items()) lua.execute(f"local H = ...; H.tracks = nil; H.src_span = nil; {sets}", H) - H.click("RefreshBtn") + follow(H) return H @@ -893,7 +923,7 @@ def _stacked(lua, direct, top, *under): paths = ", ".join("'" + p.replace("\\", "\\\\") + "'" for p in under) lua.execute(f"local H = ...; H.tracks = nil; H.src_span = nil; " f"H.item_track = {len(under) + 1}; H.under_paths = {{ {paths} }}", H) - H.click("RefreshBtn") + follow(H) return H @@ -927,7 +957,7 @@ def _under_a_clip(lua, direct, setup=""): H = load(lua, ui_script(direct), clip="/media/B.mov") lua.execute("local H = ...; H.tracks = nil; H.src_span = nil; H.item_track = 2; " "H.under_paths = { '/media/A.mov' }; H.playhead_tc = '01:00:00:10'; " + setup, H) - H.click("RefreshBtn") + follow(H) return H @@ -1246,7 +1276,8 @@ def test_a_refused_super_scale_is_not_reported_as_done(lua, direct): return n end, CreateProcessW = function(app, line, pa, ta, inherit, flags, env, cwd, si, pi) - CREATED[#CREATED + 1] = { line = line.text, flags = flags } + CREATED[#CREATED + 1] = { line = line.text, flags = flags, + cwd = cwd and cwd.text } return REFUSE and 0 or 1 end, WaitForSingleObject = function() @@ -1268,12 +1299,25 @@ def test_windows_commands_start_without_a_console_window(lua, direct): lua.execute('package.config = "\\\\" .. package.config:sub(2)') lua.execute(FAKE_FFI) H = load(lua, ui_script(direct)) + H.click("AnalyseBtn") + first = lua.eval("CREATED")[1] + assert first.flags == 0x08000000 + assert not any("start" in c for c in H.commands.values()), "os.execute ran it as well" + + +def test_the_windows_run_opens_no_window(lua, direct): + """The render's minimised window could be opened and closed, which ended + the render. It now starts with no window, from the plugin folder.""" + lua.execute('package.config = "\\\\" .. package.config:sub(2)') + lua.execute(FAKE_FFI) + H = load(lua, ui_script(direct)) H.click("SmoothBtn") created = lua.eval("CREATED") last = created[len(created)] assert last.flags == 0x08000000 - assert ' /c start "Upflow2x" /D ' in last.line - assert last.line.endswith(" /MIN cmd /c .\\.eternal2x_run.cmd") + assert last.line.endswith(" /c .\\.eternal2x_run.cmd"), last.line + assert "start" not in last.line and "/MIN" not in last.line, last.line + assert last.cwd == str(direct).replace("/", "\\"), last.cwd assert not any("start" in c for c in H.commands.values()), "os.execute ran it as well" @@ -1320,14 +1364,6 @@ def test_the_windows_background_run_goes_through_a_script(lua, direct): assert "2>&1" in text -def test_a_saved_on_1s_setting_falls_back_to_auto_detect(lua, direct): - conf = direct / "Installer" / "Eternal2x.conf" - conf.write_text(conf.read_text() + "base_hold=1\n") - H = load(lua, ui_script(direct)) - H.click("AnalyseBtn") - assert "--base-hold" not in H.last_command() - - # -------------------------------------------------------------------------- # reaching Resolve the way a real Scripts-menu run does # -------------------------------------------------------------------------- @@ -1344,7 +1380,7 @@ def test_the_panel_uses_a_resolve_global_when_there_is_one(lua, direct): lua.execute('resolve = nil') H.load(str(ui_script(direct)), "/media/first.mov") lua.execute('resolve = Resolve(); Resolve = nil') - H.click("RefreshBtn") + follow(H) assert "first.mov" in H.widgets["SourceLabel"].Text @@ -1643,7 +1679,7 @@ def test_reset_does_not_drop_a_run_that_is_still_reporting(lua, direct): H.click("CancelBtn") assert (direct / ".eternal2x_status.json").exists() assert not H.enabled("SmoothBtn") - assert "still running" in H.status() + assert "Stopping" in H.status() def test_reset_does_not_drop_a_run_that_has_not_reported_yet(lua, direct): @@ -1653,7 +1689,7 @@ def test_reset_does_not_drop_a_run_that_has_not_reported_yet(lua, direct): H.click("SmoothBtn") H.click("CancelBtn") assert not H.enabled("SmoothBtn") - assert "still running" in H.status() + assert "Stopping" in H.status() def test_a_heartbeat_from_before_the_clock_moved_back_is_not_a_live_run(lua, direct): @@ -1675,7 +1711,7 @@ def test_the_panel_keeps_its_own_word_while_a_run_reports_progress(lua, direct): write_status(direct, fraction=0.52, message="Starting", updated=time.time()) H.click("CancelBtn") H.click("RefreshStatusBtn") - assert "still running" in H.status() + assert "Stopping" in H.status() assert "52%" in H.widgets["Progress"].Text @@ -1686,9 +1722,11 @@ def test_the_stall_warning_goes_once_progress_resumes(lua, direct): write_status(direct, fraction=0.3, updated=time.time() - 10 * 60) H.click("RefreshStatusBtn") assert "No progress" in H.status() + assert H.text_of("CancelBtn") == "Reset", "the warning says press Reset" write_status(direct, fraction=0.4, updated=time.time()) H.click("RefreshStatusBtn") assert "No progress" not in H.status() + assert H.text_of("CancelBtn") == "Stop" def test_reset_clears_a_run_that_went_quiet(lua, direct): @@ -1925,6 +1963,31 @@ def test_an_unwritable_run_script_does_not_rerun_the_last_one(lua, direct): assert H.enabled("SmoothBtn") +def test_stop_ends_a_run_that_is_going(lua, direct): + """With no window to close, Stop is how a run ends early.""" + import time + H = load(lua, ui_script(direct)) + assert H.text_of("CancelBtn") == "Reset" + H.click("SmoothBtn") + assert H.text_of("CancelBtn") == "Stop" + write_status(direct, fraction=0.3, updated=time.time()) + H.click("CancelBtn") + assert (direct / ".eternal2x_status.stop").exists() + assert "Stopping" in H.status() + write_status(direct, fraction=0.3, done=True, ok=False, message="Stopped.") + H.click("RefreshStatusBtn") + assert H.status() == "Stopped." + assert H.text_of("CancelBtn") == "Reset" + assert H.enabled("SmoothBtn") + + +def test_a_stop_left_over_does_not_stop_the_next_run(lua, direct): + (direct / ".eternal2x_status.stop").write_text("") + H = load(lua, ui_script(direct)) + H.click("SmoothBtn") + assert not (direct / ".eternal2x_status.stop").exists() + + def test_a_render_that_finished_while_the_panel_was_closed_is_offered(lua, direct): write_status(direct, fraction=1.0, done=True, ok=True, message="Done.", import_path=str(direct / "shot_smooth.mp4")) @@ -2015,7 +2078,7 @@ def test_render_hands_over_the_frames_marked_on_the_clip(lua, direct): H.click("SmoothBtn") assert "--marks-file" in H.last_command() assert marks_sent(direct) == "0,6,12" - assert "3 frames marked" in H.status() + assert "3 marked frames" in H.status() def test_render_without_markers_finds_the_drawings_itself(lua, direct): @@ -2042,10 +2105,112 @@ def test_analyse_marks_the_drawings_it_found_on_the_clip(lua, direct): H.click("RefreshStatusBtn") assert H.marker_frames() == "0,2,4,8" assert H.tagged_markers() == 4 - assert "Marked 4 frames" in H.widgets["AnalysisLabel"].Text + assert H.widgets["AnalysisLabel"].Text == "Placed 4 markers. Animated on 2s." + assert "Marked 4 frames on the clip" in H.widgets["AnalysisLabel"].ToolTip assert "Render" in H.status() +def _analysed(lua, direct): + H = lua.eval(f'dofile("{HARNESS.as_posix()}")') + H.source_range = lua.table(1.0, 3.0) + H.load(str(ui_script(direct)), "/media/shot.mov") + H.click("AnalyseBtn") + write_status(direct, fraction=1.0, done=True, ok=True, marks="0,2,4,8", + message="12 frames, 4 unique drawings. Animated on 2s.") + H.click("RefreshStatusBtn") + return H + + +def test_rendering_the_analysed_clip_keeps_its_analysis(lua, direct): + H = _analysed(lua, direct) + H.click("SmoothBtn") + assert H.widgets["AnalysisLabel"].Text == "Placed 4 markers. Animated on 2s." + + +def test_another_part_of_the_file_clears_the_last_analysis(lua, direct): + H = _analysed(lua, direct) + H.source_range = lua.table(5.0, 7.0) + H.click("SmoothBtn") + assert "Placed" not in H.widgets["AnalysisLabel"].Text + assert H.widgets["AnalysisLabel"].ToolTip == "" + + +def test_another_clip_from_the_same_part_of_the_file_clears_the_analysis(lua, direct): + H = _analysed(lua, direct) + H.item_id = "another-cut" + follow(H) + assert "Placed" not in H.widgets["AnalysisLabel"].Text + + +def test_the_card_waits_while_the_playhead_moves(lua, direct): + """Playing back, the playhead moves every tick. Reading the clip under it + each time would cost Resolve, so the card catches up once it stops.""" + H = lua.eval(f'dofile("{HARNESS.as_posix()}")') + H.under_playhead = False + H.load(str(ui_script(direct)), "/media/shot.mov") + H.under_playhead = True + for tc in ("01:00:00:01", "01:00:00:02", "01:00:00:03"): + H.playhead_tc = tc + H.fire("PollTimer", "Timeout") + assert "shot.mov" not in H.widgets["SourceLabel"].Text, "it read the clip mid-playback" + H.fire("PollTimer", "Timeout") + assert "shot.mov" in H.widgets["SourceLabel"].Text + + +def test_finding_a_clip_clears_the_missing_clip_message(lua, direct): + H = lua.eval(f'dofile("{HARNESS.as_posix()}")') + H.under_playhead = False + H.load(str(ui_script(direct)), "/media/shot.mov") + assert "playhead" in H.status() + H.under_playhead = True + follow(H) + assert "shot.mov" in H.widgets["SourceLabel"].Text + assert H.status() == "Ready." + + +def test_refresh_progress_also_picks_up_the_clip(lua, direct): + """Without a timer, nothing else would move the card.""" + H = lua.eval(f'dofile("{HARNESS.as_posix()}")') + H.under_playhead = False + H.load(str(ui_script(direct)), "/media/shot.mov") + H.under_playhead = True + H.click("RefreshStatusBtn") + assert "shot.mov" in H.widgets["SourceLabel"].Text + + +def test_the_clip_card_follows_the_playhead_from_the_start(lua, direct): + """No button to press: the timer runs as soon as the panel opens.""" + H = load(lua, ui_script(direct)) + assert H.window.__items["PollTimer"].__running + + +def test_the_clip_card_keeps_the_last_clip_in_a_gap(lua, direct): + """As Analyse and Render do, so moving off the clip does not lose it.""" + H = load(lua, ui_script(direct)) + H.under_playhead = False + follow(H) + assert "shot.mov" in H.widgets["SourceLabel"].Text + assert H.widgets["SourceLabel"].ToolTip.endswith("\n\n/media/shot.mov") + assert H.status() == "Ready.", "a gap reported itself in the status" + + +def test_with_no_clip_the_card_has_no_path(lua, direct): + H = lua.eval(f'dofile("{HARNESS.as_posix()}")') + H.under_playhead = False + H.load(str(ui_script(direct)), "/media/shot.mov") + assert "shot.mov" not in H.widgets["SourceLabel"].ToolTip + assert "playhead" in H.widgets["SourceLabel"].ToolTip + + +def test_a_clip_kept_from_before_keeps_its_tip(lua, direct): + """The playhead in a gap: Analyse keeps the last clip, and its path.""" + H = load(lua, ui_script(direct)) + H.under_playhead = False + H.click("AnalyseBtn") + assert "shot.mov" in H.widgets["SourceLabel"].Text + assert H.widgets["SourceLabel"].ToolTip.endswith("/media/shot.mov") + + def test_analysing_again_replaces_only_its_own_markers(lua, direct): """The editor's markers stay. Eternal2x's from last time go.""" H = load(lua, ui_script(direct)) @@ -2061,6 +2226,7 @@ def test_the_threshold_slider_sets_how_big_a_change_counts(lua, direct): H = load(lua, ui_script(direct)) H.click("AnalyseBtn") assert "--threshold" not in H.last_command(), "the default should send nothing" + assert "ThresholdSlider" in H.window.__items, "the threshold should be on the panel" H.set_slider("ThresholdSlider", 7) assert H.widgets["ThresholdValue"].Text == "7" H.click("AnalyseBtn") @@ -2074,7 +2240,7 @@ def test_a_trimmed_clip_counts_its_markers_from_its_left_edge(lua, direct): into a clip trimmed 20 frames in reads back as frame 30.""" H = load(lua, ui_script(direct)) H.left_offset, H.source_frames = 20, lua.table(20, 67) - H.click("RefreshBtn") + follow(H) for key in (30, 20, 5, 75): H.add_marker(key) H.click("SmoothBtn") @@ -2084,7 +2250,7 @@ def test_a_trimmed_clip_counts_its_markers_from_its_left_edge(lua, direct): def test_analyse_marks_a_trimmed_clip_where_its_frames_are(lua, direct): H = load(lua, ui_script(direct)) H.left_offset, H.source_frames = 20, lua.table(20, 67) - H.click("RefreshBtn") + follow(H) H.click("AnalyseBtn") write_status(direct, fraction=1.0, done=True, ok=True, marks="0,2,4", message="On 2s.") H.click("RefreshStatusBtn") @@ -2096,7 +2262,7 @@ def test_markers_on_a_clip_at_another_rate_become_its_own_frames(lua, direct): H = load(lua, ui_script(direct)) H.clip_fps, H.timeline_fps = "30", "23.976" H.left_offset, H.source_frames = 47, lua.table(59, 118) - H.click("RefreshBtn") + follow(H) H.add_marker(47) H.add_marker(57) H.click("SmoothBtn") @@ -2132,7 +2298,7 @@ def test_markers_on_a_render_above_its_original_count_too(lua, direct): """The editor sees the render on top and may mark that instead.""" H = _stacked(lua, direct, "/r/shot_smooth/frame_[000000-000047].png", "/media/shot.mov") lua.execute("local H = ...; H.under_markers[1][0] = {}; H.under_markers[1][12] = {}", H) - H.click("RefreshBtn") + follow(H) H.add_marker(6) H.click("SmoothBtn") assert "shot.mov" in H.last_command() @@ -2149,7 +2315,7 @@ def test_markers_on_a_slowed_render_count_as_the_frames_they_show(lua, direct): H = _stacked(lua, direct, "/r/shot_3x_smooth/frame_[000000-000143].png", "/media/shot.mov") lua.execute("local H = ...; H.under_markers[1][0] = {}; " "H.src_span[2] = H.src_span[1] + 144", H) - H.click("RefreshBtn") + follow(H) H.add_marker(6) H.add_marker(120) H.click("SmoothBtn") @@ -2171,7 +2337,7 @@ def test_a_marker_near_the_end_of_a_slowed_render_counts(lua, direct): H = _stacked(lua, direct, "/r/shot_3x_smooth/frame_[000000-000356].png", "/media/shot.mov") lua.execute("local H = ...; H.clip_fps = '60'; H.src_span[2] = H.src_span[1] + 142; " "H.under_spans[1][2] = H.under_spans[1][1] + 47", H) - H.click("RefreshBtn") + follow(H) H.add_marker(141) H.click("SmoothBtn") assert marks_sent(direct) == "117" @@ -2201,7 +2367,7 @@ def test_analyse_markers_never_read_back_as_the_drawing_before(lua, direct): H.clip_fps, H.timeline_fps, H.item_duration, H.left_offset = "30", "23.976", 200, 47 H.source_frames = lua.table(59, 308) lua.execute("local H = ...; H.src_span = nil; H.tracks = nil", H) - H.click("RefreshBtn") + follow(H) H.click("AnalyseBtn") write_status(direct, fraction=1.0, done=True, ok=True, marks="0,125,127,129", message="On 2s.") H.click("RefreshStatusBtn") @@ -2217,7 +2383,7 @@ def test_markers_on_a_clip_whose_bounds_cannot_be_read_stop_the_render(lua, dire """Guessing the left offset would read every marker as the wrong frame.""" H = load(lua, ui_script(direct)) H.left_offset_fails = True - H.click("RefreshBtn") + follow(H) H.add_marker(30) before = H.last_command() H.click("SmoothBtn") @@ -2230,7 +2396,7 @@ def test_markers_on_a_trimmed_clip_without_its_source_range_stop_the_render(lua, counted from the clip's left edge would keep the wrong frames.""" H = load(lua, ui_script(direct)) H.left_offset = 20 - H.click("RefreshBtn") + follow(H) H.add_marker(30) before = H.last_command() H.click("SmoothBtn") @@ -2241,7 +2407,7 @@ def test_markers_on_a_trimmed_clip_without_its_source_range_stop_the_render(lua, def test_analyse_places_no_markers_on_a_trimmed_clip_without_its_source_range(lua, direct): H = load(lua, ui_script(direct)) H.left_offset = 20 - H.click("RefreshBtn") + follow(H) H.click("AnalyseBtn") write_status(direct, fraction=1.0, done=True, ok=True, marks="0,2,4", message="On 2s.") H.click("RefreshStatusBtn") @@ -2261,7 +2427,7 @@ def test_markers_on_an_untrimmed_clip_without_its_source_range_still_render(lua, def test_an_unmarked_clip_whose_bounds_cannot_be_read_still_renders(lua, direct): H = load(lua, ui_script(direct)) H.left_offset_fails = True - H.click("RefreshBtn") + follow(H) H.click("SmoothBtn") assert "--marks-file" not in H.last_command() assert "No markers" in H.status() @@ -2301,7 +2467,7 @@ def test_markers_on_a_render_at_another_rate_count_as_the_original_frames(lua, d render's marker 12 timeline frames in is the original's frame 15.""" H = _stacked(lua, direct, "/r/shot_smooth/frame_[000000-000047].png", "/media/shot.mov") lua.execute("local H = ...; H.under_fps = '30'; H.under_markers[1][0] = {}", H) - H.click("RefreshBtn") + follow(H) H.add_marker(12) H.click("SmoothBtn") assert marks_sent(direct) == "0,15" @@ -2310,7 +2476,7 @@ def test_markers_on_a_render_at_another_rate_count_as_the_original_frames(lua, d def test_render_markers_past_the_end_of_the_original_do_not_count(lua, direct): H = _stacked(lua, direct, "/r/shot_smooth/frame_[000000-000047].png", "/media/shot.mov") lua.execute("local H = ...; H.under_spans[1][2] = H.under_spans[1][1] + 24", H) - H.click("RefreshBtn") + follow(H) H.add_marker(30) H.click("SmoothBtn") assert "--marks-file" not in H.last_command() @@ -2322,12 +2488,12 @@ def test_found_frames_that_share_a_timeline_frame_are_reported(lua, direct): share timeline frame 4. The panel says so rather than dropping one.""" H = load(lua, ui_script(direct)) H.clip_fps, H.timeline_fps = "30", "23.976" - H.click("RefreshBtn") + follow(H) H.click("AnalyseBtn") write_status(direct, fraction=1.0, done=True, ok=True, marks="0,2,4,5,7", message="On 1s.") H.click("RefreshStatusBtn") assert H.marker_frames() == "0,2,4,6" - assert "1 more found frame shares a timeline frame" in H.widgets["AnalysisLabel"].Text + assert "1 more found frame shares a timeline frame" in H.widgets["AnalysisLabel"].ToolTip def test_ntsc_rates_count_exactly_over_a_long_clip(lua, direct): @@ -2336,7 +2502,7 @@ def test_ntsc_rates_count_exactly_over_a_long_clip(lua, direct): H = load(lua, ui_script(direct)) H.clip_fps, H.timeline_fps, H.item_duration = "23.976", "24", 1200 lua.execute("local H = ...; H.src_span = nil; H.tracks = nil", H) - H.click("RefreshBtn") + follow(H) H.add_marker(1001) H.click("SmoothBtn") assert marks_sent(direct) == "1000" @@ -2350,9 +2516,9 @@ def test_ntsc_rates_count_exactly_over_a_long_clip(lua, direct): def test_several_shared_frames_read_as_a_plural(lua, direct): H = load(lua, ui_script(direct)) H.clip_fps, H.timeline_fps = "60", "24" - H.click("RefreshBtn") + follow(H) H.click("AnalyseBtn") write_status(direct, fraction=1.0, done=True, ok=True, marks="0,3,5,6,7", message="On 1s.") H.click("RefreshStatusBtn") - text = H.widgets["AnalysisLabel"].Text + text = H.widgets["AnalysisLabel"].ToolTip assert "2 more found frames share timeline frames with others, as the clip runs faster" in text diff --git a/tests/test_render.py b/tests/test_render.py index fb891d6..d99a1b4 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -473,9 +473,9 @@ def test_progress_is_reported(make_anime, tmp_path): progress=lambda s, f: seen.append((s, f))) stages = {s for s, _ in seen} - assert "Analysing" in stages and "Rendering" in stages + assert "Analysing" in stages and "Interpolating" in stages assert all(0.0 <= f <= 1.0 for _, f in seen) - assert max(f for s, f in seen if s == "Rendering") == pytest.approx(1.0, abs=0.01) + assert max(f for s, f in seen if s == "Interpolating") == pytest.approx(1.0, abs=0.01) diff --git a/tests/test_resolve_smooth.py b/tests/test_resolve_smooth.py index 5a6c261..b5af58b 100644 --- a/tests/test_resolve_smooth.py +++ b/tests/test_resolve_smooth.py @@ -364,11 +364,12 @@ def _grainy_twos(tmp_path): return src -def test_a_grainy_clip_suggests_grainy_source(monkeypatch, tmp_path): +def test_a_grainy_clip_says_to_mark_the_drawings(monkeypatch, tmp_path): status = tmp_path / "status.json" run(monkeypatch, ["--video", str(_grainy_twos(tmp_path)), "--analyse", "--status-file", str(status)]) - assert "Grainy source" in json.loads(status.read_text())["message"] + message = json.loads(status.read_text())["message"] + assert "grain" in message and "Mark the drawings with M" in message def test_grainy_source_finds_the_drawings(monkeypatch, tmp_path): @@ -402,6 +403,144 @@ def test_status_file_records_failures(install_fake_resolve, monkeypatch, tmp_pat assert "missing" in payload["message"] +def test_the_panels_stop_ends_the_run(make_anime, monkeypatch, tmp_path): + """The run has no window to close, so the panel's Stop file ends it.""" + src = make_anime(holds=(2,) * 8) + status = tmp_path / "status.json" + stop = tmp_path / "status.stop" + stop.write_text("") + out = tmp_path / "o" + assert run(monkeypatch, ["--video", str(src), "--status-file", str(status), + "--output-dir", str(out)]) == 1 + + payload = json.loads(status.read_text()) + assert payload["done"] is True + assert payload["ok"] is False + assert payload["message"] == "Stopped." + assert not stop.exists(), "a Stop left behind would end the next run too" + assert not list(out.rglob("*.mp4")) and not list(out.rglob("*.png")) + + +def test_a_stop_while_the_last_frames_are_written_still_counts(make_anime, monkeypatch, + tmp_path): + src = make_anime(holds=(2,) * 8) + status = tmp_path / "status.json" + real = smooth.render_plan + + def render_then_stop(*a, **k): + result = real(*a, **k) + status.with_suffix(".stop").write_text("") + return result + + monkeypatch.setattr(smooth, "render_plan", render_then_stop) + assert run(monkeypatch, ["--video", str(src), "--status-file", str(status), + "--output-dir", str(tmp_path / "o")]) == 1 + payload = json.loads(status.read_text()) + assert payload["ok"] is False and payload["message"] == "Stopped." + assert payload["import_path"] == "", "a stopped render must not be imported" + + +def _stop_after_render(monkeypatch, status): + real = smooth.render_plan + + def render_then_stop(*a, **k): + result = real(*a, **k) + status.with_suffix(".stop").write_text("") + return result + + monkeypatch.setattr(smooth, "render_plan", render_then_stop) + + +def test_a_late_stop_is_not_reported_as_a_success_in_json(make_anime, monkeypatch, capsys, + tmp_path): + src = make_anime(holds=(2,) * 8) + status = tmp_path / "status.json" + _stop_after_render(monkeypatch, status) + assert run(monkeypatch, ["--video", str(src), "--json", "--status-file", str(status), + "--output-dir", str(tmp_path / "o")]) == 1 + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["ok"] is False and payload["error"] == "Stopped." + assert "import_path" not in payload + + +def test_a_late_stop_puts_nothing_into_resolve(anime_in_resolve, monkeypatch, tmp_path): + _src, resolve, _tl, _items, _ = anime_in_resolve() + status = tmp_path / "status.json" + _stop_after_render(monkeypatch, status) + assert run(monkeypatch, ["--status-file", str(status), + "--output-dir", str(tmp_path / "out")]) == 1 + pool = resolve.GetProjectManager().GetCurrentProject().GetMediaPool() + assert not pool.imported, "a stopped render was imported" + + +def test_a_stop_while_the_final_status_waits_for_the_panel_still_counts(make_anime, + monkeypatch, + tmp_path): + """The panel holds the status file open as it polls, so the last write can + wait a while, and Stop can come in that time.""" + src = make_anime(holds=(2,) * 8) + status = tmp_path / "status.json" + real_replace = Path.replace + held = [] + + def held_once(self, target): + if Path(target) == status and not held and '"ok": true' in self.read_text(): + held.append(True) + status.with_suffix(".stop").write_text("") + raise PermissionError("the panel has it open") + return real_replace(self, target) + + monkeypatch.setattr(Path, "replace", held_once) + assert run(monkeypatch, ["--video", str(src), "--status-file", str(status), + "--output-dir", str(tmp_path / "o")]) == 1 + payload = json.loads(status.read_text()) + assert payload["ok"] is False and payload["message"] == "Stopped." + + +def test_a_stop_once_the_import_has_begun_lets_it_finish(anime_in_resolve, monkeypatch, + tmp_path): + """Stopped halfway into Resolve, the clip would be imported but not placed.""" + _src, resolve, timeline, _items, _ = anime_in_resolve() + status = tmp_path / "status.json" + pool = resolve.GetProjectManager().GetCurrentProject().GetMediaPool() + real_import = pool.ImportMedia + + def import_then_stop(*a, **k): + status.with_suffix(".stop").write_text("") + return real_import(*a, **k) + + monkeypatch.setattr(pool, "ImportMedia", import_then_stop) + assert run(monkeypatch, ["--status-file", str(status), + "--output-dir", str(tmp_path / "out")]) == 0 + assert pool.imported, "nothing was imported" + assert json.loads(status.read_text())["ok"] is True + assert not status.with_suffix(".stop").exists(), "it would stop the next run" + + +def test_a_stop_during_an_import_resolve_refuses_does_not_stop_the_next_run( + anime_in_resolve, monkeypatch, tmp_path): + """The render is finished either way, so the Stop came too late to use.""" + _src, resolve, _tl, _items, _ = anime_in_resolve() + status = tmp_path / "status.json" + pool = resolve.GetProjectManager().GetCurrentProject().GetMediaPool() + pool.import_mode = "none" + real_import = pool.ImportMedia + + def import_then_stop(*a, **k): + status.with_suffix(".stop").write_text("") + return real_import(*a, **k) + + monkeypatch.setattr(pool, "ImportMedia", import_then_stop) + assert run(monkeypatch, ["--status-file", str(status), + "--output-dir", str(tmp_path / "out")]) == 0 + assert not status.with_suffix(".stop").exists(), "it would stop the next run" + + monkeypatch.setattr(pool, "ImportMedia", real_import) + assert run(monkeypatch, ["--status-file", str(status), + "--output-dir", str(tmp_path / "again")]) == 0 + assert json.loads(status.read_text())["message"] != "Stopped." + + def test_status_file_never_contains_a_partial_write(make_anime, monkeypatch, tmp_path): """The panel polls this file, so it must always parse.""" src = make_anime(holds=(2,) * 8) diff --git a/tests/test_rife.py b/tests/test_rife.py index 1b4393e..49cea8e 100644 --- a/tests/test_rife.py +++ b/tests/test_rife.py @@ -221,6 +221,28 @@ def test_a_slow_pip_keeps_saying_it_is_working(monkeypatch): assert len(said) >= 3 and set(said) == {"Installing AI runtime"} +def test_a_stop_during_the_install_lets_pip_finish(monkeypatch): + """Cut short, pip could leave the Python half installed.""" + from Stages.resolve_smooth import Stopped + + monkeypatch.setattr(rife, "HEARTBEAT_SECONDS", 0.05) + started = [] + + def stop(stage, fraction): + raise Stopped() + + real_popen = rife.subprocess.Popen + + def popen(*a, **k): + started.append(real_popen(*a, **k)) + return started[-1] + + monkeypatch.setattr(rife.subprocess, "Popen", popen) + with pytest.raises(Stopped): + rife._pip([sys.executable, "-c", "import time; time.sleep(0.4)"], stop) + assert started[0].poll() is not None, "pip was left running" + + class _FakeSession: def __init__(self, providers, fail_on_gpu_start=False, fail_on_gpu_run=False, gpu_quietly_on_cpu=False): diff --git a/tests/test_upscaler.py b/tests/test_upscaler.py index e6a012b..09f212b 100644 --- a/tests/test_upscaler.py +++ b/tests/test_upscaler.py @@ -227,8 +227,19 @@ def upscale(self, frame): render_plan(src, plan, tmp_path / "out", Eternal2xConfig(network_upscale=True, upscale_enabled=False), sequence=True, progress=lambda stage, fraction: stages.append(stage)) - assert stages[:3] == ["Rendering"] * 3 - assert stages[-1] == "Rendering, upscaling on the CPU" + assert stages[:3] == ["Interpolating and upscaling"] * 3 + assert stages[-1] == "Interpolating and upscaling on the CPU" + + +def test_an_upscale_alone_says_upscaling(monkeypatch, make_anime, tmp_path): + monkeypatch.setattr(upscaler, "Upscaler", _TwiceAsBig) + stages = [] + src = make_anime(holds=(2,) * 6) + plan, _, _ = analyse_video(src, Eternal2xConfig()) + render_plan(src, plan, tmp_path / "out", Eternal2xConfig(network_upscale=True, + upscale_enabled=False, interpolate_enabled=False), sequence=True, + progress=lambda stage, fraction: stages.append(stage)) + assert set(stages) == {"Upscaling"} def test_the_network_is_only_loaded_when_asked(twice_as_big, make_anime, tmp_path):