diff --git a/plugins/caspar/app/App.jsx b/plugins/caspar/app/App.jsx
index 4a19ac60..ccc4216f 100644
--- a/plugins/caspar/app/App.jsx
+++ b/plugins/caspar/app/App.jsx
@@ -3,6 +3,7 @@ import React from 'react'
import * as SharedContext from './sharedContext'
import * as Settings from './views/Settings'
+import { InspectorRange } from './views/InspectorRange'
import { InspectorServer } from './views/InspectorServer'
import { InspectorTemplate } from './views/InspectorTemplate'
import { InspectorTransition } from './views/InspectorTransition'
@@ -31,6 +32,8 @@ export default function App () {
return
case 'inspector/template':
return
+ case 'inspector/range':
+ return
case 'settings/servers':
return
case 'liveSwitch':
diff --git a/plugins/caspar/app/components/LibraryListItem/index.jsx b/plugins/caspar/app/components/LibraryListItem/index.jsx
index 417fedf9..881f7354 100644
--- a/plugins/caspar/app/components/LibraryListItem/index.jsx
+++ b/plugins/caspar/app/components/LibraryListItem/index.jsx
@@ -43,7 +43,9 @@ const ITEM_CONSTRUCTORS = [
target: item.name,
...(DEFAULT_VALUES[item.type] || {})
},
- duration: asset.calculateDurationMs(item)
+ duration: asset.calculateDurationMs(item),
+ medialength: Number.parseInt(item?.duration),
+ framerate: asset.frameRateFractionToDecimal(item?.framerate)
}
}
}
diff --git a/plugins/caspar/app/components/MediaSeek/index.jsx b/plugins/caspar/app/components/MediaSeek/index.jsx
new file mode 100644
index 00000000..5feff57c
--- /dev/null
+++ b/plugins/caspar/app/components/MediaSeek/index.jsx
@@ -0,0 +1,167 @@
+import React, { useRef, useEffect, useState } from 'react'
+import { millisecondsToTime } from '../../utils/asset.cjs'
+import "./style.css"
+
+const MediaHandle = ({ style, handlePointerDown, type, timestamp }) => {
+ return (
+
handlePointerDown(type)}
+ >
+ {timestamp}
+
+ )
+}
+
+const MediaSelectionBar = ({
+ handlePositions,
+ handlePointerDown,
+ maxValue,
+}) => {
+ const [lastActiveHandle, setLastActiveHandle] = useState(null)
+
+ const pointerDown = (type) => {
+ handlePointerDown(type)
+ setLastActiveHandle(type)
+ }
+
+ const isFullRange =
+ handlePositions.in === 0 && handlePositions.out === maxValue
+ const leftHandlePos = (handlePositions.in / maxValue) * 100
+ const rightHandlePos = (handlePositions.out / maxValue) * 100
+ return (
+ <>
+
+
+
handlePointerDown("bar")}
+ style={{
+ opacity: isFullRange ? 0 : 1,
+
+ pointerEvents: isFullRange ? "none" : "auto",
+ }}
+ >
+ =
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export const MediaSeek = ({ inValue, outValue, maxValue, onChange }) => {
+ const trackRef = useRef(null)
+ const [dragging, setDragging] = useState(null)
+ const [handlePositions, setHandlePositions] = useState({
+ in: inValue,
+ out: outValue,
+ })
+
+ useEffect(() => {
+ setHandlePositions({ in: inValue, out: outValue })
+ }, [inValue, outValue])
+
+ const handlePointerMove = (e) => {
+ const rect = trackRef.current.getBoundingClientRect()
+ const percent = (e.clientX - rect.left) / rect.width
+ const clampedPercent = Math.max(0, Math.min(1, percent))
+ const value = clampedPercent * maxValue
+
+ if (dragging === "in") {
+ setHandlePositions((prev) => ({
+ ...prev,
+ in: Math.min(value, prev.out),
+ }))
+ } else if (dragging === "out") {
+ setHandlePositions((prev) => ({
+ ...prev,
+ out: Math.max(value, prev.in),
+ }))
+ } else { // dragging the whole bar
+ const range = handlePositions.out - handlePositions.in
+ let newInPoint = value - range / 2
+ let newOutPoint = value + range / 2
+
+ // Don't allow the bar to be dragged out of bounds
+ if (newInPoint <= 0) {
+ newInPoint = 0
+ newOutPoint = range
+ } else if (newOutPoint >= maxValue) {
+ newOutPoint = maxValue
+ newInPoint = maxValue - range
+ }
+
+ setHandlePositions(() => ({
+ in: newInPoint,
+ out: newOutPoint,
+ }))
+ }
+ }
+
+ const handlePointerDown = (type) => {
+ setDragging(type)
+ }
+
+ const handlePointerUp = () => {
+ // Only trigger onChange if values have changed
+ if (handlePositions.in !== inValue || handlePositions.out !== outValue) {
+ onChange(handlePositions.in, handlePositions.out)
+ }
+ setDragging(null)
+ }
+
+ useEffect(() => {
+ if (dragging) {
+ window.addEventListener("pointermove", handlePointerMove)
+ window.addEventListener("pointerup", handlePointerUp)
+ }
+
+ return () => {
+ window.removeEventListener("pointermove", handlePointerMove)
+ window.removeEventListener("pointerup", handlePointerUp)
+ }
+ }, [dragging, handlePointerMove, handlePointerUp])
+
+ return (
+
+ )
+}
diff --git a/plugins/caspar/app/components/MediaSeek/style.css b/plugins/caspar/app/components/MediaSeek/style.css
new file mode 100644
index 00000000..8f34d988
--- /dev/null
+++ b/plugins/caspar/app/components/MediaSeek/style.css
@@ -0,0 +1,89 @@
+.MediaSeekBar {
+ width: 100%;
+ margin: 0;
+ user-select: none;
+ font-family: sans-serif;
+ border-radius: 0.6rem;
+}
+
+.MediaSeekBar-track {
+ position: relative;
+ height: 34px;
+ background-color: var(--base-color--shade2);
+ border-radius: inherit;
+ cursor: initial;
+ box-shadow: inset 0 0 0 1px var(--base-color--shade2);
+ overflow: visible;
+}
+
+.MediaSelectionBar {
+ position: relative;
+ height: inherit;
+ border-radius: inherit;
+ overflow: hidden;
+ cursor: initial;
+}
+
+.MediaSelectionBar-highlighted {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ background-color: var(--base-color--shade4);
+ opacity: 0.5;
+ border-radius: 0.25rem;
+}
+
+.MediaSelectionBar-highlightedBar {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ margin: 0;
+ font-size: 100%;
+ color: var(--base-color--shade3);
+ user-select: none;
+ cursor: grab;
+ padding: 0.1rem 0.5rem;
+}
+
+.MediaSelectionBar-highlightedBar:active {
+ cursor: grabbing;
+}
+
+.MediaHandle {
+ position: absolute;
+ top: 15%;
+ width: 10px;
+ height: 70%;
+ background-color: var(--base-color);
+ border-radius: 0.25rem;
+ cursor: ew-resize;
+ transform: translateX(-50%);
+}
+
+.MediaHandle--Value {
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ margin-top: 4px;
+ font-size: 12px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ white-space: nowrap;
+ z-index: 1;
+ font-family: var(--base-fontFamily--primary);
+ color: var(--base-color);
+}
+
+.MediaHandle::after {
+ content: "";
+ position: absolute;
+ top: 15%;
+ height: 70%;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 20%;
+ border-radius: inherit;
+ background-color: invert(var(--base-color));
+}
diff --git a/plugins/caspar/app/utils/asset.cjs b/plugins/caspar/app/utils/asset.cjs
index c6e180b8..e3ac3e06 100644
--- a/plugins/caspar/app/utils/asset.cjs
+++ b/plugins/caspar/app/utils/asset.cjs
@@ -62,16 +62,15 @@ function calculateDurationMs (item) {
if (!item) {
return DEFAULT_DURATION_MS
}
- // If the item is a still image, return 0
+
if (item.type === 'STILL') {
return 0
}
- // If the item has no duration, return the default duration
+
if (isNaN(Number(item?.duration))) {
return DEFAULT_DURATION_MS
}
- // If the item has no framerate, return the default duration
if (!item?.framerate) {
return DEFAULT_DURATION_MS
}
@@ -114,3 +113,69 @@ function frameRateFractionToDecimal (fraction) {
return dividend / divisor
}
+exports.frameRateFractionToDecimal = frameRateFractionToDecimal
+
+/**
+ * Build a string in hours:minutes:seconds format from milliseconds
+ * @example
+ * 1000 -> '00:01'
+ * 61000 -> '01:01'
+ * 3600000 -> '01:00:00'
+ * @param {number} ms
+ * @returns {string}
+ */
+function millisecondsToTime(ms) {
+ if (ms < 0) return "00:00"
+ if (!ms) return "00:00"
+ if (isNaN(ms)) return "00:00"
+
+ const totalSeconds = Math.floor(ms / 1000)
+ const hours = Math.floor(totalSeconds / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+ const seconds = totalSeconds % 60
+ const pad = (n) => n.toString().padStart(2, "0")
+
+ if (hours > 0) {
+ return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
+ } else {
+ return `${pad(minutes)}:${pad(seconds)}`
+ }
+}
+exports.millisecondsToTime = millisecondsToTime
+
+/**
+ * Frames to milliseconds
+ * @param {number} frames
+ * @param {number} framerate
+ * @returns {number}
+ */
+function framesToMilliseconds(frames, framerate) {
+ if (frames < 0) return 0
+ if (!frames) return 0
+ if (isNaN(frames)) return 0
+ if (framerate <= 0) return 0
+ if (!framerate) return 0
+ if (isNaN(framerate)) return 0
+
+ // Round to 1 decimal place
+ return Math.round((frames / framerate) * 1000 * 10) / 10
+}
+exports.framesToMilliseconds = framesToMilliseconds
+
+/**
+ * Milliseconds to frames
+ * @param {number} ms
+ * @param {number} framerate
+ * @returns {number}
+ */
+function millisecondsToFrames(ms, framerate) {
+ if (ms < 0) return 0
+ if (!ms) return 0
+ if (isNaN(ms)) return 0
+ if (framerate <= 0) return 0
+ if (!framerate) return 0
+ if (isNaN(framerate)) return 0
+
+ return Math.round((ms / 1000) * framerate)
+}
+exports.millisecondsToFrames = millisecondsToFrames
diff --git a/plugins/caspar/app/utils/asset.unit.test.js b/plugins/caspar/app/utils/asset.unit.test.js
index 80536f3e..d24cc04f 100644
--- a/plugins/caspar/app/utils/asset.unit.test.js
+++ b/plugins/caspar/app/utils/asset.unit.test.js
@@ -1,4 +1,4 @@
-const { calculateDurationMs } = require('./asset.cjs')
+const { calculateDurationMs, millisecondsToTime, framesToMilliseconds, millisecondsToFrames} = require('./asset.cjs')
test('calculateDurationMs should return 0 for 0 duration (string or number)', () => {
// Test for both string and number '0'
@@ -53,3 +53,80 @@ test('calculateDurationMs should return 0 for STILL image', () => {
}
expect(calculateDurationMs(item)).toEqual(0)
})
+
+test('calculateDurationMs should return default time for non-numeric duration', () => {
+ const item = {
+ framerate: '1/25',
+ duration: 'not-a-number'
+ }
+ expect(calculateDurationMs(item)).toEqual(5000)
+})
+
+test('returns "00:00" for 0 or negative input', () => {
+ expect(millisecondsToTime(0)).toEqual('00:00')
+ expect(millisecondsToTime(-100)).toEqual('00:00')
+ expect(millisecondsToTime(null)).toEqual('00:00')
+ expect(millisecondsToTime(undefined)).toEqual('00:00')
+})
+
+test('converts seconds correctly', () => {
+ expect(millisecondsToTime(1000)).toEqual('00:01')
+ expect(millisecondsToTime(61000)).toEqual('01:01')
+})
+
+test('converts hours correctly', () => {
+ expect(millisecondsToTime(3600000)).toEqual('01:00:00')
+ expect(millisecondsToTime(3661000)).toEqual('01:01:01')
+})
+
+test('handles arbitrary times', () => {
+ expect(millisecondsToTime(7325000)).toEqual('02:02:05')
+})
+
+test('handles non-numeric input gracefully', () => {
+ expect(millisecondsToTime('not-a-number')).toEqual('00:00')
+ expect(millisecondsToTime({})).toEqual('00:00')
+ expect(millisecondsToTime([])).toEqual('00:00')
+})
+
+test('handles large durations', () => {
+ expect(millisecondsToTime(100 * 3600000)).toEqual('100:00:00')
+})
+
+test('framesToMilliseconds converts frames to milliseconds correctly', () => {
+ expect(framesToMilliseconds(25, 25)).toEqual(1000)
+ expect(framesToMilliseconds(50, 25)).toEqual(2000)
+ expect(framesToMilliseconds(30, 29.97)).toEqual(1001.0)
+})
+
+test('framesToMilliseconds returns 0 for non-numeric frames', () => {
+ expect(framesToMilliseconds('not-a-number', 25)).toEqual(0)
+})
+
+test('framesToMilliseconds returns 0 for invalid framerate', () => {
+ expect(framesToMilliseconds(25, 'invalid')).toEqual(0)
+})
+
+test('framesToMilliseconds returns 0 for zero or negative framerate', () => {
+ expect(framesToMilliseconds(25, 0)).toEqual(0)
+ expect(framesToMilliseconds(25, -25)).toEqual(0)
+})
+
+test('millisecondsToFrames converts milliseconds to frames correctly', () => {
+ expect(millisecondsToFrames(1000, 25)).toEqual(25)
+ expect(millisecondsToFrames(2000, 25)).toEqual(50)
+ expect(millisecondsToFrames(1001, 29.97)).toBeCloseTo(30)
+})
+
+test('millisecondsToFrames returns 0 for non-numeric milliseconds', () => {
+ expect(millisecondsToFrames('not-a-number', 25)).toEqual(0)
+})
+
+test('millisecondsToFrames returns 0 for invalid framerate', () => {
+ expect(millisecondsToFrames(1000, 'invalid')).toEqual(0)
+})
+
+test('millisecondsToFrames returns 0 for zero or negative framerate', () => {
+ expect(millisecondsToFrames(1000, 0)).toEqual(0)
+ expect(millisecondsToFrames(1000, -25)).toEqual(0)
+})
diff --git a/plugins/caspar/app/views/InspectorRange.jsx b/plugins/caspar/app/views/InspectorRange.jsx
new file mode 100644
index 00000000..94fdffea
--- /dev/null
+++ b/plugins/caspar/app/views/InspectorRange.jsx
@@ -0,0 +1,79 @@
+import React from 'react'
+import bridge from 'bridge'
+import { SharedContext } from '../sharedContext'
+import { MediaSeek } from '../components/MediaSeek'
+import { framesToMilliseconds, millisecondsToFrames} from '../utils/asset.cjs'
+
+export const InspectorRange = () => {
+ const [state] = React.useContext(SharedContext)
+ const [selection, setSelection] = React.useState([])
+
+ // Fetch selection when state changes
+ React.useEffect(() => {
+ async function updateSelection() {
+ const sel = await bridge.client.selection.getSelection()
+ setSelection(sel)
+ }
+ updateSelection()
+ }, [state])
+
+
+ const items = selection.map(id => state?.items?.[id])
+ const item = items[0]
+ if (!item) return null
+
+ // Only allow seek for one item at a time
+ if (items.length > 1) {
+ return (
+
+
Multiple items selected
+
+ )
+ }
+
+ // Get parameters from the first selected item
+ const framerate = item?.data?.framerate || 25
+ const medialength = item?.data?.medialength || millisecondsToFrames(item?.data?.duration, framerate)
+ // Don't show seek bar if length is 0
+ if (medialength === 0) return null
+
+ const seek = Number.parseInt(item?.data?.caspar?.seek) || 0
+ const length = item?.data?.caspar?.length || (medialength - seek)
+
+ // Convert frames to milliseconds
+ const inValue = framesToMilliseconds(seek, framerate)
+ const outValue = framesToMilliseconds(seek + length, framerate)
+ const maxValue = framesToMilliseconds(medialength, framerate)
+
+ function handleChange (newIn, newOut) {
+ const id = selection[0]
+ const item = state?.items?.[id]
+ const framerate = item?.data?.framerate || 25
+
+ // newIn and newOut are in milliseconds
+ const seekFrames = millisecondsToFrames(newIn, framerate) // frames
+ const lengthFrames = millisecondsToFrames(newOut - newIn, framerate) // frames
+ const duration = Math.round(newOut - newIn) // milliseconds
+
+ bridge.items.applyItem(id, {
+ data: {
+ caspar: {
+ seek: seekFrames,
+ length: lengthFrames
+ },
+ duration: duration
+ }
+ })
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/plugins/caspar/lib/handlers.js b/plugins/caspar/lib/handlers.js
index 3b2955b0..f9c569dd 100644
--- a/plugins/caspar/lib/handlers.js
+++ b/plugins/caspar/lib/handlers.js
@@ -22,13 +22,13 @@ const PLAY_HANDLERS = {
return commands.sendString(serverId, item?.data?.caspar?.amcp)
},
'bridge.caspar.media': async (serverId, item) => {
- return commands.sendCommand(serverId, 'play', item?.data?.caspar?.target, item?.data?.caspar?.loop, 0, undefined, undefined, undefined, item?.data?.caspar)
+ return commands.sendCommand(serverId, 'play', item?.data?.caspar?.target, item?.data?.caspar?.loop, item?.data?.caspar?.seek, item?.data?.caspar?.length, undefined, undefined, item?.data?.caspar)
},
'bridge.caspar.image-scroller': async (serverId, item) => {
return commands.sendCommand(serverId, 'playImageScroller', item?.data?.caspar?.target, item?.data?.caspar)
},
'bridge.caspar.load': async (serverId, item) => {
- return commands.sendCommand(serverId, 'loadbg', item?.data?.caspar?.target, item?.data?.caspar?.loop, 0, undefined, undefined, item?.data?.caspar?.auto, item?.data?.caspar)
+ return commands.sendCommand(serverId, 'loadbg', item?.data?.caspar?.target, item?.data?.caspar?.loop, item?.data?.caspar?.seek, item?.data?.caspar?.length, undefined, item?.data?.caspar?.auto, item?.data?.caspar)
},
'bridge.caspar.template': (serverId, item) => {
return commands.sendCommand(serverId, 'cgAdd', item?.data?.caspar?.target, getCleanTemplateDataString(item), true, item?.data?.caspar)
diff --git a/plugins/caspar/lib/types.js b/plugins/caspar/lib/types.js
index d9dc0871..9c4b348a 100644
--- a/plugins/caspar/lib/types.js
+++ b/plugins/caspar/lib/types.js
@@ -189,6 +189,21 @@ function init (htmlPath) {
type: 'string',
'ui.group': 'Transition',
'ui.uri': `${htmlPath}?path=inspector/transition`
+ },
+ 'caspar.seek': {
+ name: 'Seek',
+ type: 'string',
+ default: '0',
+ allowsVariables: true,
+ 'ui.group': 'Timing',
+ 'ui.unit': 'frames'
+ },
+ 'caspar.range': {
+ name: '',
+ type: 'string',
+ allowsVariables: false,
+ 'ui.group': 'Timing',
+ 'ui.uri': `${htmlPath}?path=inspector/range`
}
}
})