Compare commits

..

33 Commits

Author SHA1 Message Date
ElementalAlchemist 8df629a1be Fix scrollbar on page 1 week ago
ElementalAlchemist 5ee26a8f64 Fix video player height 1 week ago
ElementalAlchemist 4185c2dc69 Attempt to fix controls sometimes hiding 1 week ago
ElementalAlchemist d09bc91de6 Add restreamer links to download video and frames 1 week ago
ElementalAlchemist f748ad35e0 Fix losing milliseconds sometimes 1 week ago
ElementalAlchemist e6aaddb9eb Fix time ago conversion occurring in the wrong direction 1 week ago
ElementalAlchemist 3e4f52e9ef Store player settings 1 week ago
ElementalAlchemist 454230f866 Switch to more native video controls 1 week ago
ElementalAlchemist 8f88c719ad Remove the default video controls 1 week ago
ElementalAlchemist 1239321b86 Implement basic video controls
These will be expanded or replaced with vidstack's controls in the future, but this at least gives us most of the basic requirements.
1 week ago
ElementalAlchemist 9bfe472468 Bound max video player size 1 week ago
ElementalAlchemist 15d4a4e3cf Make the video load bar work properly 1 week ago
ElementalAlchemist d0640bf5c8 Add vidstack video player 1 week ago
ElementalAlchemist 5ee213455d Basic loading of videos 1 week ago
ElementalAlchemist 26785b1958 Add cancel/reset buttons for thumbnail edit 1 week ago
ElementalAlchemist a50d5d11b2 Add video load time fields to restreamer 1 week ago
ElementalAlchemist c54bd3c63f Add errors and keyboard shortcuts to restreamer 1 week ago
ElementalAlchemist 98ba88fc12 Add components for keyboard shortcuts 1 week ago
ElementalAlchemist 33cc5d0d66 Add Hls.js as a dependency 1 week ago
ElementalAlchemist 1383ae8ccd Move luxon dependency to npm 1 week ago
ElementalAlchemist 1e5718f3a6 Add template form 1 week ago
ElementalAlchemist 375694875a Enable viewing and editing thumbnail templates 1 week ago
ElementalAlchemist 1ad9f6f1a4 Fix pressing "enter" in time converter reloading the page 1 week ago
ElementalAlchemist 9d4bbfd32a Use UTC as the default time zone when converting if no other time zone is specified 1 week ago
ElementalAlchemist f306643a92 Add the time converter utility 1 week ago
ElementalAlchemist 0bb524bf38 Update Dockerfile to build Thrimbletrimmer when building images 1 week ago
ElementalAlchemist d3a7d0bd80 Initial structure and clock
This is the start of replacing the old Thrimbletrimmer with a new SolidJS-based Thrimbletrimmer. It includes an implementation of the clock page for a basic implementation of signal-based page.
1 week ago
Mike Lang aebbb603fc bus_analyzer: use 0.1s before the end timestamp, not the exact end 1 week ago
Mike Lang dc291d4e64 bus_analyzer: check for new segments more often
to lower latency
1 week ago
Mike Lang 5db7bcda71 bus_analyzer: use last frame of segment, not first 1 week ago
Mike Lang 941050aea9 end of run memes 1 week ago
Mike Lang 6062f0a8ec restreamer: Ignore transitions when doing rough cuts, instead of disallowing 2 weeks ago
Christopher Usher 8a0ca215cf Updated colour of the dawn sky and dashboard to observed value 2 weeks ago

@ -314,14 +314,14 @@ def recognize_time_of_day(frame):
'day': (82, 218, 217), 'day': (82, 218, 217),
'dusk': (217, 150, 181), 'dusk': (217, 150, 181),
'night': (0, 0, 0), 'night': (0, 0, 0),
'dawn': (56, 53, 125), # estimated from previous years 'dawn': (36, 38, 117),
} }
dash_colours = { dash_colours = {
'score': (181, 181, 150), 'score': (181, 181, 150),
'day': (146, 0, 1), 'day': (146, 0, 1),
'dusk': (115, 0, 0), 'dusk': (115, 0, 0),
'night': (41, 0, 0), 'night': (41, 0, 0),
'dawn': (118, 0, 0), # estimated from previous years 'dawn': (78, 0, 0),
} }
threshold = 20 # use stronger constraint once we have dusk, night and dawn footage threshold = 20 # use stronger constraint once we have dusk, night and dawn footage
sky_pixel = frame.getpixel((1614, 192)) sky_pixel = frame.getpixel((1614, 192))
@ -344,10 +344,10 @@ def recognize_time_of_day(frame):
return best, distance return best, distance
def extract_segment(prototypes, segment): def extract_segment(prototypes, segment, time):
ODO_SCORE_THRESHOLD = 0.01 ODO_SCORE_THRESHOLD = 0.01
CLOCK_SCORE_THRESHOLD = 0.01 CLOCK_SCORE_THRESHOLD = 0.01
frame_data = b"".join(extract_frame([segment], segment.start)) frame_data = b"".join(extract_frame([segment], time))
frame = Image.open(BytesIO(frame_data)) frame = Image.open(BytesIO(frame_data))
odometer, score, _ = recognize_odometer(prototypes, frame) odometer, score, _ = recognize_odometer(prototypes, frame)
if score < ODO_SCORE_THRESHOLD: if score < ODO_SCORE_THRESHOLD:

@ -26,7 +26,7 @@ def do_extract_segment(*segment_paths, prototypes_path="./prototypes"):
prototypes = load_prototypes(prototypes_path) prototypes = load_prototypes(prototypes_path)
for segment_path in segment_paths: for segment_path in segment_paths:
segment_info = parse_segment_path(segment_path) segment_info = parse_segment_path(segment_path)
odometer, clock, tod = extract_segment(prototypes, segment_info) odometer, clock, tod = extract_segment(prototypes, segment_info, segment_info.start)
print(f"{segment_path} {odometer} {clock} {tod}") print(f"{segment_path} {odometer} {clock} {tod}")
@ -76,7 +76,7 @@ def compare_segments(dbconnect, base_dir='.', prototypes_path="./prototypes", si
for old_odometer, (segment, old_clock, old_tod) in selected: for old_odometer, (segment, old_clock, old_tod) in selected:
path = os.path.join(base_dir, segment) path = os.path.join(base_dir, segment)
segment_info = parse_segment_path(path) segment_info = parse_segment_path(path)
odometer, clock, tod = extract_segment(prototypes, segment_info) odometer, clock, tod = extract_segment(prototypes, segment_info, segment_info.start)
results.append((segment, { results.append((segment, {
"odo": (old_odometer, odometer), "odo": (old_odometer, odometer),
"clock": (old_clock, clock), "clock": (old_clock, clock),
@ -118,8 +118,13 @@ def analyze_segment(db_manager, prototypes, segment_path, check_segment_name=Non
if check_segment_name is not None: if check_segment_name is not None:
assert segment_name == check_segment_name assert segment_name == check_segment_name
# A timestamp fully at the end doesn't get us a valid frame.
# But we want to be as late as possible to minimize latency.
# We attempt to do a fixed time before the end, or use the start if too short.
timestamp = max(segment_info.start, segment_info.end - datetime.timedelta(seconds=0.1))
try: try:
odometer, clock, tod = extract_segment(prototypes, segment_info) odometer, clock, tod = extract_segment(prototypes, segment_info, timestamp)
except Exception: except Exception:
logging.warning(f"Failed to extract segment {segment_path!r}", exc_info=True) logging.warning(f"Failed to extract segment {segment_path!r}", exc_info=True)
odometer = None odometer = None
@ -143,7 +148,7 @@ def analyze_segment(db_manager, prototypes, segment_path, check_segment_name=Non
timeofday = %(timeofday)s timeofday = %(timeofday)s
""", """,
channel=segment_info.channel, channel=segment_info.channel,
timestamp=segment_info.start, timestamp=timestamp,
segment=segment_name, segment=segment_name,
error=error, error=error,
odometer=odometer, odometer=odometer,
@ -200,7 +205,7 @@ def main(
prototypes_path="./prototypes", prototypes_path="./prototypes",
concurrency=10, concurrency=10,
): ):
CHECK_INTERVAL = 2 CHECK_INTERVAL = 0.5
stopping = gevent.event.Event() stopping = gevent.event.Event()

@ -397,8 +397,8 @@ def cut(channel, quality):
type = request.args.get('type', 'fast') type = request.args.get('type', 'fast')
if type == 'rough': if type == 'rough':
if has_transitions: # NOTE: We intentionally ignore transitions for rough cuts, as these are mostly used
return "Cannot do rough cut with transitions", 400 # when downloading source footage for later editing.
return Response(rough_cut_segments(segment_ranges, ranges), mimetype='video/MP2T') return Response(rough_cut_segments(segment_ranges, ranges), mimetype='video/MP2T')
elif type == 'fast': elif type == 'fast':
return Response(fast_cut_segments(segment_ranges, ranges, transitions), mimetype='video/MP2T') return Response(fast_cut_segments(segment_ranges, ranges, transitions), mimetype='video/MP2T')

@ -1,4 +1,6 @@
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { HLSProvider } from "vidstack";
import { Fragment } from "hls.js";
export enum TimeType { export enum TimeType {
UTC, UTC,
@ -99,3 +101,29 @@ export function timeAgoFromDateTime(dateTime: DateTime): string {
return `${negative}${hours}:${minutesString}:${secondsString}`; return `${negative}${hours}:${minutesString}:${secondsString}`;
} }
export function dateTimeFromVideoPlayerTime(
videoProvider: HLSProvider,
videoTime: number,
): DateTime | null {
// We do a little bit of cheating our way into private data. The standard way of getting this involves
// handling events and capturing and saving a fragments array from it, which seems overly cumbersome.
const fragments = (videoProvider.instance as any).latencyController.levelDetails
.fragments as Fragment[];
let fragmentStartTime: number | undefined = undefined;
let fragmentStartISOTime: string | undefined = undefined;
for (const fragment of fragments) {
const fragmentEndTime = fragment.start + fragment.duration;
if (videoTime >= fragment.start && videoTime < fragmentEndTime) {
fragmentStartTime = fragment.start;
fragmentStartISOTime = fragment.rawProgramDateTime;
break;
}
}
if (fragmentStartISOTime === undefined) {
return null;
}
const wubloaderTime = DateTime.fromISO(fragmentStartISOTime);
const offset = videoTime - fragmentStartTime;
return wubloaderTime.plus({ seconds: offset });
}

@ -1,13 +1,14 @@
// Used to make the controls appear below the video player
media-player { media-player {
flex-direction: column; flex-direction: column;
}
media-provider, media-captions, video {
max-width: 100%; max-width: 100%;
max-height: 50vh; max-height: 50vh;
} }
// This level of specificity is required to override player CSS
#root > media-player > media-provider {
align-items: normal;
}
// Used to make the controls appear below the video player // Used to make the controls appear below the video player
media-player:not([data-fullscreen]) media-controls { media-player:not([data-fullscreen]) media-controls {
position: relative; position: relative;

@ -21,6 +21,7 @@ import {
} from "./convertTime"; } from "./convertTime";
import styles from "./video.module.scss"; import styles from "./video.module.scss";
import "./video.scss"; import "./video.scss";
import { HLSProvider } from "vidstack";
import { MediaPlayerElement } from "vidstack/elements"; import { MediaPlayerElement } from "vidstack/elements";
import "vidstack/icons"; import "vidstack/icons";
@ -200,12 +201,14 @@ export const StreamTimeSettings: Component<StreamTimeSettingsProps> = (props) =>
export interface VideoPlayerProps { export interface VideoPlayerProps {
src: Accessor<string>; src: Accessor<string>;
setPlayerTime: Setter<number>;
mediaPlayer: Accessor<MediaPlayerElement>;
setMediaPlayer: Setter<MediaPlayerElement>;
} }
export const VideoPlayer: Component<VideoPlayerProps> = (props) => { export const VideoPlayer: Component<VideoPlayerProps> = (props) => {
let [mediaPlayer, setMediaPlayer] = createSignal<MediaPlayerElement>();
createEffect(() => { createEffect(() => {
const player = mediaPlayer(); const player = props.mediaPlayer();
const srcURL = props.src(); const srcURL = props.src();
player.src = srcURL; player.src = srcURL;
}); });
@ -214,9 +217,10 @@ export const VideoPlayer: Component<VideoPlayerProps> = (props) => {
let [duration, setDuration] = createSignal(0); let [duration, setDuration] = createSignal(0);
onMount(() => { onMount(() => {
const player = mediaPlayer(); const player = props.mediaPlayer();
player.subscribe(({ currentTime, duration }) => { player.subscribe(({ currentTime, duration }) => {
setPlayerTime(currentTime); setPlayerTime(currentTime);
props.setPlayerTime(currentTime);
setDuration(duration); setDuration(duration);
}); });
player.streamType = "on-demand"; player.streamType = "on-demand";
@ -243,14 +247,13 @@ export const VideoPlayer: Component<VideoPlayerProps> = (props) => {
return ( return (
<media-player <media-player
src={props.src()} src={props.src()}
ref={setMediaPlayer} ref={props.setMediaPlayer}
preload="auto" preload="auto"
controlsDelay={0}
storage="thrimbletrimmer" storage="thrimbletrimmer"
> >
<media-provider <media-provider
onClick={(event) => { onClick={(event) => {
const player = mediaPlayer(); const player = props.mediaPlayer();
if (player.paused) { if (player.paused) {
player.play(event); player.play(event);
} else { } else {
@ -259,7 +262,7 @@ export const VideoPlayer: Component<VideoPlayerProps> = (props) => {
}} }}
/> />
<media-captions class="vds-captions" /> <media-captions class="vds-captions" />
<media-controls class="vds-controls"> <media-controls class="vds-controls" hideDelay={0}>
<media-controls-group class="vds-controls-group"> <media-controls-group class="vds-controls-group">
<media-tooltip> <media-tooltip>
<media-tooltip-trigger> <media-tooltip-trigger>

@ -5,7 +5,6 @@ body {
background: #222; background: #222;
color: #fff; color: #fff;
height: 100vh;
margin: 0; margin: 0;
} }

@ -19,3 +19,7 @@
top: 0; top: 0;
right: 0; right: 0;
} }
.videoLinks a {
margin: 2px;
}

@ -10,8 +10,14 @@ import {
Suspense, Suspense,
} from "solid-js"; } from "solid-js";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { HLSProvider } from "vidstack";
import { MediaPlayerElement } from "vidstack/elements";
import styles from "./Restreamer.module.scss"; import styles from "./Restreamer.module.scss";
import { dateTimeFromWubloaderTime, wubloaderTimeFromDateTime } from "../common/convertTime"; import {
dateTimeFromVideoPlayerTime,
dateTimeFromWubloaderTime,
wubloaderTimeFromDateTime,
} from "../common/convertTime";
import { import {
KeyboardShortcuts, KeyboardShortcuts,
StreamTimeSettings, StreamTimeSettings,
@ -92,6 +98,8 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
streamStartTime: DateTime.utc().minus({ minutes: 10 }), streamStartTime: DateTime.utc().minus({ minutes: 10 }),
streamEndTime: null, streamEndTime: null,
}); });
const [playerTime, setPlayerTime] = createSignal<number>(0);
const [mediaPlayer, setMediaPlayer] = createSignal<MediaPlayerElement>();
const videoURL = () => { const videoURL = () => {
const streamInfo = streamVideoInfo(); const streamInfo = streamVideoInfo();
@ -109,6 +117,33 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
return url; return url;
}; };
const downloadVideoURL = () => {
const streamInfo = streamVideoInfo();
const startTime = wubloaderTimeFromDateTime(streamInfo.streamStartTime);
const params = new URLSearchParams({ type: "smart", start: encodeURIComponent(startTime) });
if (streamInfo.streamEndTime) {
const endTime = wubloaderTimeFromDateTime(streamInfo.streamEndTime);
params.append("end", endTime);
}
return `/cut/${streamInfo.streamName}/source.ts?${params}`;
};
const downloadFrameURL = () => {
const streamInfo = streamVideoInfo();
const player = mediaPlayer();
const videoTime = playerTime();
const provider = player.provider as HLSProvider;
if (!provider) {
return "";
}
const currentTime = dateTimeFromVideoPlayerTime(provider, videoTime);
if (currentTime === null) {
return "";
}
const wubloaderTime = wubloaderTimeFromDateTime(currentTime);
return `/frame/${streamInfo.streamName}/source.png?timestamp=${wubloaderTime}`;
};
return ( return (
<> <>
<StreamTimeSettings <StreamTimeSettings
@ -119,7 +154,16 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
errorList={props.errorList} errorList={props.errorList}
setErrorList={props.setErrorList} setErrorList={props.setErrorList}
/> />
<VideoPlayer src={videoURL} /> <VideoPlayer
src={videoURL}
setPlayerTime={setPlayerTime}
mediaPlayer={mediaPlayer}
setMediaPlayer={setMediaPlayer}
/>
<div class={styles.videoLinks}>
<a href={downloadVideoURL()}>Download Video</a>
<a href={downloadFrameURL()}>Download Current Frame as Image</a>
</div>
</> </>
); );
}; };

@ -130,8 +130,8 @@ def post_schedule(client, send_client, start_time, schedule, stream, hour, no_me
shift_hour = hour - omega + 1 shift_hour = hour - omega + 1
if hour == last: if hour == last:
shift = "crab" shift = "rdporb"
shift_hour = 8.88 shift_hour = ""
def render_name(user_id, mention=True): def render_name(user_id, mention=True):
if no_mentions: if no_mentions:

Loading…
Cancel
Save