From e0460ed0c89d223d2dd0ecb3340d155136edfb77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:34:57 +0000 Subject: [PATCH 1/2] Seek to chapter in VOD player instead of shifting chat Chapter "Open in player" links passed the chapter start as offset=, which twitch-vod-chat treats as the chat offset, so chat was shifted by the chapter start time instead of the video seeking there. Pass it as t= (seek target) instead, and bump the twitch-vod-chat submodule to the version that supports t=. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XuPs3nkTaDwZGoCD8oKKEV --- client-vue/src/components/vod/VodItemChapters.vue | 2 +- twitch-vod-chat | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client-vue/src/components/vod/VodItemChapters.vue b/client-vue/src/components/vod/VodItemChapters.vue index e6e5b38c..3db244e7 100644 --- a/client-vue/src/components/vod/VodItemChapters.vue +++ b/client-vue/src/components/vod/VodItemChapters.vue @@ -234,7 +234,7 @@ function playerLink(offset = 0, chatdownload = false): string { if (!props.vod || !store.config) return "#"; const video_path = `${props.vod.webpath}/${props.vod.basename}.mp4`; const chat_path = `${props.vod.webpath}/${props.vod.basename}.${chatdownload ? "chat" : "chatdump"}`; - return `${store.cfg("basepath", "")}/vodplayer#source=file_http&video_path=${video_path}&chatfile=${chat_path}&offset=${offset}`; + return `${store.cfg("basepath", "")}/vodplayer#source=file_http&video_path=${video_path}&chatfile=${chat_path}&t=${Math.floor(offset)}`; } function twitchVideoLink(video_id: string): string { diff --git a/twitch-vod-chat b/twitch-vod-chat index 39251925..823d4a9c 160000 --- a/twitch-vod-chat +++ b/twitch-vod-chat @@ -1 +1 @@ -Subproject commit 39251925ac72860b973cff0c8c93f4691e7cd951 +Subproject commit 823d4a9caf5335aeddd3c021c7291e6b4e12622d From 44a18c5ac1102d98b3e47c6c9936621dbfa0fed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 21:08:19 +0000 Subject: [PATCH 2/2] Keep chat and chapters in sync with captures that had ad breaks Streamlink filters out Twitch ad segments and the remux to mp4 collapses the resulting gaps, so the captured video is shorter than the stream. Chat and chapters are timed in stream time, so they drifted further after every ad break (12+ minutes on a 4.5h stream). Sync existing captures to the video using the recorded stream pauses: - Map stream time to video time by removing the pauses before each point (StreamPauses helper). Nothing changes for captures without pauses. - Serve a synced copy of the chat dump (.synced.chatdump) at /api/v0/vod/:uuid/synced.chatdump, generated on demand. The original chat dump is never modified. - Use it in the player links, chat render for the captured video and clip chat cuts. - Add video_offset/video_duration to chapters and use them for everything that refers to the captured video (chapter table, player and editor links, embedded chapters, LosslessCut EDL, VTT, split by chapters). The stream time offset is kept for Twitch links and chapter editing. Optionally fill the pauses with black video when converting (video.pad_stream_pauses, off by default): - Cut the capture at each pause, at the exact timestamp gap if one matches or at the nearest keyframe of the estimated position, and insert a black, silent filler of the pause length using the concat demuxer. - The capture is only stream copied; one short filler unit is encoded and repeated. - Padded vods are flagged (stream_pauses_padded) so no sync is applied. - Falls back to a normal conversion on any failure. Also fix Helper.path_ffprobe returning the ffmpeg binary on Linux. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XuPs3nkTaDwZGoCD8oKKEV --- client-vue/src/components/vod/VodItem.vue | 13 +- .../src/components/vod/VodItemChapters.vue | 34 +- client-vue/src/core/Providers/Base/BaseVOD.ts | 6 + .../src/core/Providers/Base/BaseVODChapter.ts | 8 + .../core/Providers/Twitch/TwitchVODChapter.ts | 2 + client-vue/src/views/EditorView.vue | 18 +- client-vue/test/mockdata.ts | 4 + common/Api/Client.ts | 9 + common/ServerConfig.ts | 7 + server/src/Controllers/Vod.ts | 40 +- server/src/Core/Helper.ts | 6 +- .../src/Core/Providers/Base/BaseAutomator.ts | 94 +++- server/src/Core/Providers/Base/BaseVOD.ts | 170 ++++++- .../src/Core/Providers/Base/BaseVODChapter.ts | 27 +- server/src/Core/Providers/Twitch/TwitchVOD.ts | 21 +- .../Core/Providers/Twitch/TwitchVODChapter.ts | 2 + server/src/Helpers/PadVideo.test.ts | 211 +++++++++ server/src/Helpers/PadVideo.ts | 421 ++++++++++++++++++ server/src/Helpers/StreamPauses.test.ts | 187 ++++++++ server/src/Helpers/StreamPauses.ts | 131 ++++++ server/src/Helpers/Video.ts | 225 +++++++++- server/src/Routes/Api.ts | 1 + server/src/Storage/JSON.ts | 2 + 23 files changed, 1579 insertions(+), 60 deletions(-) create mode 100644 server/src/Helpers/PadVideo.test.ts create mode 100644 server/src/Helpers/PadVideo.ts create mode 100644 server/src/Helpers/StreamPauses.test.ts create mode 100644 server/src/Helpers/StreamPauses.ts diff --git a/client-vue/src/components/vod/VodItem.vue b/client-vue/src/components/vod/VodItem.vue index 792ac1ee..f14cd1bf 100644 --- a/client-vue/src/components/vod/VodItem.vue +++ b/client-vue/src/components/vod/VodItem.vue @@ -451,13 +451,22 @@ function openPlayer(): void { hash += `&video_path=${props.vod.webpath}/${props.vod.basename}_vod.mp4`; } + // stream pauses are cut out of the captured video, chat and chapters need to be synced to it + const syncToVideo = playerSettings.value.vodSource == "captured" && props.vod.needs_video_sync; + if (playerSettings.value.chatSource == "captured") { - hash += `&chatfile=${props.vod.webpath}/${props.vod.basename}.chatdump`; + if (syncToVideo) { + hash += `&chatfile=${store.cfg("basepath", "")}/api/v0/vod/${props.vod.uuid}/synced.chatdump`; + } else { + hash += `&chatfile=${props.vod.webpath}/${props.vod.basename}.chatdump`; + } } else { hash += `&chatfile=${props.vod.webpath}/${props.vod.basename}_chat.json`; } - hash += `&chapters=${props.vod.chapters?.map((c) => `${c.offset}:${encodeURIComponent(c.title.replaceAll(":", "").replaceAll(";", ""))}`).join(";")}`; + hash += `&chapters=${props.vod.chapters + ?.map((c) => `${syncToVideo ? c.video_offset : c.offset}:${encodeURIComponent(c.title.replaceAll(":", "").replaceAll(";", ""))}`) + .join(";")}`; // url.searchParams.set("offset", this.playerSettings.offset.toString()); // window.open(url.toString(), "_blank"); diff --git a/client-vue/src/components/vod/VodItemChapters.vue b/client-vue/src/components/vod/VodItemChapters.vue index 3db244e7..deea1d0f 100644 --- a/client-vue/src/components/vod/VodItemChapters.vue +++ b/client-vue/src/components/vod/VodItemChapters.vue @@ -42,8 +42,8 @@ }" > - - {{ chapter.offset !== undefined ? humanDuration(chapter.offset) : "Unknown" }} + + {{ chapter.video_offset !== undefined ? humanDuration(chapter.video_offset) : "Unknown" }} @@ -58,15 +58,15 @@ -