-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat: optional WebVTT timestamp subtitle track in exported MP4s #4766
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -104,6 +104,8 @@ function downloadEvents( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $minTime = ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $maxTimeSecs = -1; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $maxTime = ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $vttCues = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $concatOffset = 0.0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| foreach ($events_by_monitor_id[$mid] as $event) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ($minTimeSecs == -1 or $minTimeSecs > $event->StartDateTimeSecs()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $minTimeSecs = $event->StartDateTimeSecs(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -114,6 +116,32 @@ function downloadEvents( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $maxTime = $event->EndDateTime(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $eventFileList .= 'file \''.$event->Path().'/'.$event->DefaultVideo().'\''.PHP_EOL; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $duration = (float)$event->Length(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ($duration <= 0 and $event->EndDateTimeSecs()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $duration = $event->EndDateTimeSecs() - $event->StartDateTimeSecs(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ($duration > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $eventStart = $event->StartDateTimeSecs(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $whole = (int)floor($duration); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for ($s = 0; $s < $whole; $s++) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $vttCues[] = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'start' => $concatOffset + $s, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'end' => $concatOffset + $s + 1, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'text' => date('Y-m-d H:i:s', $eventStart + $s), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+127
to
+133
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ($duration - $whole > 0.001) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $vttCues[] = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'start' => $concatOffset + $whole, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'end' => $concatOffset + $duration, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'text' => date('Y-m-d H:i:s', $eventStart + $whole), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+131
to
+138
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'text' => date('Y-m-d H:i:s', $eventStart + $s), | |
| ]; | |
| } | |
| if ($duration - $whole > 0.001) { | |
| $vttCues[] = [ | |
| 'start' => $concatOffset + $whole, | |
| 'end' => $concatOffset + $duration, | |
| 'text' => date('Y-m-d H:i:s', $eventStart + $whole), | |
| 'text' => gmdate('Y-m-d H:i:s \U\T\C', $eventStart + $s), | |
| ]; | |
| } | |
| if ($duration - $whole > 0.001) { | |
| $vttCues[] = [ | |
| 'start' => $concatOffset + $whole, | |
| 'end' => $concatOffset + $duration, | |
| 'text' => gmdate('Y-m-d H:i:s \U\T\C', $eventStart + $whole), |
Copilot
AI
May 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The cue text timestamps are generated with date(...) (local timezone and no offset), while creation_time is written in UTC (gmdate(...)). This makes the exported timestamps ambiguous and potentially inconsistent across systems/timezone changes. Consider including an explicit timezone in cue text (e.g., UTC with gmdate or local with offset) so consumers can interpret timestamps unambiguously.
| 'text' => date('Y-m-d H:i:s', $eventStart + $s), | |
| ]; | |
| } | |
| if ($duration - $whole > 0.001) { | |
| $vttCues[] = [ | |
| 'start' => $concatOffset + $whole, | |
| 'end' => $concatOffset + $duration, | |
| 'text' => date('Y-m-d H:i:s', $eventStart + $whole), | |
| 'text' => gmdate('Y-m-d H:i:s \U\T\C', $eventStart + $s), | |
| ]; | |
| } | |
| if ($duration - $whole > 0.001) { | |
| $vttCues[] = [ | |
| 'start' => $concatOffset + $whole, | |
| 'end' => $concatOffset + $duration, | |
| 'text' => gmdate('Y-m-d H:i:s \U\T\C', $eventStart + $whole), |
Copilot
AI
May 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When enabled, this builds an in-memory $vttCues array with one entry per second and then concatenates the entire WebVTT into a single string. For long exports, this can cause significant memory growth and slow string concatenation. A more scalable approach would be to stream cues directly to a file handle (write header once, then fwrite per cue) or generate/write cues per event rather than accumulating them all in arrays/strings.
| function buildVttContent($cues) { | |
| $out = "WEBVTT\n\n"; | |
| foreach ($cues as $cue) { | |
| if ($cue['end'] <= $cue['start']) continue; | |
| $out .= formatVttTimestamp($cue['start']).' --> '.formatVttTimestamp($cue['end'])."\n"; | |
| $out .= $cue['text']."\n\n"; | |
| } | |
| return $out; | |
| } | |
| function writeVttFile($path, $cues) { | |
| $content = buildVttContent($cues); | |
| return @file_put_contents($path, $content); | |
| function writeVttStream($stream, $cues) { | |
| $bytesWritten = fwrite($stream, "WEBVTT\n\n"); | |
| if ($bytesWritten === false) { | |
| return false; | |
| } | |
| foreach ($cues as $cue) { | |
| if ($cue['end'] <= $cue['start']) continue; | |
| $cueText = formatVttTimestamp($cue['start']).' --> '.formatVttTimestamp($cue['end'])."\n"; | |
| $cueText .= $cue['text']."\n\n"; | |
| $result = fwrite($stream, $cueText); | |
| if ($result === false) { | |
| return false; | |
| } | |
| $bytesWritten += $result; | |
| } | |
| return $bytesWritten; | |
| } | |
| function buildVttContent($cues) { | |
| $stream = fopen('php://temp/maxmemory:1048576', 'w+b'); | |
| if ($stream === false) { | |
| return false; | |
| } | |
| $result = writeVttStream($stream, $cues); | |
| if ($result === false) { | |
| fclose($stream); | |
| return false; | |
| } | |
| rewind($stream); | |
| $content = stream_get_contents($stream); | |
| fclose($stream); | |
| return $content; | |
| } | |
| function writeVttFile($path, $cues) { | |
| $stream = @fopen($path, 'wb'); | |
| if ($stream === false) { | |
| return false; | |
| } | |
| $result = writeVttStream($stream, $cues); | |
| fclose($stream); | |
| return $result; |
Copilot
AI
May 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using the error suppression operator (@file_put_contents) can make failures harder to diagnose (it hides warnings like permission or disk-full issues). Prefer calling file_put_contents without @ and, on failure, logging the underlying error (e.g., via error_get_last()) so operations/debugging have actionable detail.
| return @file_put_contents($path, $content); | |
| $result = file_put_contents($path, $content); | |
| if ($result === false) { | |
| $lastError = error_get_last(); | |
| if ($lastError and isset($lastError['message'])) { | |
| ZM\Error("Failed to write VTT file '$path': ".$lastError['message']); | |
| } else { | |
| ZM\Error("Failed to write VTT file '$path'"); | |
| } | |
| } | |
| return $result; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When enabled, this builds an in-memory
$vttCuesarray with one entry per second and then concatenates the entire WebVTT into a single string. For long exports, this can cause significant memory growth and slow string concatenation. A more scalable approach would be to stream cues directly to a file handle (write header once, thenfwriteper cue) or generate/write cues per event rather than accumulating them all in arrays/strings.