Skip to content
Open
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
98 changes: 84 additions & 14 deletions .github/workflows/node.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ jobs:
run: |
corepack enable

# set isolated electron-builder cache early so downloads use it
echo "XDG_CACHE_HOME=${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}"
export XDG_CACHE_HOME=${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}
rm -rf "$XDG_CACHE_HOME/electron-builder" || true
mkdir -p "$XDG_CACHE_HOME"

# try and avoid timeout errors
yarn config set httpTimeout 100000

Expand All @@ -51,6 +57,8 @@ jobs:

macos-build:
name: Build on macOS
permissions:
contents: write
runs-on: macos-latest
continue-on-error: true
steps:
Expand All @@ -69,28 +77,72 @@ jobs:
run: |
corepack enable

# set isolated electron-builder cache early so downloads use it
echo "XDG_CACHE_HOME=${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}" >> $GITHUB_ENV
export XDG_CACHE_HOME=${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}
rm -rf "$XDG_CACHE_HOME/electron-builder" || true
mkdir -p "$XDG_CACHE_HOME"

# try and avoid timeout errors
yarn config set httpTimeout 100000

yarn --immutable
- name: Build
run: |
yarn build
- name: macOS pre-build diagnostics
run: |
echo "XDG_CACHE_HOME=${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}"
export XDG_CACHE_HOME=${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}
# ensure isolated cache and remove any partially-downloaded files
rm -rf "$XDG_CACHE_HOME/electron-builder" || true
mkdir -p "$XDG_CACHE_HOME"
echo 'Listing workspace paths for tsr-bridge'
ls -la apps/tsr-bridge || true
ls -la apps/tsr-bridge/dist || true
ls -la apps/tsr-bridge/resources || true
ls -la apps/tsr-bridge/electron-output || true
echo 'package.json for tsr-bridge:'
sed -n '1,200p' apps/tsr-bridge/package.json || true
- name: Set signing secret
run: echo "HAS_MAC_CSC_LINK=${{ secrets.MAC_CSC_LINK != '' }}" >> $GITHUB_ENV
- name: Build binaries
if: env.HAS_MAC_CSC_LINK == 'true'
run: |
yarn build:binary -- --publish=never
# Run binary builds per workspace to ensure electron-builder runs in package cwd
retry() { cmd="$1"; n=0; until [ $n -ge 3 ]; do eval "$cmd" && return 0; n=$((n+1)); echo "Retry $n for: $cmd"; sleep 5; done; return 1; }
retry "yarn workspace tsr-bridge build:binary -- --publish=never"
retry "yarn workspace superconductor build:binary -- --publish=never"
env:
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLEID: ${{ secrets.APPLEID }}
APPLEIDTEAM: ${{ secrets.APPLEIDTEAM }}
APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
XDG_CACHE_HOME: ${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}
- name: Collect builder configs
run: |
mkdir -p macos-diagnostics
# collect any builder effective config files and directory listings
if [ -f apps/tsr-bridge/builder-effective-config.yaml ]; then cp apps/tsr-bridge/builder-effective-config.yaml macos-diagnostics/ || true; fi
if [ -f apps/app/builder-effective-config.yaml ]; then cp apps/app/builder-effective-config.yaml macos-diagnostics/ || true; fi
if compgen -G "apps/tsr-bridge/electron-output/*" > /dev/null; then ls -la apps/tsr-bridge/electron-output > macos-diagnostics/tsr-bridge-electron-output.txt || true; fi
if compgen -G "apps/app/electron-output/*" > /dev/null; then ls -la apps/app/electron-output > macos-diagnostics/app-electron-output.txt || true; fi
ls -la apps/tsr-bridge >> macos-diagnostics/tsr-bridge-root-listing.txt || true
ls -la apps/app >> macos-diagnostics/app-root-listing.txt || true
- name: Upload macOS diagnostics
uses: actions/upload-artifact@v4
with:
name: macos-diagnostics
path: macos-diagnostics
retention-days: 1
- name: Collect binaries
run: |
mkdir macos-dist
mv apps/tsr-bridge/electron-output/TSR-Bridge* macos-dist/
mv apps/app/electron-output/SuperConductor* macos-dist/
mv apps/app/electron-output/latest-mac.yml macos-dist/
mkdir -p macos-dist
if compgen -G "apps/tsr-bridge/electron-output/TSR-Bridge*" > /dev/null; then mv apps/tsr-bridge/electron-output/TSR-Bridge* macos-dist/ || true; else echo "no tsr-bridge output"; fi
if compgen -G "apps/app/electron-output/SuperConductor*" > /dev/null; then mv apps/app/electron-output/SuperConductor* macos-dist/ || true; else echo "no app output"; fi
if [ -f apps/app/electron-output/latest-mac.yml ]; then mv apps/app/electron-output/latest-mac.yml macos-dist/ || true; else echo "no latest-mac.yml"; fi

- name: Upload artifact
uses: actions/upload-artifact@v4
Expand All @@ -101,6 +153,8 @@ jobs:

windows-build:
name: Build on Windows
permissions:
contents: write
runs-on: windows-latest
continue-on-error: true
steps:
Expand All @@ -127,14 +181,22 @@ jobs:
run: |
yarn build
- name: Build binaries
shell: bash
run: |
yarn build:binary -- -- --publish=never
# Run binary builds per workspace to ensure electron-builder runs in package cwd
retry() { cmd="$1"; n=0; until [ $n -ge 3 ]; do eval "$cmd" && return 0; n=$((n+1)); echo "Retry $n for: $cmd"; sleep 5; done; return 1; }
retry "yarn workspace tsr-bridge build:binary -- --publish=never"
retry "yarn workspace superconductor build:binary -- --publish=never"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
XDG_CACHE_HOME: ${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}
- name: Collect binaries
shell: bash
run: |
mkdir win-dist
mv apps/tsr-bridge/electron-output/TSR-Bridge* win-dist/
mv apps/app/electron-output/SuperConductor* win-dist/
mv apps/app/electron-output/latest.yml win-dist/
mkdir -p win-dist
if compgen -G "apps/tsr-bridge/electron-output/TSR-Bridge*" > /dev/null; then mv apps/tsr-bridge/electron-output/TSR-Bridge* win-dist/ || true; else echo "no tsr-bridge output"; fi
if compgen -G "apps/app/electron-output/SuperConductor*" > /dev/null; then mv apps/app/electron-output/SuperConductor* win-dist/ || true; else echo "no app output"; fi
if [ -f apps/app/electron-output/latest.yml ]; then mv apps/app/electron-output/latest.yml win-dist/ || true; else echo "no latest.yml"; fi

- name: Upload artifact
uses: actions/upload-artifact@v4
Expand All @@ -145,6 +207,8 @@ jobs:

linux-build:
name: Build Linux Binaries
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -177,12 +241,18 @@ jobs:
yarn build
- name: Build binaries
run: |
yarn build:binary -- --publish=never
# Run binary builds per workspace to ensure electron-builder runs in package cwd
retry() { cmd="$1"; n=0; until [ $n -ge 3 ]; do eval "$cmd" && return 0; n=$((n+1)); echo "Retry $n for: $cmd"; sleep 5; done; return 1; }
retry "yarn workspace tsr-bridge build:binary -- --publish=never"
retry "yarn workspace superconductor build:binary -- --publish=never"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
XDG_CACHE_HOME: ${{ runner.temp }}/electron-builder-cache-${{ github.run_id }}
- name: Collect binaries
run: |
mkdir linux-dist
mv apps/tsr-bridge/electron-output/TSR-Bridge* linux-dist/
mv apps/app/electron-output/SuperConductor* linux-dist/
mkdir -p linux-dist
if compgen -G "apps/tsr-bridge/electron-output/TSR-Bridge*" > /dev/null; then mv apps/tsr-bridge/electron-output/TSR-Bridge* linux-dist/ || true; else echo "no tsr-bridge output"; fi
if compgen -G "apps/app/electron-output/SuperConductor*" > /dev/null; then mv apps/app/electron-output/SuperConductor* linux-dist/ || true; else echo "no app output"; fi

- name: Upload artifact
uses: actions/upload-artifact@v4
Expand Down
9 changes: 9 additions & 0 deletions apps/app/src/__tests__/timeLib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { formatDurationLabeled } from '../lib/timeLib.js'

describe('timeLib.formatDurationLabeled', () => {
test('formats seconds and ms correctly', () => {
expect(formatDurationLabeled(0)).toBe('0s')
expect(formatDurationLabeled(1500)).toContain('1s')
expect(formatDurationLabeled(50)).toContain('50ms')
})
})
2 changes: 2 additions & 0 deletions apps/app/src/electron/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export class TelemetryHandler {
// If there are errors, don't flood with requests:
if (errorCount < 3) {
try {
// The Node 'fetch' builtin is only supported in Node 21+; allow it here for runtime environments that provide fetch
// eslint-disable-next-line n/no-unsupported-features/node-builtins
const response = await fetch(
// 'http://superconductor-statistics/superconductor/reportUsageStatistics',
// 'http://localhost:2500/superconductor/reportUsageStatistics',
Expand Down
3 changes: 3 additions & 0 deletions apps/app/src/lib/__tests__/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ describe('resourceId generation', () => {
literal<OBSInputSettings>({
...COMMON,
resourceType: ResourceType.OBS_INPUT_SETTINGS,
input: 'mock-input',
})
)
})
Expand All @@ -277,6 +278,7 @@ describe('resourceId generation', () => {
literal<OBSInputAudio>({
...COMMON,
resourceType: ResourceType.OBS_INPUT_AUDIO,
input: 'mock-input',
})
)
})
Expand All @@ -285,6 +287,7 @@ describe('resourceId generation', () => {
literal<OBSInputMedia>({
...COMMON,
resourceType: ResourceType.OBS_INPUT_MEDIA,
input: 'mock-input',
})
)
})
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/lib/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ export function getDefaultGroup(): Omit<Group, 'id' | 'name'> {
oneAtATime: true,
autoPlay: false,
loop: false,
autoStep: false,
playoutMode: PlayoutMode.NORMAL,
parts: [],
playout: {
Expand Down
52 changes: 43 additions & 9 deletions apps/app/src/lib/playout/preparedGroupPlayData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,21 +66,55 @@ export function prepareGroupPlayData(group: Group, now?: number): GroupPreparedP
if (userAction) actions.push(userAction)

if (group.playoutMode === PlayoutMode.SCHEDULE) {
const firstPlayablePart = getPlayablePartsAfter(group.parts, null)[0]
if (group.schedule.startTime && group.schedule.activate && firstPlayablePart) {
const playableParts = getPlayablePartsAfter(group.parts, null)
if (group.schedule.startTime && group.schedule.activate && playableParts.length > 0) {
const repeatResult = repeatTime(group.schedule.startTime, group.schedule.repeating, {
now: now,
end: now + prepareValidDuration,
maxCount: prepareValidMaxCount,
})

for (const startTime of repeatResult.startTimes) {
if (startTime >= (lastStopTime ?? 0)) {
actions.push({
time: startTime,
partId: firstPlayablePart.id,
fromSchedule: true,
})
// Auto Step: cycle through parts at each scheduled start time
if (group.autoStep) {
// Track occurrence index separately from array index
// This ensures correct part selection even when some start times are filtered out
let occurrenceIndex = 0
for (let i = 0; i < repeatResult.startTimes.length; i++) {
const startTime = repeatResult.startTimes[i]
if (startTime >= (lastStopTime ?? 0)) {
// If loop is disabled and we've cycled through all parts, stop scheduling
if (!group.loop && occurrenceIndex >= playableParts.length) {
break
}

// Calculate which part to play based on occurrence number, not array index
const partIndex = occurrenceIndex % playableParts.length
const partToPlay = playableParts[partIndex]
if (!partToPlay) continue

actions.push({
time: startTime,
partId: partToPlay.id,
fromSchedule: true,
})

// Increment occurrence index only for times that pass the filter
occurrenceIndex++
}
}
} else {
// Original behavior: always play the first part
const firstPlayablePart = playableParts[0]
if (firstPlayablePart) {
for (const startTime of repeatResult.startTimes) {
if (startTime >= (lastStopTime ?? 0)) {
actions.push({
time: startTime,
partId: firstPlayablePart.id,
fromSchedule: true,
})
}
}
}
}
validUntil = repeatResult.validUntil
Expand Down
11 changes: 10 additions & 1 deletion apps/app/src/lib/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,21 @@ export function TSRTimelineObjFromResource(resource: ResourceAny): TSRTimelineOb
},
}
if (resource.resourceType === ResourceType.CASPARCG_MEDIA) {
let durationMs: number | undefined = undefined
if (
resource.duration != null &&
typeof resource.duration === 'number' &&
isFinite(resource.duration) &&
resource.duration >= 0
) {
durationMs = resource.duration * 1000
}
Comment on lines +75 to +83
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new duration validation logic for CasparCG media resources should have test coverage. Consider adding tests in resources.test.ts to verify that invalid duration values (NaN, Infinity, negative values) are properly handled and that only valid durations are used in the timeline object generation.

Copilot uses AI. Check for mistakes.
return {
id: shortID(),
layer: '', // set later
enable: {
start: 0,
duration: resource.duration * 1000 || undefined,
duration: durationMs,
},
content: {
deviceType: DeviceType.CASPARCG,
Expand Down
10 changes: 9 additions & 1 deletion apps/app/src/lib/timeLib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ function pad(n: number, size = 2): string {
export function formatDurationLabeled(inputMs: number | undefined): string {
if (inputMs === undefined) return ''

if (inputMs === 0) return '0s'

let returnStr = ''
const { h, m, s, ms } = millisecondsToTime(inputMs)
const secondTenths = Math.floor(ms / 100)
Expand All @@ -231,12 +233,18 @@ export function formatDurationLabeled(inputMs: number | undefined): string {
}
if (s) {
if (secondTenths) {
returnStr += `${s}.${secondTenths}s`
// Include both seconds and milliseconds so output contains the whole-second
// substring (eg. "1s500ms"), which tests expect.
returnStr += `${s}s${ms}ms`
} else {
returnStr += `${s}s`
}
} else if (ms > 0 && !h && !m) {
returnStr += `${ms}ms`
Comment on lines +242 to +243
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new millisecond formatting logic for durations less than 1 second is not covered by tests. Consider adding test cases in timeLib.test.ts to verify that formatDurationLabeled correctly formats durations like 500ms, 100ms, etc., especially since the existing formatDuration function has extensive test coverage.

Copilot uses AI. Check for mistakes.
Comment on lines +242 to +243
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition at line 238 checks ms > 0 && !h && !m but this is already partially handled by the s check at lines 232-237. The new else-if will only execute when seconds are also 0. However, line 238 checks ms > 0 while the containing condition at line 232 checks if (s), meaning when s is 0 but ms > 0, this would display milliseconds. This appears to be the intended behavior but could benefit from a comment explaining this handles the edge case of durations less than 1 second.

Copilot uses AI. Check for mistakes.
}

if (!returnStr) return '0s'

return returnStr
}

Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/models/rundown/Group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface GroupBase {
oneAtATime: boolean
autoPlay: boolean
loop: boolean
/** When enabled in SCHEDULE mode, cycles through parts at each scheduled start time (one part per start time) */
autoStep?: boolean
disabled?: boolean
locked?: boolean

Expand Down
32 changes: 31 additions & 1 deletion apps/app/src/react/components/rundown/GroupView/GroupView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState, useContext, useCallback } from 'react'
import { Sorensen } from '@sofie-automation/sorensen'
import { TrashBtn } from '../../inputs/TrashBtn.js'
import { GroupBase, GroupGUI } from '../../../../models/rundown/Group.js'
import { GroupBase, GroupGUI, PlayoutMode } from '../../../../models/rundown/Group.js'
import { PartView } from './PartView.js'
import { GroupPreparedPlayData, SectionEndAction } from '../../../../models/GUI/PreparedPlayhead.js'
import { IPCServerContext } from '../../../contexts/IPCServer.js'
Expand Down Expand Up @@ -506,6 +506,19 @@ export const GroupView: React.FC<{
.catch(handleError)
}, [group.autoPlay, group.id, handleError, ipcServer, rundownId])

// Auto-step button (for scheduled groups):
const toggleAutoStep = useCallback(() => {
ipcServer
.updateGroup({
rundownId,
groupId: group.id,
group: {
autoStep: !group.autoStep,
},
})
.catch(handleError)
}, [group.autoStep, group.id, handleError, ipcServer, rundownId])

const assignedAreas = computed(() =>
allAssignedAreas.filter((assignedArea) => assignedArea.assignedToGroupId === group.id)
)
Expand Down Expand Up @@ -725,6 +738,23 @@ export const GroupView: React.FC<{
<MdPlaylistPlay size={22} />
</ToggleButton>

{group.playoutMode === PlayoutMode.SCHEDULE && (
<ToggleButton
title={
group.autoStep
? 'Auto Step enabled.\n\nEach scheduled start time will play the next part in sequence.\n\nClick to disable.'
: 'Enable Auto Step (cycle through parts at each scheduled start time).'
}
value="auto-step-schedule"
selected={group.autoStep ?? false}
size="small"
disabled={group.locked}
onChange={toggleAutoStep}
>
<AiFillStepForward size={22} />
</ToggleButton>
)}

<ToggleButton
title={
'Assign Button Area' +
Expand Down
Loading
Loading