-- ReaRecordVideo.lua -- Record a camera with ffmpeg while REAPER records audio, then import and align. local script_path = debug.getinfo(1).source:match("@?(.*[\\/])") package.path = package.path .. ";" .. script_path .. "?.lua" package.loaded["ffmpeg_utils"] = nil local ffmpeg = require("ffmpeg_utils") local r = reaper if not r.ImGui_CreateContext then r.ShowMessageBox("ReaImGui is required for this script.", "Error", 0) return end local ctx = r.ImGui_CreateContext("ReaRecordVideo") local APP_NAME = "VideoRecorder" local state = { video_devices = {}, audio_devices = {}, selected_video_idx = 0, selected_audio_idx = -1, -- -1 = None (REAPER handles audio) selected_video_name = nil, selected_audio_name = nil, status_msg = "Idle", error_msg = nil, ffmpeg_path = "", phase = "idle", -- idle | recording | stopping recording_start_time = 0, recorded_duration = 0, start_cursor_pos = nil, output_filename = "", output_full_path = "", stop_wait_start = 0, imported = false, closing = false, } if r.HasExtState("ReaRecordVideo", "ffmpeg_path") then state.ffmpeg_path = r.GetExtState("ReaRecordVideo", "ffmpeg_path") ffmpeg.set_ffmpeg_path(state.ffmpeg_path) end local function device_index_by_name(list, name) if not name then return nil end for i, dev in ipairs(list) do if dev.name == name then return i - 1 end end return nil end local function RefreshDevices() ffmpeg.set_ffmpeg_path(state.ffmpeg_path) local prev_video = state.selected_video_name local prev_audio = state.selected_audio_name state.status_msg = "Scanning devices..." local v, a = ffmpeg.get_devices() state.video_devices = v state.audio_devices = a local v_idx = device_index_by_name(v, prev_video) if v_idx then state.selected_video_idx = v_idx elseif #v > 0 then state.selected_video_idx = 0 state.selected_video_name = v[1].name else state.selected_video_idx = 0 state.selected_video_name = nil end local a_idx = device_index_by_name(a, prev_audio) if a_idx then state.selected_audio_idx = a_idx else state.selected_audio_idx = -1 state.selected_audio_name = nil end if ffmpeg.any_ffmpeg_running() and state.phase == "idle" then state.status_msg = "Warning: an ffmpeg process is already running" else state.status_msg = "Idle" end end RefreshDevices() local function GetLoopInfo() if r.GetSetRepeat(-1) == 0 then return nil end local startPosition, endPosition = r.GetSet_LoopTimeRange(false, true, 0, 0, false) if startPosition == endPosition then return nil end return { position = startPosition, length = endPosition - startPosition, } end local function TrackHasMediaPastCursor(track) local pos = state.start_cursor_pos or r.GetCursorPosition() local count = r.GetTrackNumMediaItems(track) for i = 0, count - 1 do local item = r.GetTrackMediaItem(track, i) local item_pos = r.GetMediaItemInfo_Value(item, "D_POSITION") local item_len = r.GetMediaItemInfo_Value(item, "D_LENGTH") if item_pos + item_len > pos then return true end end return false end local function FindActiveVideoRecordingTrack() for i = 0, r.GetNumTracks() - 1 do local track = r.GetTrack(0, i) local _, name = r.GetTrackName(track) if name:match(APP_NAME) and not TrackHasMediaPastCursor(track) then return track end end return nil end local function GetTrackForVideo() local track = FindActiveVideoRecordingTrack() if track then return track end track = r.GetSelectedTrack(0, 0) if track and not TrackHasMediaPastCursor(track) then return track end local n = r.GetNumTracks() r.InsertTrackAtIndex(n, false) track = r.GetTrack(0, n) r.GetSetMediaTrackInfo_String(track, "P_NAME", APP_NAME .. " " .. os.date("%Y-%m-%d"), true) r.SetMediaTrackInfo_Value(track, "D_VOL", 0) return track end local function source_duration(source, full_path) if source then local len = r.GetMediaSourceLength(source) if len and len > 0 then return len end end return ffmpeg.get_video_duration(full_path) end local function BuildVideoItem(filename, recordingDuration) local project_path = r.GetProjectPath("") local full_path = state.output_full_path if not full_path or full_path == "" then full_path = project_path .. "/" .. filename end if not ffmpeg.file_exists(full_path) then error("Video file not found: " .. full_path, 0) end r.Undo_BeginBlock() local track = GetTrackForVideo() local source = r.PCM_Source_CreateFromFile(full_path) local video_file_duration = source_duration(source, full_path) if not video_file_duration or video_file_duration <= 0 then if source then r.PCM_Source_Destroy(source) end r.Undo_EndBlock("ReaRecordVideo import (failed)", -1) error("Could not determine video length. Import failed.", 0) end local rate = r.Master_GetPlayRate(0) if not rate or rate == 0 then rate = 1 end local itemPosition = state.start_cursor_pos or r.GetCursorPosition() -- Size the item to the REAPER take, not the shorter ffmpeg file. -- Negative D_STARTOFFS (latency) used to hide the tail past the item edge. local span = (recordingDuration and recordingDuration > 0) and recordingDuration or video_file_duration local itemLength = span * rate local loop = GetLoopInfo() if loop then itemPosition = loop.position itemLength = loop.length end local item = r.AddMediaItemToTrack(track) r.SetMediaItemInfo_Value(item, "D_POSITION", itemPosition) r.SetMediaItemInfo_Value(item, "D_LENGTH", itemLength) local latency = (recordingDuration or video_file_duration) - video_file_duration local offset = 0 local first = true while offset < video_file_duration do local take = r.AddTakeToMediaItem(item) local take_source = first and source or r.PCM_Source_CreateFromFile(full_path) first = false r.SetMediaItemTake_Source(take, take_source) r.SetMediaItemTakeInfo_Value(take, "D_PLAYRATE", 1 / rate) r.SetMediaItemTakeInfo_Value(take, "D_STARTOFFS", offset - latency) offset = offset + (itemLength / rate) end r.Main_OnCommand(40047, 0) -- Peaks: Build any missing peaks r.UpdateArrange() r.Undo_EndBlock("ReaRecordVideo import", -1) state.imported = true end local function ReaperIsRecording() return (r.GetPlayState() & 4) ~= 0 end local function RequestStop() if state.phase ~= "recording" then return end state.recorded_duration = r.time_precise() - state.recording_start_time -- Already stopped in REAPER (space / transport stop); don't issue Stop again. if ReaperIsRecording() then r.Main_OnCommand(40667, 0) -- Transport: Stop (save all recorded media) end ffmpeg.stop_recording() state.phase = "stopping" state.stop_wait_start = r.time_precise() state.status_msg = "Finalizing video..." end local function PollStop() local elapsed = r.time_precise() - state.stop_wait_start if ffmpeg.is_recording() and elapsed < 8.0 then state.status_msg = "Finalizing video..." return end local dur = ffmpeg.get_video_duration(state.output_full_path) if (not dur or dur <= 0) and elapsed < 8.0 then state.status_msg = "Reading video file..." return end state.phase = "idle" state.status_msg = string.format("Recorded %.2fs", state.recorded_duration) local ok, err = pcall(BuildVideoItem, state.output_filename, state.recorded_duration) if not ok then state.error_msg = tostring(err):gsub("^.-:%d+: ", "") end ffmpeg.clear() end local function StartRecording() state.error_msg = nil if #state.video_devices == 0 then state.error_msg = "No video device selected" return end local vid_dev = state.video_devices[state.selected_video_idx + 1] if not vid_dev then state.error_msg = "No video device selected" return end local aud_dev = nil if state.selected_audio_idx >= 0 then aud_dev = state.audio_devices[state.selected_audio_idx + 1] end local project_path = r.GetProjectPath("") if not project_path or project_path == "" then state.error_msg = "Save the project first so video has a destination folder" return end local filename = "video_" .. os.date("%y%m%d_%H%M%S") .. ".mkv" if ffmpeg.is_windows() then filename = filename:gsub("%.mkv$", ".avi") end local full_path = project_path .. "/" .. filename state.start_cursor_pos = r.GetCursorPosition() state.output_filename = filename state.output_full_path = full_path state.imported = false local ok = ffmpeg.start_recording(vid_dev.id, aud_dev and aud_dev.id or nil, full_path) if not ok then state.error_msg = "Failed to start ffmpeg" ffmpeg.clear() return end local loop = GetLoopInfo() if loop then r.SetEditCurPos(loop.position, true, true) end r.Main_OnCommand(1013, 0) -- Transport: Record state.recording_start_time = r.time_precise() state.phase = "recording" state.error_msg = nil end local function DestroyUI() if ctx and r.ImGui_DestroyContext then pcall(r.ImGui_DestroyContext, ctx) ctx = nil end end local function Frame() if state.phase == "recording" then -- Spacebar, transport stop, or record toggle-off in REAPER. -- Small grace so 1013 has time to raise the recording bit. if not ReaperIsRecording() and (r.time_precise() - state.recording_start_time) > 0.25 then RequestStop() elseif not ffmpeg.is_recording() then state.error_msg = "ffmpeg exited unexpectedly" RequestStop() end elseif state.phase == "stopping" then PollStop() end if state.closing then if state.phase == "stopping" then r.defer(Frame) return end DestroyUI() return end local visible, open = r.ImGui_Begin(ctx, "Record Video", true) if visible then local busy = state.phase ~= "idle" if busy then r.ImGui_BeginDisabled(ctx) end if r.ImGui_BeginCombo(ctx, "Video Device", state.video_devices[state.selected_video_idx + 1] and state.video_devices[state.selected_video_idx + 1].name or "Select...") then for i, dev in ipairs(state.video_devices) do if r.ImGui_Selectable(ctx, dev.name, i - 1 == state.selected_video_idx) then state.selected_video_idx = i - 1 state.selected_video_name = dev.name end end r.ImGui_EndCombo(ctx) end local audio_label = "None (REAPER audio)" if state.selected_audio_idx >= 0 and state.audio_devices[state.selected_audio_idx + 1] then audio_label = state.audio_devices[state.selected_audio_idx + 1].name end if r.ImGui_BeginCombo(ctx, "Audio Device", audio_label) then if r.ImGui_Selectable(ctx, "None (REAPER audio)", state.selected_audio_idx == -1) then state.selected_audio_idx = -1 state.selected_audio_name = nil end for i, dev in ipairs(state.audio_devices) do if r.ImGui_Selectable(ctx, dev.name, i - 1 == state.selected_audio_idx) then state.selected_audio_idx = i - 1 state.selected_audio_name = dev.name end end r.ImGui_EndCombo(ctx) end local changed, new_path = r.ImGui_InputText(ctx, "FFMPEG Path", state.ffmpeg_path) if changed then state.ffmpeg_path = new_path r.SetExtState("ReaRecordVideo", "ffmpeg_path", new_path, true) ffmpeg.set_ffmpeg_path(new_path) end if r.ImGui_Button(ctx, "Refresh Devices") and not busy then RefreshDevices() end if busy then r.ImGui_EndDisabled(ctx) end r.ImGui_Separator(ctx) if state.phase == "recording" then r.ImGui_TextColored(ctx, 0xFF0000FF, "RECORDING") r.ImGui_SameLine(ctx) r.ImGui_Text(ctx, string.format("%.1fs", r.time_precise() - state.recording_start_time)) else r.ImGui_Text(ctx, "Status: " .. state.status_msg) end if state.error_msg then r.ImGui_TextColored(ctx, 0xFF0000FF, "Error: " .. state.error_msg) end local w = select(1, r.ImGui_GetContentRegionAvail(ctx)) local btn_h = 50 if state.phase == "recording" then if r.ImGui_Button(ctx, "STOP RECORDING", w, btn_h) then RequestStop() end elseif state.phase == "stopping" then r.ImGui_BeginDisabled(ctx) r.ImGui_Button(ctx, "FINALIZING...", w, btn_h) r.ImGui_EndDisabled(ctx) else if r.ImGui_Button(ctx, "START RECORDING", w, btn_h) then local ok, err = pcall(StartRecording) if not ok then state.error_msg = tostring(err):gsub("^.-:%d+: ", "") state.phase = "idle" end end end r.ImGui_End(ctx) end if open then r.defer(Frame) else state.closing = true if state.phase == "recording" then RequestStop() end if state.phase == "stopping" then r.defer(Frame) return end DestroyUI() end end r.atexit(function() if state.phase == "recording" then r.Main_OnCommand(40667, 0) ffmpeg.stop_recording() if state.output_filename ~= "" and not state.imported then state.recorded_duration = r.time_precise() - state.recording_start_time pcall(BuildVideoItem, state.output_filename, state.recorded_duration) end elseif state.phase == "stopping" and not state.imported then pcall(BuildVideoItem, state.output_filename, state.recorded_duration) end DestroyUI() end) r.defer(Frame)