diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index 71c701a9..2da0cacd 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -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 @@ -51,6 +57,8 @@ jobs: macos-build: name: Build on macOS + permissions: + contents: write runs-on: macos-latest continue-on-error: true steps: @@ -69,6 +77,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 }}" >> $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 @@ -76,21 +90,59 @@ jobs: - 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 @@ -101,6 +153,8 @@ jobs: windows-build: name: Build on Windows + permissions: + contents: write runs-on: windows-latest continue-on-error: true steps: @@ -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 @@ -145,6 +207,8 @@ jobs: linux-build: name: Build Linux Binaries + permissions: + contents: write runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -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 diff --git a/apps/app/src/__tests__/timeLib.test.ts b/apps/app/src/__tests__/timeLib.test.ts new file mode 100644 index 00000000..ca815b27 --- /dev/null +++ b/apps/app/src/__tests__/timeLib.test.ts @@ -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') + }) +}) diff --git a/apps/app/src/electron/telemetry.ts b/apps/app/src/electron/telemetry.ts index 22779c4f..db71d283 100644 --- a/apps/app/src/electron/telemetry.ts +++ b/apps/app/src/electron/telemetry.ts @@ -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', diff --git a/apps/app/src/lib/__tests__/resources.test.ts b/apps/app/src/lib/__tests__/resources.test.ts index 791ed174..e19693d9 100644 --- a/apps/app/src/lib/__tests__/resources.test.ts +++ b/apps/app/src/lib/__tests__/resources.test.ts @@ -269,6 +269,7 @@ describe('resourceId generation', () => { literal({ ...COMMON, resourceType: ResourceType.OBS_INPUT_SETTINGS, + input: 'mock-input', }) ) }) @@ -277,6 +278,7 @@ describe('resourceId generation', () => { literal({ ...COMMON, resourceType: ResourceType.OBS_INPUT_AUDIO, + input: 'mock-input', }) ) }) @@ -285,6 +287,7 @@ describe('resourceId generation', () => { literal({ ...COMMON, resourceType: ResourceType.OBS_INPUT_MEDIA, + input: 'mock-input', }) ) }) diff --git a/apps/app/src/lib/defaults.ts b/apps/app/src/lib/defaults.ts index f926c367..2d6ca5b5 100644 --- a/apps/app/src/lib/defaults.ts +++ b/apps/app/src/lib/defaults.ts @@ -246,6 +246,7 @@ export function getDefaultGroup(): Omit { oneAtATime: true, autoPlay: false, loop: false, + autoStep: false, playoutMode: PlayoutMode.NORMAL, parts: [], playout: { diff --git a/apps/app/src/lib/playout/preparedGroupPlayData.ts b/apps/app/src/lib/playout/preparedGroupPlayData.ts index 970d6657..120090a6 100644 --- a/apps/app/src/lib/playout/preparedGroupPlayData.ts +++ b/apps/app/src/lib/playout/preparedGroupPlayData.ts @@ -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 diff --git a/apps/app/src/lib/resources.ts b/apps/app/src/lib/resources.ts index 9b34ea3a..37421d82 100644 --- a/apps/app/src/lib/resources.ts +++ b/apps/app/src/lib/resources.ts @@ -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 + } return { id: shortID(), layer: '', // set later enable: { start: 0, - duration: resource.duration * 1000 || undefined, + duration: durationMs, }, content: { deviceType: DeviceType.CASPARCG, diff --git a/apps/app/src/lib/timeLib.ts b/apps/app/src/lib/timeLib.ts index 61398f62..2470728e 100644 --- a/apps/app/src/lib/timeLib.ts +++ b/apps/app/src/lib/timeLib.ts @@ -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) @@ -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` } + if (!returnStr) return '0s' + return returnStr } diff --git a/apps/app/src/models/rundown/Group.ts b/apps/app/src/models/rundown/Group.ts index 6fb6c6b9..79ece645 100644 --- a/apps/app/src/models/rundown/Group.ts +++ b/apps/app/src/models/rundown/Group.ts @@ -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 diff --git a/apps/app/src/react/components/rundown/GroupView/GroupView.tsx b/apps/app/src/react/components/rundown/GroupView/GroupView.tsx index 613e599e..6ec1a347 100644 --- a/apps/app/src/react/components/rundown/GroupView/GroupView.tsx +++ b/apps/app/src/react/components/rundown/GroupView/GroupView.tsx @@ -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' @@ -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) ) @@ -725,6 +738,23 @@ export const GroupView: React.FC<{ + {group.playoutMode === PlayoutMode.SCHEDULE && ( + + + + )} + )} +
+ g.autoStep, undefined)} + disabled={modifiableGroups.length === 0} + onChange={(value) => { + modifiableGroups.forEach((g) => { + ipcServer + .updateGroup({ + rundownId, + groupId: g.id, + group: { + autoStep: value, + }, + }) + .catch(handleError) + }) + }} + /> +
)} diff --git a/apps/app/src/react/components/sidebar/resource/ResourceLibraryItem.tsx b/apps/app/src/react/components/sidebar/resource/ResourceLibraryItem.tsx index ce8dc48f..4a4766e7 100644 --- a/apps/app/src/react/components/sidebar/resource/ResourceLibraryItem.tsx +++ b/apps/app/src/react/components/sidebar/resource/ResourceLibraryItem.tsx @@ -58,7 +58,11 @@ export const ResourceLibraryItem = function ResourceLibraryItem({ resource, sele
{resource.type}
{bytesToSize(resource.size)}
-
{formatDurationLabeled(resource.duration * 1000)}
+
+ {resource.duration != null && resource.duration > 0 + ? formatDurationLabeled(resource.duration * 1000) + : ''} +
diff --git a/apps/app/tsconfig.build.json b/apps/app/tsconfig.build.json index 9776ee6f..847139b4 100644 --- a/apps/app/tsconfig.build.json +++ b/apps/app/tsconfig.build.json @@ -5,10 +5,11 @@ "outDir": "dist", "jsx": "react", "lib": ["DOM"], - "baseUrl": "./" + "baseUrl": "./", + "types": ["jest"] }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts"], - "exclude": ["src/**/__tests__/**/*", "dist/**/*"], + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts", "src/**/*.test.ts"], + "exclude": ["dist/**/*"], "references": [ // { "path": "../../shared/packages/api" }, diff --git a/apps/tsr-bridge/package.json b/apps/tsr-bridge/package.json index be83af3b..5efaf96b 100644 --- a/apps/tsr-bridge/package.json +++ b/apps/tsr-bridge/package.json @@ -10,7 +10,8 @@ }, "scripts": { "build": "vite build", - "build:binary": "electron-builder", + "clean:electron-output": "rm -rf electron-output", + "build:binary": "yarn clean:electron-output && node ./scripts/prep-build-binary.cjs", "start": "yarn build && electron dist/main.mjs", "react:dev": "vite", "electron:dev": "nodemon", diff --git a/apps/tsr-bridge/scripts/prep-build-binary.cjs b/apps/tsr-bridge/scripts/prep-build-binary.cjs new file mode 100644 index 00000000..edfede74 --- /dev/null +++ b/apps/tsr-bridge/scripts/prep-build-binary.cjs @@ -0,0 +1,81 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable no-console */ +const { spawnSync } = require('child_process') +const fs = require('fs') +const path = require('path') + +function exists(p) { + try { + return fs.existsSync(p) + } catch { + return false + } +} + +const cwd = process.cwd() +const distDir = path.join(cwd, 'dist') +const buildResources = path.join(cwd, 'resources') +const electronOutput = path.join(cwd, 'electron-output') + +console.log('prep-build-binary: cwd=', cwd) +console.log('prep-build-binary: checking paths:') +console.log(' - dist exists:', exists(distDir), distDir) +console.log(' - resources exists:', exists(buildResources), buildResources) +console.log(' - electron-output exists:', exists(electronOutput), electronOutput) + +if (!exists(distDir)) { + console.warn('prep-build-binary: WARNING: dist/ directory missing. Run `yarn workspace tsr-bridge build` first.') +} + +// Print a short listing to help CI debug +function list(dir) { + if (!exists(dir)) return + console.log('\nListing ' + dir) + try { + const items = fs.readdirSync(dir) + for (const it of items.slice(0, 200)) { + const p = path.join(dir, it) + const st = fs.statSync(p) + console.log(`${st.isDirectory() ? 'd' : '-'} ${st.size.toString().padStart(8)} ${it}`) + } + } catch (err) { + console.error('Error listing', dir, err && err.stack ? err.stack : err) + } +} + +list(cwd) +list(distDir) +list(buildResources) + +// Forward args to electron-builder +const args = process.argv.slice(2) +console.log('\nRunning electron-builder with args:', args.join(' ')) + +// Try running electron-builder via available runner: prefer npx, fall back to yarn +// Run electron-builder via a shell fallback chain so missing binaries don't +// cause spawnSync to throw ENOENT on Windows/CI. Try `npx`, then `yarn exec`, then +// the local `node_modules/.bin/electron-builder`. +const cmdParts = [] +const quotedArgs = args.map((a) => { + if (/\s/.test(a)) return '"' + a.replace(/"/g, '\\"') + '"' + return a +}) +const argString = quotedArgs.join(' ') +cmdParts.push(`npx electron-builder ${argString}`) +cmdParts.push(`yarn exec electron-builder ${argString}`) +cmdParts.push(`node ./node_modules/.bin/electron-builder ${argString}`) +const shellCmd = cmdParts.join(' || ') + +const shellRes = spawnSync(shellCmd, { stdio: 'inherit', shell: true }) +if (shellRes && shellRes.error) { + console.error('prep-build-binary: spawn error', shellRes.error) + // eslint-disable-next-line n/no-process-exit + process.exit(1) +} +if (typeof shellRes.status === 'number' && shellRes.status !== 0) { + console.error('prep-build-binary: electron-builder exited with code', shellRes.status) + // eslint-disable-next-line n/no-process-exit + process.exit(shellRes.status) +} +// eslint-disable-next-line n/no-process-exit +process.exit(0) diff --git a/apps/tsr-bridge/tools/notarize.cjs b/apps/tsr-bridge/tools/notarize.cjs index ff61e594..f31acab9 100644 --- a/apps/tsr-bridge/tools/notarize.cjs +++ b/apps/tsr-bridge/tools/notarize.cjs @@ -2,6 +2,8 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports const { notarize } = require('@electron/notarize') +// eslint-disable-next-line @typescript-eslint/no-require-imports +const fs = require('fs') exports.default = async function notarizing(context) { const { electronPlatformName, appOutDir } = context @@ -9,19 +11,45 @@ exports.default = async function notarizing(context) { return } + // If Apple credentials are not provided, skip notarize (CI/PR builds) if (!process.env.APPLEID || !process.env.APPLEIDPASS || !process.env.APPLEIDTEAM) { // eslint-disable-next-line no-console console.log('Skipping notarizing, due to missing APPLEID, APPLEIDTEAM or APPLEIDPASS environment variables') return } - const appName = context.packager.appInfo.productFilename + const appName = context.packager && context.packager.appInfo && context.packager.appInfo.productFilename + const appPath = `${appOutDir}/${appName}.app` + + // Defensive logging to help diagnose CI failures where appPath may be unexpected + // eslint-disable-next-line no-console + console.log('Notarize: electronPlatformName=', electronPlatformName) + // eslint-disable-next-line no-console + console.log('Notarize: appOutDir=', appOutDir) + // eslint-disable-next-line no-console + console.log('Notarize: appName=', appName) + // eslint-disable-next-line no-console + console.log('Notarize: computed appPath=', appPath) + + // Verify path exists before calling notarize + try { + if (!fs.existsSync(appPath)) { + // eslint-disable-next-line no-console + console.log(`Notarize: appPath does not exist, skipping notarize: ${appPath}`) + return + } - return notarize({ - appBundleId: 'tv.superfly.tsr-bridge', - appPath: `${appOutDir}/${appName}.app`, - appleId: process.env.APPLEID, - appleIdPassword: process.env.APPLEIDPASS, - teamId: process.env.APPLEIDTEAM, - }) + return await notarize({ + appBundleId: 'tv.superfly.tsr-bridge', + appPath, + appleId: process.env.APPLEID, + appleIdPassword: process.env.APPLEIDPASS, + teamId: process.env.APPLEIDTEAM, + }) + } catch (err) { + // eslint-disable-next-line no-console + console.error('Notarize: error during notarize step', err && err.stack ? err.stack : err) + // Do not throw — avoid failing the whole build on notarize errors + return + } } diff --git a/jest.config.base.cjs b/jest.config.base.cjs index b70507bd..d38bd90b 100644 --- a/jest.config.base.cjs +++ b/jest.config.base.cjs @@ -7,6 +7,7 @@ module.exports = { extensionsToTreatAsEsm: ['.ts'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', + '^@shared\\/(.*)$': '/../../shared/packages/$1/src', }, transform: { '^.+\\.(ts|tsx)$': [ diff --git a/package.json b/package.json index fca84348..872af9c5 100644 --- a/package.json +++ b/package.json @@ -79,5 +79,5 @@ "yarn lint:raw --fix" ] }, - "packageManager": "yarn@4.9.1" + "packageManager": "yarn@4.12.0" } diff --git a/shared/packages/lib/src/Resources.ts b/shared/packages/lib/src/Resources.ts index 3fd609a6..de013d8c 100644 --- a/shared/packages/lib/src/Resources.ts +++ b/shared/packages/lib/src/Resources.ts @@ -500,12 +500,14 @@ export function getResourceLocatorFromTimelineObj( return '0' case ResourceType.INVALID: return 'INVALID' - case ResourceType.OBS_INPUT_AUDIO: case ResourceType.OBS_RECORDING: case ResourceType.OBS_STREAMING: case ResourceType.OBS_RENDER: - case ResourceType.OBS_INPUT_SETTINGS: return '0' + case ResourceType.OBS_INPUT_AUDIO: + return ((mapping && (mapping.options as any).input) as string) || '0' + case ResourceType.OBS_INPUT_SETTINGS: + return ((mapping && (mapping.options as any).input) as string) || '0' case ResourceType.OBS_SCENE: return (obj as TSRTimelineObj).content.sceneName case ResourceType.OBS_TRANSITION: diff --git a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts index 900b4904..4bb51879 100644 --- a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts +++ b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts @@ -18,6 +18,7 @@ import { addTemplatesToResourcesFromCasparCGMediaScanner, addTemplatesToResourcesFromDisk, } from './CasparCGTemplates.js' +import { durationFromFrames, frameTimeFromFrames } from './helpers.js' import { assertNever, getResourceIdFromResource } from '@shared/lib' export class CasparCGSideload implements SideLoadDevice { @@ -43,7 +44,7 @@ export class CasparCGSideload implements SideLoadDevice { this.log.info(`CasparCG ${this.deviceId}: Sideload connection disconnected`) }) this.ccg.on('error', (error) => { - this.log.info(`CasparCG Error: ${error}`) + this.log.error(`CasparCG ${this.deviceId} Error:`, error) }) } public async refreshResourcesAndMetadata(): Promise<{ resources: ResourceAny[]; metadata: MetadataAny }> { @@ -70,15 +71,25 @@ export class CasparCGSideload implements SideLoadDevice { { let mediaList: ClipInfo[] = [] - const res = await this.ccg.cls() - if (res.error) throw res.error + try { + const res = await this.ccg.cls() + if (res.error) { + this.log.error(`CasparCG ${this.deviceId}: Error getting media list:`, res.error) + throw res.error + } - const response = await res.request - if (this._isSuccessful(response)) { - mediaList = response.data - } else if (response.responseCode !== 501) { - // This probably means it's something other than media-scanner not running - this.log.error(`Could not get media list. Received response:`, response.responseCode, response.message) + const response = await res.request + if (this._isSuccessful(response)) { + mediaList = response.data || [] + } else if (response.responseCode !== 501) { + // This probably means it's something other than media-scanner not running + this.log.error( + `CasparCG ${this.deviceId}: Could not get media list. Received response code: ${response.responseCode}, message: ${response.message}` + ) + } + } catch (error) { + this.log.error(`CasparCG ${this.deviceId}: Exception while getting media list:`, error) + mediaList = [] } for (const media of mediaList) { @@ -99,6 +110,34 @@ export class CasparCGSideload implements SideLoadDevice { type = 'video' } + /** + * Calculate duration safely, handling edge cases for CasparCG 2.5 compatibility + * CasparCG 2.5 may store framerate in different formats: + * As fps directly (e.g., 30 for 30fps) + * As fps * 1000 (e.g., 30000 for 30fps) - most common in CasparCG 2.5 + * First, check if duration is provided directly (preferred) + */ + // Use helper to parse framerate and calculate duration/frameTime + let duration = 0 + let frameTime = '' + if ( + (media as any).duration != null && + typeof (media as any).duration === 'number' && + (media as any).duration > 0 + ) { + duration = (media as any).duration + } else { + duration = durationFromFrames(media.frames, media.framerate) + } + + if ( + media.frames != null && + media.framerate != null && + typeof media.frames === 'number' && + typeof media.framerate === 'number' + ) { + frameTime = frameTimeFromFrames(media.frames, media.framerate) + } const resource: CasparCGMedia = { resourceType: ResourceType.CASPARCG_MEDIA, deviceId: this.deviceId, @@ -107,11 +146,11 @@ export class CasparCGSideload implements SideLoadDevice { name: media.clip, displayName: media.clip, changed: media.datetime, - duration: media.frames / media.framerate, - frameRate: media.framerate, - frames: media.frames, + duration, + frameRate: media.framerate ?? 0, + frames: media.frames ?? 0, size: media.size, - frameTime: '', + frameTime, } resource.id = getResourceIdFromResource(resource) diff --git a/shared/packages/tsr-bridge/src/sideload/CasparCGTemplates.ts b/shared/packages/tsr-bridge/src/sideload/CasparCGTemplates.ts index 860117bf..d8ddfaeb 100644 --- a/shared/packages/tsr-bridge/src/sideload/CasparCGTemplates.ts +++ b/shared/packages/tsr-bridge/src/sideload/CasparCGTemplates.ts @@ -62,6 +62,8 @@ export async function addTemplatesToResourcesFromCasparCGMediaScanner( let jsonData: MediaScannerTemplateData | null = null 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://${casparCG.host}:8000/templates`) if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) diff --git a/shared/packages/tsr-bridge/src/sideload/__tests__/helpers.test.ts b/shared/packages/tsr-bridge/src/sideload/__tests__/helpers.test.ts new file mode 100644 index 00000000..109fe551 --- /dev/null +++ b/shared/packages/tsr-bridge/src/sideload/__tests__/helpers.test.ts @@ -0,0 +1,27 @@ +import { parseCasparFramerate, durationFromFrames, frameTimeFromFrames } from '../helpers.js' + +describe('CasparCG helpers', () => { + test('parse simple fps', () => { + expect(parseCasparFramerate(30)).toBe(30) + expect(parseCasparFramerate(60)).toBe(60) + }) + + test('parse fps*1000', () => { + expect(parseCasparFramerate(30000)).toBeCloseTo(30) + expect(parseCasparFramerate(59940)).toBeCloseTo(59.94) + }) + + test('parse fps*1001 (ntsc)', () => { + expect(parseCasparFramerate(30030)).toBeCloseTo(29.97, 2) + }) + + test('duration from frames', () => { + expect(durationFromFrames(300, 30000)).toBeCloseTo(10) + expect(durationFromFrames(2997, 30030)).toBeGreaterThan(49) + }) + + test('frameTime from frames', () => { + const ft = frameTimeFromFrames(3601 * 30 + 5, 30) + expect(ft.startsWith('01:00:01')).toBeTruthy() + }) +}) diff --git a/shared/packages/tsr-bridge/src/sideload/helpers.ts b/shared/packages/tsr-bridge/src/sideload/helpers.ts new file mode 100644 index 00000000..ca606a6f --- /dev/null +++ b/shared/packages/tsr-bridge/src/sideload/helpers.ts @@ -0,0 +1,60 @@ +// Helpers for parsing CasparCG framerates and calculating durations +export function parseCasparFramerate(raw: number | undefined | null): number { + if (raw == null || typeof raw !== 'number' || !isFinite(raw) || raw <= 0) return 0 + + // If value looks already like FPS (20-70), return it directly + if (raw >= 20 && raw <= 70) return raw + + // If encoded as fps*1000 or fps*1001, try decoding + if (raw > 1000) { + const by1000 = raw / 1000 + const by1001 = raw / 1001 + + // Known fractional NTSC rates + const ntsc29 = 29.97 + const ntsc59 = 59.94 + const eps = 0.05 + + // Prefer the candidate closest to known NTSC rates + if (Math.abs(by1001 - ntsc29) < eps || Math.abs(by1001 - ntsc59) < eps) { + return by1001 + } + + // Otherwise choose the candidate that's in a plausible FPS range + if (by1000 >= 20 && by1000 <= 70) return by1000 + if (by1001 >= 20 && by1001 <= 70) return by1001 + + // Fallback to by1000 + return by1000 + } + + // Otherwise, fallback to raw (may be unusual) + return raw +} + +export function durationFromFrames(frames: number | undefined | null, rawFramerate: number | undefined | null): number { + if (frames == null || rawFramerate == null) return 0 + const fps = parseCasparFramerate(rawFramerate) + if (!(fps > 0)) return 0 + const duration = frames / fps + if (!isFinite(duration) || duration < 0 || duration > 86400) return 0 + return duration +} + +export function frameTimeFromFrames( + framesTotal: number | undefined | null, + rawFramerate: number | undefined | null +): string { + if (framesTotal == null || rawFramerate == null) return '' + const fps = Math.round(parseCasparFramerate(rawFramerate)) + if (!(fps > 0)) return '' + + const totalFrames = framesTotal + const frames = totalFrames % fps + const totalSeconds = Math.floor(totalFrames / fps) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(frames).padStart(2, '0')}` +} diff --git a/shared/packages/tsr-bridge/tsconfig.json b/shared/packages/tsr-bridge/tsconfig.json index 1df00708..a1f949aa 100644 --- a/shared/packages/tsr-bridge/tsconfig.json +++ b/shared/packages/tsr-bridge/tsconfig.json @@ -2,10 +2,11 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "types": ["jest"] }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/__tests__/**/*"], + "include": ["src/**/*.ts", "src/**/*.test.ts"], + "exclude": ["dist/**/*"], "references": [ // { "path": "../api" },