Skip to content

Commit

Permalink
Merge pull request #222 from lsst-ts/tickets/DM-47302
Browse files Browse the repository at this point in the history
DM-47302: Add basic video framestep controls
  • Loading branch information
ugyballoons authored Nov 7, 2024
2 parents 6f87092 + 52536a0 commit ec57af1
Show file tree
Hide file tree
Showing 6 changed files with 222 additions and 51 deletions.
11 changes: 8 additions & 3 deletions python/lsst/ts/rubintv/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ class HasButton(BaseModel):
text_shadow: bool = False


class MediaType(str, Enum):
IMAGE: str = "image"
VIDEO: str = "video"


class MosaicViewMeta(BaseModel):
"""Populated in the models data YAML file, each MosaicViewMeta object pairs
a channel name with a set of metadata columns to display alongside the
Expand All @@ -83,13 +88,13 @@ class MosaicViewMeta(BaseModel):
The channel name.
metaColumns : list[str]
A list of metadata columns.
dataType : str
Presently, "image" or "video" are only options.
dataType : MediaType
Presently, IMAGE or VIDEO are the only options.
"""

channel: str
metaColumns: list[str]
dataType: str = "image"
mediaType: MediaType = MediaType.IMAGE


class ExtraButton(HasButton):
Expand Down
10 changes: 10 additions & 0 deletions python/lsst/ts/rubintv/models/models_data.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,14 @@ cameras:
title: Current Image
per_day: True

mosaic_view_meta:
- channel: stills
mediaType: image
metaColumns: []
- channel: movies
mediaType: video
metaColumns: []

- name: comcam
title: ComCam
online: True
Expand Down Expand Up @@ -257,8 +265,10 @@ cameras:

mosaic_view_meta:
- channel: day_movie
mediaType: video
metaColumns: []
- channel: last_n_movie
mediaType: video
metaColumns: []

copy_row_template: "dataId = {\"day_obs\": {dayObs}, \"seq_num\": \
Expand Down
235 changes: 188 additions & 47 deletions src/js/components/MosaicView.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,74 @@ import {
metadataType,
mosaicSingleView,
} from "./componentPropTypes"
import { _getById, getStrHashCode } from "../modules/utils"

const FRAMELENGTH = 0.1
const BACK = -FRAMELENGTH
const FORWARD = FRAMELENGTH

class MediaType {
static IMAGE = { value: "image" }
static VIDEO = { value: "video" }

static getMediaType(type) {
switch (type) {
case "image":
return this.IMAGE
case "video":
return this.VIDEO
default:
return null
}
}
}

const commonColumns = ["seqNum"]

export default function MosaicView({ locationName, camera }) {
const [historicalBusy, setHistoricalBusy] = useState(null)
const [currentMeta, setCurrentMeta] = useState({})
const initialViews = camera.mosaic_view_meta.map((view) => ({
...view,
latestEvent: {},
}))
const [views, setViews] = useState(initialViews)

function initialViews() {
let videoCount = 0
const views = camera.mosaic_view_meta.map((view) => {
const isVideo = view.mediaType === MediaType.VIDEO.value
videoCount += isVideo ? 1 : 0
return {
...view,
mediaType: MediaType.getMediaType(view.mediaType),
latestEvent: {},
// only apply 'selected == true' to first video
selected: isVideo && videoCount == 1 ? true : false,
}
})
return views
}

function hasMultipleVideos() {
// Is there more than one video?
const vids = camera.mosaic_view_meta.filter(
({ mediaType }) => mediaType === MediaType.VIDEO
)
return vids.length > 1 ? true : false
}

function selectView(thisView) {
setViews((prevViews) =>
prevViews.map((view) =>
view.channel === thisView.channel
? { ...view, selected: true }
: { ...view, selected: false }
)
)
}

useEffect(() => {
window.addEventListener("camera", handleMetadataChange)
window.addEventListener("historicalStatus", handleHistoricalStateChange)
window.addEventListener("channel", handleChannelEvent)

function handleMetadataChange(event) {
const { data, dataType } = event.detail
if (Object.entries(data).length < 1 || dataType !== "metadata") {
Expand Down Expand Up @@ -58,21 +108,22 @@ export default function MosaicView({ locationName, camera }) {
)
window.removeEventListener("channel", handleChannelEvent)
}
})
}, [])

return (
<div className="viewsArea">
<ul className="views">
{views.map((view) => {
return (
<li key={view.channel} className="view">
<ChannelView
locationName={locationName}
camera={camera}
view={view}
currentMeta={currentMeta}
/>
</li>
<ChannelViewListItem
locationName={locationName}
camera={camera}
view={view}
currentMeta={currentMeta}
selectView={selectView}
isSelectable={hasMultipleVideos()}
key={view.channel}
/>
)
})}
</ul>
Expand All @@ -84,59 +135,83 @@ MosaicView.propTypes = {
camera: cameraType,
}

function ChannelView({ locationName, camera, view, currentMeta }) {
function ChannelViewListItem({
locationName,
camera,
view,
currentMeta,
selectView,
isSelectable,
}) {
const channel = camera.channels.find(({ name }) => name === view.channel)
if (!channel) {
return <h3>Channel {view.channel} not found</h3>
}
const { latestEvent: { day_obs: dayObs }} = view
const {
selected,
mediaType,
latestEvent: { day_obs: dayObs },
} = view
const clsName = [
"view",
`view-${mediaType.value}`,
selected ? "selected" : null,
]
.join(" ")
.trimEnd()
const clickHandler = isSelectable ? () => selectView(view) : null
return (
<>
<h3 className="channel">{channel.title}
{ dayObs && (
<span>: { dayObs }</span>
) }
<li className={clsName} onClick={clickHandler}>
<h3 className="channel">
{channel.title}
{dayObs && <span>: {dayObs}</span>}
</h3>
<ChannelMedia
locationName={locationName}
camera={camera}
event={view.latestEvent}
mediaType={mediaType}
/>
<ChannelMetadata
view={view}
metadata={currentMeta}
/>
</>
<ChannelMetadata view={view} metadata={currentMeta} />
</li>
)
}
ChannelView.propTypes = {
ChannelViewListItem.propTypes = {
locationName: PropTypes.string,
camera: cameraType,
view: mosaicSingleView,
currentMeta: metadataType
}

function ChannelMedia({ locationName, camera, event }) {
const { filename, ext } = event
const mediaURL = buildMediaURI(locationName, camera.name, event.channel_name, filename)
switch (ext) {
case 'mp4':
return <ChannelVideo mediaURL={mediaURL}/>
case 'jpg':
case 'jpeg':
case 'png':
return <ChannelImage mediaURL={mediaURL}/>
currentMeta: metadataType,
selectView: PropTypes.func,
isSelectable: PropTypes.bool,
}

function ChannelMedia({ locationName, camera, event, mediaType }) {
const { filename } = event
if (!filename) return <ChannelMediaPlaceholder />
const mediaURL = buildMediaURI(
locationName,
camera.name,
event.channel_name,
filename
)
switch (mediaType) {
case MediaType.VIDEO:
return <ChannelVideo mediaURL={mediaURL} />
case MediaType.IMAGE:
default:
return <ChannelMediaPlaceholder/>
return <ChannelImage mediaURL={mediaURL} />
}
}
ChannelMedia.propTypes = {
locationName: PropTypes.string,
camera: cameraType,
event: eventType,
mediaType: PropTypes.shape({
value: PropTypes.string,
}),
}

function ChannelImage({mediaURL}) {
function ChannelImage({ mediaURL }) {
const imgSrc = new URL(`event_image/${mediaURL}`, APP_DATA.baseUrl)
return (
<div className="viewImage">
Expand All @@ -150,15 +225,30 @@ ChannelImage.propTypes = {
mediaURL: PropTypes.string,
}

function ChannelVideo({mediaURL}) {
function ChannelVideo({ mediaURL }) {
const [isLoaded, setIsLoaded] = useState(false)
const videoSrc = new URL(`event_video/${mediaURL}`, APP_DATA.baseUrl)
const vidID = `v_${getStrHashCode(mediaURL)}`
return (
<div className="viewVideo">
<a href={videoSrc}>
<video className="resp" controls autoPlay loop>
<source src={videoSrc}/>
<video
className="resp"
id={vidID}
autoPlay
loop
controls
onLoadedData={() => setIsLoaded(true)}
>
<source src={videoSrc} />
</video>
</a>
{isLoaded && (
<div className="video-extra-controls">
<button onClick={() => frameStep(vidID, BACK)}>&lt;</button>
<button onClick={() => frameStep(vidID, FORWARD)}>&gt;</button>
</div>
)}
</div>
)
}
Expand All @@ -175,7 +265,11 @@ function ChannelMediaPlaceholder() {
}

function ChannelMetadata({ view, metadata }) {
const { channel, metaColumns: viewColumns, latestEvent: {seq_num: seqNum} } = view
const {
channel,
metaColumns: viewColumns,
latestEvent: { seq_num: seqNum },
} = view
if (viewColumns.length == 0) {
return
}
Expand All @@ -188,7 +282,9 @@ function ChannelMetadata({ view, metadata }) {
const value = metadatum[column] ?? "No value set"
return (
<tr key={column} className="viewMetaCol">
<th scope="row" className="colName">{column}</th>
<th scope="row" className="colName">
{column}
</th>
<td className="colValue">{value}</td>
</tr>
)
Expand All @@ -204,3 +300,48 @@ ChannelMetadata.propTypes = {

const buildMediaURI = (locationName, cameraName, channelName, filename) =>
`${locationName}/${cameraName}/${channelName}/${filename}`

function frameStep(vidID, timeDelta) {
const video = _getById(vidID)
pauseVideo(video)
const currentTime = video.currentTime
const duration = video.duration
if (timeDelta < 0 && currentTime + timeDelta < 0) {
video.currentTime = 0
} else if (
timeDelta > 0 &&
currentTime + timeDelta >= duration - FRAMELENGTH
) {
video.currentTime = video.duration
} else {
video.currentTime = currentTime + timeDelta
}
}

window.onkeydown = videoControl

function videoControl(e) {
const video = document.querySelector(".view-video.selected video")
if (!video) {
return
}
const key = e.code
let timeDelta = 0
switch (key) {
case "ArrowLeft":
timeDelta = BACK
break
case "ArrowRight":
timeDelta = FORWARD
break
}
if (timeDelta) {
frameStep(video.id, timeDelta)
}
}

function pauseVideo(video) {
if (!video.paused) {
video.pause()
}
}
8 changes: 8 additions & 0 deletions src/js/modules/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,11 @@ export function getWebSockURL (name) {
const appName = window.location.pathname.split('/')[1]
return `${wsProtocol}//${hostname}/${appName}/${name}/`
}

export function getStrHashCode (str) {
var hash = 0, i = 0, len = str.length
while ( i < len ) {
hash = ((hash << 5) - hash + str.charCodeAt(i++)) << 0
}
return hash
}
Loading

0 comments on commit ec57af1

Please sign in to comment.