Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
### Added
- An AI agent
- A `bridge.time.now` api for getting the current server time
- The new API function `bridge.items.seekItem` used to seek an item to a specific point in time
- The ability to bypass delay and play an item immediately through the api
- A new on end action to force stop
- In and out points on trimmable items
- A timeline widget
- In and out points for trimmable items
- A quick way to change caspar targets
- An API for opening modals
- Loop detection and prevention
### Changed
- Make margins in the inspector smaller
### Fixed
Expand All @@ -22,7 +22,11 @@
- An issue where variable autocomplete wouldn't sync with the input field horisontal scroll
- A bug where there were no guards for placing a group, or timeline, within itself
- A memory leak occuring within caspar widgets hooking onto the state
- UI issues with the caspar library widget
- UI issues with the caspar library widget
- An issue preventing nested groups from being 'stepped into'
- An issue where the menus of window buttons became misaligned on Windows
- Updated non-breaking dependencies
- Updated Electron to 41.1.1

## 1.0.0-beta.10
### Added
Expand Down
20 changes: 0 additions & 20 deletions api/items.js
Original file line number Diff line number Diff line change
Expand Up @@ -353,26 +353,6 @@ class Items {
}
}

/**
* Seek an already-playing item to a position
* without re-emitting item.play
*
* Updates willStartPlayingAt / didStartPlayingAt and
* reschedules items.endItem at the correct remaining time
*
* @param { String } id
* @param { Number } positionMs How far into the item's duration to seek
*/
async seekItem (id, positionMs) {
const item = await this.getItem(id)

if (!item) {
return
}

this.#props.Commands.executeCommand('items.seekItem', item, positionMs)
}

/**
* Play the item and emit
* the 'stop' event
Expand Down
8 changes: 1 addition & 7 deletions app/components/AppMenu/AppMenuRootItem.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,9 @@ export function AppMenuRootItem ({ label, spec }) {
const elRef = React.useRef()

async function handleClick (e) {
const bounds = e.target.getBoundingClientRect()

const x = e.screenX - (e.clientX - bounds.x)
const y = e.screenY - (e.clientY - bounds.y) + bounds.height + MENU_MARGIN_PX

const bridge = await api.load()
bridge.ui.contextMenu.open(spec, {
x,
y
...bridge.ui.contextMenu.getPositionFromEvent(e)
})
}

Expand Down
9 changes: 8 additions & 1 deletion app/components/Preferences/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const INTERNAL_SETTINGS = [
}
]

const DIVIDER_SETTING_KEY = 'divider'

export function Preferences ({ onClose = () => {} }) {
const [, applyShared] = React.useContext(SharedContext)
const [, applyLocal] = React.useContext(LocalContext)
Expand Down Expand Up @@ -154,12 +156,17 @@ export function Preferences ({ onClose = () => {} }) {
{
(sections[curPath[0]]?.items[curPath[1]]?.items || [])
.filter(setting => setting)
.map(setting => {
.map((setting, i) => {
if (setting?.type === DIVIDER_SETTING_KEY) {
return <div key={`preferences-divider-${i}`} className='Preferences-divider' />
}

/*
Compose a key that's somewhat unique but still static
in order to prevent unnecessary re-rendering
*/
const key = `${setting?.title}${setting?.description}${JSON.stringify(setting?.inputs)}`

return (
<Preference
key={key}
Expand Down
21 changes: 17 additions & 4 deletions app/components/Preferences/sections/general.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@
{ "type": "version" }
]
},
{
"title": "Hide messages",
"description": "Hide all status messages that are normally shown in the bottom right of the app",
"inputs": [
{ "type": "boolean", "bind": "shared._userDefaults.hideMessages", "label": "Hide messages" }
]
},
{
"type": "divider"
},
{
"title": "HTTP port",
"description": "This setting requires a full restart",
Expand All @@ -19,11 +29,14 @@
{ "type": "boolean", "bind": "shared._userDefaults.httpBindToAll", "label": "Bind to 0.0.0.0" }
]
},
{
"title": "Hide messages",
"description": "Hide all status messages that are normally shown in the bottom right of the app",
{
"type": "divider"
},
{
"title": "Loop prevention",
"description": "Disabling loop prevention will allow item loops with sub frame delays but risks creating loops that freeze the application",
"inputs": [
{ "type": "boolean", "bind": "shared._userDefaults.hideMessages", "label": "Hide messages" }
{ "type": "boolean", "bind": "shared.core.internals.enableLoopPrevention", "label": "Prevent loops" }
]
}
]
8 changes: 8 additions & 0 deletions app/components/Preferences/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,12 @@
justify-content: space-between;

box-sizing: border-box;
}

.Preferences-divider {
width: 100%;
height: 1px;
margin: 10px 0 20px;

background: var(--base-color--shade1);
}
2 changes: 0 additions & 2 deletions app/views/WorkspaceWidget.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,6 @@ export const WorkspaceWidget = () => {
if (!path) {
return
}

console.log('Update', buildChildrenUpdate(path, data))
applyShared({ children: buildChildrenUpdate(path, data) })
}

Expand Down
3 changes: 0 additions & 3 deletions docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,9 +354,6 @@ Play an item and set its state to `playing`.
| ------ | ---- | ----------- |
| `immediate` | `boolean` | Skip any `delay` set on the item and play it immediately |

### `bridge.items.seekItem(id, positionMs): Promise<Void>`
Seek an already-playing item to `positionMs` milliseconds into its duration without re-emitting `item.play` and reschedules the `item.end` event at the correct remaining time.

### `bridge.items.getEffectiveDuration(item): number`
Get the effective playback duration of an item in milliseconds, taking `data.inPoint` and `data.outPoint` into account.

Expand Down
149 changes: 149 additions & 0 deletions lib/api/PlayHistory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// SPDX-FileCopyrightText: 2026 Axel Boberg
//
// SPDX-License-Identifier: MIT

/**
* The default size of the circular buffer, i.e. the maximum
* number of recent plays to keep in memory
*
* Only patterns up to half this size can be detected as
* it needs to appear at lease twice within the buffer
*
* @type { number }
*/
const DEFAULT_BUFFER_SIZE_ENTRIES_N = 500

/**
* The maximum number of milliseconds allowed between any two
* consecutive plays within a detected cycle for it to be
* considered a runaway loop. If any single step in the cycle
* took longer than this (e.g. an item waiting out its duration),
* the sequence is considered intentional rather than a loop
*
* @type { number }
*/
const MAX_MS_PER_STEP = 10

/**
* @class PlayHistory
* @description Keeps track of played items and
* flags an item as a loop if it occurs
* twice within the circular buffer and
* breaks the timing threshold
*
* @typedef {{ id: string, t: number }} Entry
*/
class PlayHistory {
#buffer
#windowSize
#head = 0
#size = 0

/**
* @param { number } windowSize The number of recent play events to
* retain for cycle detection
*/
constructor (windowSize = DEFAULT_BUFFER_SIZE_ENTRIES_N) {
this.#windowSize = windowSize
this.#buffer = new Array(windowSize)
}

/**
* Get a buffered entry by logical index,
* where 0 is the oldest and size-1 is the newest
*
* @param { number } index
* @returns { Entry }
*/
#get (index) {
return this.#buffer[(this.#head + index) % this.#windowSize]
}

/**
* Record a play for an item and check
* whether it is in a loop
*
* Appends the id and current timestamp to a sliding window
* and checks whether the tail of that window forms a repeating
* sequence whose total duration is within the loop time budget
*
* Returns true if a loop is detected, false otherwise
*
* @param { string } id The item id
* @returns { boolean } True if a loop is detected
*/
record (id) {
const now = Date.now()
const writePos = (this.#head + this.#size) % this.#windowSize
this.#buffer[writePos] = { id, t: now }

if (this.#size < this.#windowSize) {
this.#size++
} else {
this.#head = (this.#head + 1) % this.#windowSize
}

/*
Check whether the most recent L entries exactly repeat
the L entries before them, for all possible cycle lengths,
and that the most recent full cycle completed within the
time budget of L × MAX_MS_PER_ITEM milliseconds
*/
const maxCycleLength = Math.floor(this.#size / 2)
for (let L = 1; L <= maxCycleLength; L++) {
let match = true
for (let i = 0; i < L; i++) {
if (this.#get(this.#size - 1 - i).id !== this.#get(this.#size - 1 - L - i).id) {
match = false
break
}
}
if (!match) {
continue
}

/*
Check that every step within the last cycle
fired within MAX_MS_PER_STEP of the previous play

A single large gap means an item waited out its duration — intentional, not a loop
*/
let isRunaway = true
for (let i = this.#size - L; i < this.#size; i++) {
const gap = this.#get(i).t - this.#get(i - 1).t
if (gap > MAX_MS_PER_STEP) {
isRunaway = false
break
}
}
if (isRunaway) {
return true
}
}

return false
}

/**
* Remove all occurrences of an item id from the buffer,
* preserving the relative order of remaining entries
*
* @param { string } id The item id
*/
delete (id) {
const entries = []
for (let i = 0; i < this.#size; i++) {
const entry = this.#get(i)
if (entry.id !== id) {
entries.push(entry)
}
}
this.#head = 0
this.#size = entries.length
for (let i = 0; i < entries.length; i++) {
this.#buffer[i] = entries[i]
}
}
}

module.exports = PlayHistory
Loading
Loading