Compare commits

..

23 Commits

Author SHA1 Message Date
ElementalAlchemist 4298937535 Fix losing milliseconds sometimes 2 weeks ago
ElementalAlchemist 5143d15f68 Fix time ago conversion occurring in the wrong direction 2 weeks ago
ElementalAlchemist 0fd6a09b1c Store player settings 2 weeks ago
ElementalAlchemist 2095880d10 Switch to more native video controls 2 weeks ago
ElementalAlchemist bb85eb494d Remove the default video controls 2 weeks ago
ElementalAlchemist aa649ac4ac 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.
2 weeks ago
ElementalAlchemist aeebf0b7ad Bound max video player size 2 weeks ago
ElementalAlchemist 9a8be2f875 Make the video load bar work properly 2 weeks ago
ElementalAlchemist 0aed412ccb Add vidstack video player 2 weeks ago
ElementalAlchemist 89ff17564e Basic loading of videos 2 weeks ago
ElementalAlchemist 6608555a8b Add cancel/reset buttons for thumbnail edit 2 weeks ago
ElementalAlchemist 87e8333d05 Add video load time fields to restreamer 2 weeks ago
ElementalAlchemist 4517ec1e68 Add errors and keyboard shortcuts to restreamer 2 weeks ago
ElementalAlchemist 5ab145b031 Add components for keyboard shortcuts 2 weeks ago
ElementalAlchemist 2bb8ff6245 Add Hls.js as a dependency 2 weeks ago
ElementalAlchemist 7de2f807b1 Move luxon dependency to npm 2 weeks ago
ElementalAlchemist c3448542c4 Add template form 2 weeks ago
ElementalAlchemist 6b381428ed Enable viewing and editing thumbnail templates 2 weeks ago
ElementalAlchemist ae808eedde Fix pressing "enter" in time converter reloading the page 2 weeks ago
ElementalAlchemist 369b70bb19 Use UTC as the default time zone when converting if no other time zone is specified 2 weeks ago
ElementalAlchemist 1c842d9d16 Add the time converter utility 2 weeks ago
ElementalAlchemist ea438c73db Update Dockerfile to build Thrimbletrimmer when building images 2 weeks ago
ElementalAlchemist 05a53924f6 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.
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': (36, 38, 117), 'dawn': (56, 53, 125), # estimated from previous years
} }
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': (78, 0, 0), 'dawn': (118, 0, 0), # estimated from previous years
} }
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, time): def extract_segment(prototypes, segment):
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], time)) frame_data = b"".join(extract_frame([segment], segment.start))
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, segment_info.start) odometer, clock, tod = extract_segment(prototypes, segment_info)
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, segment_info.start) odometer, clock, tod = extract_segment(prototypes, segment_info)
results.append((segment, { results.append((segment, {
"odo": (old_odometer, odometer), "odo": (old_odometer, odometer),
"clock": (old_clock, clock), "clock": (old_clock, clock),
@ -118,13 +118,8 @@ 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, timestamp) odometer, clock, tod = extract_segment(prototypes, segment_info)
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
@ -148,7 +143,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=timestamp, timestamp=segment_info.start,
segment=segment_name, segment=segment_name,
error=error, error=error,
odometer=odometer, odometer=odometer,
@ -205,7 +200,7 @@ def main(
prototypes_path="./prototypes", prototypes_path="./prototypes",
concurrency=10, concurrency=10,
): ):
CHECK_INTERVAL = 0.5 CHECK_INTERVAL = 2
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':
# NOTE: We intentionally ignore transitions for rough cuts, as these are mostly used if has_transitions:
# when downloading source footage for later editing. return "Cannot do rough cut with transitions", 400
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,6 +1,4 @@
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,
@ -101,29 +99,3 @@ 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,12 +1,11 @@
// Used to make the controls appear below the video player
media-player { media-player {
flex-direction: column; flex-direction: column;
max-width: 100%;
max-height: 50vh;
} }
// This level of specificity is required to override player CSS media-provider, media-captions, video {
#root > media-player > media-provider { max-width: 100%;
align-items: normal; max-height: 50vh;
} }
// Used to make the controls appear below the video player // Used to make the controls appear below the video player

@ -21,7 +21,6 @@ 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";
@ -201,14 +200,12 @@ 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 = props.mediaPlayer(); const player = mediaPlayer();
const srcURL = props.src(); const srcURL = props.src();
player.src = srcURL; player.src = srcURL;
}); });
@ -217,10 +214,9 @@ export const VideoPlayer: Component<VideoPlayerProps> = (props) => {
let [duration, setDuration] = createSignal(0); let [duration, setDuration] = createSignal(0);
onMount(() => { onMount(() => {
const player = props.mediaPlayer(); const player = 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";
@ -247,13 +243,14 @@ export const VideoPlayer: Component<VideoPlayerProps> = (props) => {
return ( return (
<media-player <media-player
src={props.src()} src={props.src()}
ref={props.setMediaPlayer} ref={setMediaPlayer}
preload="auto" preload="auto"
controlsDelay={0}
storage="thrimbletrimmer" storage="thrimbletrimmer"
> >
<media-provider <media-provider
onClick={(event) => { onClick={(event) => {
const player = props.mediaPlayer(); const player = mediaPlayer();
if (player.paused) { if (player.paused) {
player.play(event); player.play(event);
} else { } else {
@ -262,7 +259,7 @@ export const VideoPlayer: Component<VideoPlayerProps> = (props) => {
}} }}
/> />
<media-captions class="vds-captions" /> <media-captions class="vds-captions" />
<media-controls class="vds-controls" hideDelay={0}> <media-controls class="vds-controls">
<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,6 +5,7 @@ body {
background: #222; background: #222;
color: #fff; color: #fff;
height: 100vh;
margin: 0; margin: 0;
} }

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

@ -10,14 +10,8 @@ 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 { import { dateTimeFromWubloaderTime, wubloaderTimeFromDateTime } from "../common/convertTime";
dateTimeFromVideoPlayerTime,
dateTimeFromWubloaderTime,
wubloaderTimeFromDateTime,
} from "../common/convertTime";
import { import {
KeyboardShortcuts, KeyboardShortcuts,
StreamTimeSettings, StreamTimeSettings,
@ -98,8 +92,6 @@ 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();
@ -117,33 +109,6 @@ 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
@ -154,16 +119,7 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
errorList={props.errorList} errorList={props.errorList}
setErrorList={props.setErrorList} setErrorList={props.setErrorList}
/> />
<VideoPlayer <VideoPlayer src={videoURL} />
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 = "rdporb" shift = "crab"
shift_hour = "" shift_hour = 8.88
def render_name(user_id, mention=True): def render_name(user_id, mention=True):
if no_mentions: if no_mentions:

Loading…
Cancel
Save