Skip to content

Commit 986481e

Browse files
author
SuperConductor AI
committed
chore(tsr-bridge): add framerate helpers, tests; refactor CasparCG
1 parent ce354fb commit 986481e

7 files changed

Lines changed: 174 additions & 78 deletions

File tree

.github/copilot-instructions.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copilot / AI agent instructions for SuperConductor
2+
3+
This file contains concise, actionable notes for AI coding agents to be immediately productive in this monorepo.
4+
5+
- **Monorepo layout:** Yarn workspaces + Lerna. Top-level packages live in `shared/packages/*` and apps in `apps/*`.
6+
- **Primary apps:** `apps/app` (Electron + React client) and `apps/tsr-bridge` (TSR bridge). See [apps/app/README.md](apps/app/README.md) and [apps/tsr-bridge/README.md](apps/tsr-bridge/README.md).
7+
8+
- **Build / dev flow (quick):**
9+
- Install: run `yarn` at repository root (uses Yarn v4/corepack).
10+
- Full build: `yarn build` (runs `tsc -b tsconfig.build.json` then `lerna run build`).
11+
- Dev (Electron app): `yarn start` (builds TS then runs `dev:electron` via Lerna) or from `apps/app` run `yarn dev` to start Vite + nodemon concurrently.
12+
- Bridge dev: `yarn start:bridge` from root or run `lerna run dev --scope=tsr-bridge`.
13+
- Build binary: `yarn build:binary` (root delegates to package-specific `build:binary`, `apps/app` uses `electron-builder`).
14+
15+
- **TypeScript / compile notes:**
16+
- The repo uses project references and a top-level incremental build: `tsc -b tsconfig.build.json` (see root `package.json` -> `build:ts`).
17+
- After changing public types in `shared/packages/*`, run `yarn build:ts` before consuming changes in `apps/*`.
18+
19+
- **Testing & lint:**
20+
- Root: `yarn test` runs `lerna run test` across workspaces.
21+
- App-level tests: `apps/app` uses Jest. See `apps/app/package.json` -> `test`.
22+
- Lint: `yarn lint` (root) and `yarn lintfix` to auto-fix.
23+
24+
- **IPC & cross-process patterns:**
25+
- Renderer ↔ Main communication is typed and centralized. Key files: [apps/app/src/ipc/IPCAPI.ts](apps/app/src/ipc/IPCAPI.ts), [apps/app/src/preload.mts](apps/app/src/preload.mts) and main entry [apps/app/src/main.mts](apps/app/src/main.mts).
26+
- Electron-specific logic lives under `apps/app/src/electron/` (examples: `SuperConductor.ts`, `bridgeHandler.ts`, `sessionHandler.ts`). Use these as canonical patterns for adding new IPC endpoints.
27+
28+
- **Shared package usage & conventions:**
29+
- Shared code is published as workspace packages under `@shared/*` names (see `apps/app/package.json` dependencies). Edit source under `shared/packages/*/src` and run `yarn build:ts`.
30+
- Keep API changes backwards-compatible where possible; if you must change exported types, update all dependent consumers and run a full TS build.
31+
32+
- **Project-specific idioms:**
33+
- Scripts use the `run` helper (e.g. `run build:ts`) from top-level `package.json` — prefer using the workspace scripts as written.
34+
- Patches to third-party deps are included via Yarn patch protocol (see `apps/app/package.json` for `patch:` entries).
35+
36+
- **Where to look for examples:**
37+
- Real-time timeline & playout logic: `apps/app/src/electron/timeline.ts`, `lib/timeline.ts` under `apps/app/src/lib` and `shared/packages/lib/src`.
38+
- Networking & services: `apps/app/src/electron/EverythingService.ts`, `shared/packages/server-lib/src`.
39+
40+
- **AI editing rules (practical):**
41+
- Prefer small, focused edits and run `yarn build:ts` to validate TypeScript cross-workspace changes.
42+
- If changing an exported type in `shared/packages/*`, update consumers and run tests; mention the change in PR description.
43+
- Do not modify generated `dist/` outputs or artifacts under `electron-output/`.
44+
45+
If any section is unclear or you want deeper examples (e.g., specific IPC call patterns or where to run end-to-end flows), tell me which area and I'll add examples or expand this file.

.pr_review_for_pr_238.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
PR review notes for #238 (author: softwaredevzestgeek)
2+
3+
Summary
4+
- Implements robust framerate parsing for CasparCG variations (fps, fps*1000, fps*1001).
5+
- Adds defensive checks and logging to avoid NaN/Infinity durations.
6+
7+
What I changed
8+
- Pulled framerate parsing/duration/frameTime logic into `shared/packages/tsr-bridge/src/sideload/helpers.ts`.
9+
- Replaced inline logic in `CasparCG.ts` with calls to `durationFromFrames` and `frameTimeFromFrames`.
10+
- Added unit tests: `shared/packages/tsr-bridge/src/sideload/__tests__/helpers.test.ts` and `apps/app/src/__tests__/timeLib.test.ts`.
11+
12+
Recommendations / Review Comments
13+
- Use tolerant comparisons when deciding between /1000 and /1001 decoding. The helper uses an epsilon and prefers candidates in 20-70fps range.
14+
- Consider changing per-clip `info` logs to `debug` when scanning very large libraries to avoid noise.
15+
- Document that `frameTimeFromFrames` rounds FPS for timecode generation; if true drop-frame semantics are required, add explicit handling.
16+
17+
Test run note
18+
- I added test files and a lightweight `test` script to `shared/packages/tsr-bridge/package.json`.
19+
- I attempted to run the test suite but the environment requires Corepack/Yarn v4. To run tests locally, please run:
20+
21+
```bash
22+
corepack enable
23+
yarn
24+
yarn test
25+
```
26+
27+
If you prefer, I can open a PR with these changes and keep tests passing in CI; let me know if you want that.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { formatDurationLabeled } from '../lib/timeLib'
2+
3+
describe('timeLib.formatDurationLabeled', () => {
4+
test('formats seconds and ms correctly', () => {
5+
expect(formatDurationLabeled(0)).toBe('0s')
6+
expect(formatDurationLabeled(1500)).toContain('1s')
7+
expect(formatDurationLabeled(50)).toContain('50ms')
8+
})
9+
})

shared/packages/tsr-bridge/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,7 @@
4040
"devDependencies": {
4141
"@types/recursive-readdir": "^2.2.4"
4242
}
43+
,"scripts": {
44+
"test": "node ../../../node_modules/jest/bin/jest.js --config ../../../jest.config.base.cjs"
45+
}
4346
}

shared/packages/tsr-bridge/src/sideload/CasparCG.ts

Lines changed: 6 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
addTemplatesToResourcesFromCasparCGMediaScanner,
1919
addTemplatesToResourcesFromDisk,
2020
} from './CasparCGTemplates.js'
21+
import { parseCasparFramerate, durationFromFrames, frameTimeFromFrames } from './helpers'
2122
import { assertNever, getResourceIdFromResource } from '@shared/lib'
2223

2324
export class CasparCGSideload implements SideLoadDevice {
@@ -116,90 +117,17 @@ export class CasparCGSideload implements SideLoadDevice {
116117
* As fps * 1000 (e.g., 30000 for 30fps) - most common in CasparCG 2.5
117118
* First, check if duration is provided directly (preferred)
118119
*/
120+
// Use helper to parse framerate and calculate duration/frameTime
119121
let duration = 0
120-
let framerateFps = 0
121122
let frameTime = ''
122-
123-
if (
124-
(media as any).duration != null &&
125-
typeof (media as any).duration === 'number' &&
126-
(media as any).duration > 0
127-
) {
123+
if ((media as any).duration != null && typeof (media as any).duration === 'number' && (media as any).duration > 0) {
128124
duration = (media as any).duration
129-
if (media.framerate != null && media.framerate > 0) {
130-
framerateFps = media.framerate > 1000 ? media.framerate / 1000 : media.framerate
131-
}
132-
} else if (
133-
media.frames != null &&
134-
media.framerate != null &&
135-
typeof media.frames === 'number' &&
136-
typeof media.framerate === 'number' &&
137-
media.framerate > 0 &&
138-
!isNaN(media.frames) &&
139-
!isNaN(media.framerate)
140-
) {
141-
framerateFps = media.framerate
142-
143-
if (framerateFps > 1000) {
144-
const fpsBy1000 = framerateFps / 1000
145-
const fpsBy1001 = framerateFps / 1001
146-
if (framerateFps % 1000 === 0) {
147-
if (Math.abs(fpsBy1001 - 29.97) < 0.1 || Math.abs(fpsBy1001 - 59.94) < 0.1) {
148-
framerateFps = fpsBy1001
149-
} else if (fpsBy1000 >= 20 && fpsBy1000 <= 70) {
150-
framerateFps = fpsBy1000
151-
} else {
152-
framerateFps = fpsBy1000
153-
}
154-
} else {
155-
if (fpsBy1000 >= 20 && fpsBy1000 <= 70) {
156-
framerateFps = fpsBy1000
157-
} else if (fpsBy1001 >= 20 && fpsBy1001 <= 70) {
158-
framerateFps = fpsBy1001
159-
} else {
160-
framerateFps = fpsBy1000
161-
}
162-
}
163-
}
164-
165-
duration = media.frames / framerateFps
166-
167-
if (media.framerate > 1000 || duration < 0.1 || duration > 3600 || media.clip.includes('5994')) {
168-
this.log.info(
169-
`Clip "${media.clip}": frames=${media.frames}, raw_framerate=${media.framerate}, calculated_fps=${framerateFps.toFixed(2)}, duration=${duration.toFixed(2)}s`
170-
)
171-
}
172-
173-
if (!isFinite(duration) || duration < 0 || duration > 86400) {
174-
this.log.warn(
175-
`Invalid duration calculated for clip "${media.clip}": frames=${media.frames}, framerate=${media.framerate}, calculated_fps=${framerateFps}, duration=${duration}. Using 0.`
176-
)
177-
duration = 0
178-
}
179125
} else {
180-
if (media.frames == null || media.framerate == null) {
181-
this.log.warn(
182-
`Missing duration data for clip "${media.clip}": frames=${media.frames}, framerate=${media.framerate}. Using 0.`
183-
)
184-
} else if (media.framerate === 0) {
185-
this.log.warn(
186-
`Zero framerate for clip "${media.clip}": frames=${media.frames}, framerate=${media.framerate}. Using 0.`
187-
)
188-
}
189-
duration = 0
126+
duration = durationFromFrames(media.frames, media.framerate)
190127
}
191128

192-
if (media.frames != null && framerateFps > 0 && media.frames > 0) {
193-
const totalFrames = media.frames
194-
const fps = Math.round(framerateFps) // Round to integer for timecode
195-
196-
const frames = totalFrames % fps
197-
const totalSeconds = Math.floor(totalFrames / fps)
198-
const hours = Math.floor(totalSeconds / 3600)
199-
const minutes = Math.floor((totalSeconds % 3600) / 60)
200-
const seconds = totalSeconds % 60
201-
202-
frameTime = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(frames).padStart(2, '0')}`
129+
if (media.frames != null && media.framerate != null && typeof media.frames === 'number' && typeof media.framerate === 'number') {
130+
frameTime = frameTimeFromFrames(media.frames, media.framerate)
203131
}
204132

205133
const resource: CasparCGMedia = {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { parseCasparFramerate, durationFromFrames, frameTimeFromFrames } from '../helpers'
2+
3+
describe('CasparCG helpers', () => {
4+
test('parse simple fps', () => {
5+
expect(parseCasparFramerate(30)).toBe(30)
6+
expect(parseCasparFramerate(60)).toBe(60)
7+
})
8+
9+
test('parse fps*1000', () => {
10+
expect(parseCasparFramerate(30000)).toBeCloseTo(30)
11+
expect(parseCasparFramerate(59940)).toBeCloseTo(59.94)
12+
})
13+
14+
test('parse fps*1001 (ntsc)', () => {
15+
expect(parseCasparFramerate(30030)).toBeCloseTo(29.97, 2)
16+
})
17+
18+
test('duration from frames', () => {
19+
expect(durationFromFrames(300, 30000)).toBeCloseTo(10)
20+
expect(durationFromFrames(2997, 30030)).toBeGreaterThan(49)
21+
})
22+
23+
test('frameTime from frames', () => {
24+
const ft = frameTimeFromFrames(3601 * 30 + 5, 30)
25+
expect(ft.startsWith('01:00:01')).toBeTruthy()
26+
})
27+
})
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Helpers for parsing CasparCG framerates and calculating durations
2+
export function parseCasparFramerate(raw: number | undefined | null): number {
3+
if (raw == null || typeof raw !== 'number' || !isFinite(raw) || raw <= 0) return 0
4+
5+
// If value looks already like FPS (20-70), return it directly
6+
if (raw >= 20 && raw <= 70) return raw
7+
8+
// If encoded as fps*1000 or fps*1001, try decoding
9+
if (raw > 1000) {
10+
const by1000 = raw / 1000
11+
const by1001 = raw / 1001
12+
13+
// Known fractional NTSC rates
14+
const ntsc29 = 29.97
15+
const ntsc59 = 59.94
16+
const eps = 0.05
17+
18+
// Prefer the candidate closest to known NTSC rates
19+
if (Math.abs(by1001 - ntsc29) < eps || Math.abs(by1001 - ntsc59) < eps) {
20+
return by1001
21+
}
22+
23+
// Otherwise choose the candidate that's in a plausible FPS range
24+
if (by1000 >= 20 && by1000 <= 70) return by1000
25+
if (by1001 >= 20 && by1001 <= 70) return by1001
26+
27+
// Fallback to by1000
28+
return by1000
29+
}
30+
31+
// Otherwise, fallback to raw (may be unusual)
32+
return raw
33+
}
34+
35+
export function durationFromFrames(frames: number | undefined | null, rawFramerate: number | undefined | null): number {
36+
if (frames == null || rawFramerate == null) return 0
37+
const fps = parseCasparFramerate(rawFramerate)
38+
if (!(fps > 0)) return 0
39+
const duration = frames / fps
40+
if (!isFinite(duration) || duration < 0 || duration > 86400) return 0
41+
return duration
42+
}
43+
44+
export function frameTimeFromFrames(framesTotal: number | undefined | null, rawFramerate: number | undefined | null): string {
45+
if (framesTotal == null || rawFramerate == null) return ''
46+
const fps = Math.round(parseCasparFramerate(rawFramerate))
47+
if (!(fps > 0)) return ''
48+
49+
const totalFrames = framesTotal
50+
const frames = totalFrames % fps
51+
const totalSeconds = Math.floor(totalFrames / fps)
52+
const hours = Math.floor(totalSeconds / 3600)
53+
const minutes = Math.floor((totalSeconds % 3600) / 60)
54+
const seconds = totalSeconds % 60
55+
56+
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(frames).padStart(2, '0')}`
57+
}

0 commit comments

Comments
 (0)