diff --git a/meteor/server/publications/segmentPartNotesUI/__tests__/generateNotesForSegment.test.ts b/meteor/server/publications/segmentPartNotesUI/__tests__/generateNotesForSegment.test.ts index 3debf64119..fea13201aa 100644 --- a/meteor/server/publications/segmentPartNotesUI/__tests__/generateNotesForSegment.test.ts +++ b/meteor/server/publications/segmentPartNotesUI/__tests__/generateNotesForSegment.test.ts @@ -486,4 +486,121 @@ describe('generateNotesForSegment', () => { ]) ) }) + + test('partInstance with runtime invalidReason', async () => { + const playlistId = protectString('playlist0') + const nrcsName = 'some nrcs' + + const segment: Pick = { + _id: protectString('segment0'), + _rank: 1, + rundownId: protectString('rundown0'), + name: 'A segment', + notes: [], + orphaned: undefined, + } + + const partInstance0: Pick = { + _id: protectString('instance0'), + segmentId: segment._id, + rundownId: segment.rundownId, + orphaned: undefined, + reset: false, + invalidReason: { + message: generateTranslation('Runtime error occurred'), + severity: NoteSeverity.ERROR, + }, + part: { + _id: protectString('part0'), + title: 'Test Part', + } as any, + } + + const notes = generateNotesForSegment(playlistId, segment, nrcsName, [], [partInstance0]) + expect(notes).toEqual( + literal([ + { + _id: protectString('segment0_partinstance_instance0_invalid_runtime'), + note: { + type: NoteSeverity.ERROR, + message: partInstance0.invalidReason!.message, + rank: segment._rank, + origin: { + segmentId: segment._id, + rundownId: segment.rundownId, + name: partInstance0.part.title, + partId: partInstance0.part._id, + segmentName: segment.name, + }, + }, + playlistId: playlistId, + rundownId: segment.rundownId, + segmentId: segment._id, + }, + ]) + ) + }) + + test('partInstance with runtime invalidReason but reset - no note', async () => { + const playlistId = protectString('playlist0') + const nrcsName = 'some nrcs' + + const segment: Pick = { + _id: protectString('segment0'), + _rank: 1, + rundownId: protectString('rundown0'), + name: 'A segment', + notes: [], + orphaned: undefined, + } + + const partInstance0: Pick = { + _id: protectString('instance0'), + segmentId: segment._id, + rundownId: segment.rundownId, + orphaned: undefined, + reset: true, + invalidReason: { + message: generateTranslation('Runtime error occurred'), + severity: NoteSeverity.ERROR, + }, + part: { + _id: protectString('part0'), + title: 'Test Part', + } as any, + } + + const notes = generateNotesForSegment(playlistId, segment, nrcsName, [], [partInstance0]) + expect(notes).toHaveLength(0) + }) + + test('partInstance without invalidReason - no note', async () => { + const playlistId = protectString('playlist0') + const nrcsName = 'some nrcs' + + const segment: Pick = { + _id: protectString('segment0'), + _rank: 1, + rundownId: protectString('rundown0'), + name: 'A segment', + notes: [], + orphaned: undefined, + } + + const partInstance0: Pick = { + _id: protectString('instance0'), + segmentId: segment._id, + rundownId: segment.rundownId, + orphaned: undefined, + reset: false, + invalidReason: undefined, + part: { + _id: protectString('part0'), + title: 'Test Part', + } as any, + } + + const notes = generateNotesForSegment(playlistId, segment, nrcsName, [], [partInstance0]) + expect(notes).toHaveLength(0) + }) }) diff --git a/meteor/server/publications/segmentPartNotesUI/__tests__/publication.test.ts b/meteor/server/publications/segmentPartNotesUI/__tests__/publication.test.ts index f232e38371..42e3400297 100644 --- a/meteor/server/publications/segmentPartNotesUI/__tests__/publication.test.ts +++ b/meteor/server/publications/segmentPartNotesUI/__tests__/publication.test.ts @@ -69,7 +69,7 @@ describe('manipulateUISegmentPartNotesPublicationData', () => { Rundowns: new ReactiveCacheCollection('Rundowns'), Segments: new ReactiveCacheCollection('Segments'), Parts: new ReactiveCacheCollection('Parts'), - DeletedPartInstances: new ReactiveCacheCollection('DeletedPartInstances'), + PartInstances: new ReactiveCacheCollection('PartInstances'), } newCache.Rundowns.insert({ @@ -356,11 +356,11 @@ describe('manipulateUISegmentPartNotesPublicationData', () => { invalid: false, invalidReason: undefined, }) - newCache.DeletedPartInstances.insert({ + newCache.PartInstances.insert({ _id: 'instance0', segmentId: segmentId0, rundownId: rundownId, - orphaned: undefined, + orphaned: 'deleted', reset: false, part: 'part' as any, }) @@ -421,6 +421,7 @@ describe('manipulateUISegmentPartNotesPublicationData', () => { [ { _id: 'instance0', + orphaned: 'deleted', part: 'part', reset: false, rundownId: 'rundown0', diff --git a/meteor/server/publications/segmentPartNotesUI/generateNotesForSegment.ts b/meteor/server/publications/segmentPartNotesUI/generateNotesForSegment.ts index 89dd9545a2..da4205a90d 100644 --- a/meteor/server/publications/segmentPartNotesUI/generateNotesForSegment.ts +++ b/meteor/server/publications/segmentPartNotesUI/generateNotesForSegment.ts @@ -155,5 +155,31 @@ export function generateNotesForSegment( } } + // Generate notes for runtime invalidReason on PartInstances + // This is distinct from planned invalidReason on Parts - these are runtime validation issues + for (const partInstance of partInstances) { + // Skip if the PartInstance has been reset (no longer relevant) or has no runtime invalidReason + if (partInstance.reset || !partInstance.invalidReason) continue + + notes.push({ + _id: protectString(`${segment._id}_partinstance_${partInstance._id}_invalid_runtime`), + playlistId, + rundownId: partInstance.rundownId, + segmentId: segment._id, + note: { + type: partInstance.invalidReason.severity ?? NoteSeverity.ERROR, + message: partInstance.invalidReason.message, + rank: segment._rank, + origin: { + segmentId: partInstance.segmentId, + partId: partInstance.part._id, + rundownId: partInstance.rundownId, + segmentName: segment.name, + name: partInstance.part.title, + }, + }, + }) + } + return notes } diff --git a/meteor/server/publications/segmentPartNotesUI/publication.ts b/meteor/server/publications/segmentPartNotesUI/publication.ts index 4de954886c..bbdd6c0236 100644 --- a/meteor/server/publications/segmentPartNotesUI/publication.ts +++ b/meteor/server/publications/segmentPartNotesUI/publication.ts @@ -91,7 +91,7 @@ async function setupUISegmentPartNotesPublicationObservers( triggerUpdate({ invalidateSegmentIds: [doc.segmentId, oldDoc.segmentId] }), removed: (doc) => triggerUpdate({ invalidateSegmentIds: [doc.segmentId] }), }), - cache.DeletedPartInstances.find({}).observe({ + cache.PartInstances.find({}).observe({ added: (doc) => triggerUpdate({ invalidateSegmentIds: [doc.segmentId] }), changed: (doc, oldDoc) => triggerUpdate({ invalidateSegmentIds: [doc.segmentId, oldDoc.segmentId] }), @@ -184,13 +184,13 @@ export async function manipulateUISegmentPartNotesPublicationData( interface UpdateNotesData { rundownsCache: Map> parts: Map[]> - deletedPartInstances: Map[]> + partInstances: Map[]> } function compileUpdateNotesData(cache: ReadonlyDeep): UpdateNotesData { return { rundownsCache: normalizeArrayToMap(cache.Rundowns.find({}).fetch(), '_id'), parts: groupByToMap(cache.Parts.find({}).fetch(), 'segmentId'), - deletedPartInstances: groupByToMap(cache.DeletedPartInstances.find({}).fetch(), 'segmentId'), + partInstances: groupByToMap(cache.PartInstances.find({}).fetch(), 'segmentId'), } } @@ -205,7 +205,7 @@ function updateNotesForSegment( segment, getRundownNrcsName(state.rundownsCache.get(segment.rundownId)), state.parts.get(segment._id) ?? [], - state.deletedPartInstances.get(segment._id) ?? [] + state.partInstances.get(segment._id) ?? [] ) // Insert generated notes diff --git a/meteor/server/publications/segmentPartNotesUI/reactiveContentCache.ts b/meteor/server/publications/segmentPartNotesUI/reactiveContentCache.ts index 5c0cb699a3..9e24227616 100644 --- a/meteor/server/publications/segmentPartNotesUI/reactiveContentCache.ts +++ b/meteor/server/publications/segmentPartNotesUI/reactiveContentCache.ts @@ -35,7 +35,7 @@ export const partFieldSpecifier = literal> >({ @@ -44,7 +44,9 @@ export const partInstanceFieldSpecifier = literal< rundownId: 1, orphaned: 1, reset: 1, + invalidReason: 1, // @ts-expect-error Deep not supported + 'part._id': 1, 'part.title': 1, }) @@ -52,7 +54,7 @@ export interface ContentCache { Rundowns: ReactiveCacheCollection> Segments: ReactiveCacheCollection> Parts: ReactiveCacheCollection> - DeletedPartInstances: ReactiveCacheCollection> + PartInstances: ReactiveCacheCollection> } export function createReactiveContentCache(): ContentCache { @@ -60,9 +62,7 @@ export function createReactiveContentCache(): ContentCache { Rundowns: new ReactiveCacheCollection>('rundowns'), Segments: new ReactiveCacheCollection>('segments'), Parts: new ReactiveCacheCollection>('parts'), - DeletedPartInstances: new ReactiveCacheCollection>( - 'deletedPartInstances' - ), + PartInstances: new ReactiveCacheCollection>('partInstances'), } return cache diff --git a/meteor/server/publications/segmentPartNotesUI/rundownContentObserver.ts b/meteor/server/publications/segmentPartNotesUI/rundownContentObserver.ts index 8beb78d1fe..82bb509065 100644 --- a/meteor/server/publications/segmentPartNotesUI/rundownContentObserver.ts +++ b/meteor/server/publications/segmentPartNotesUI/rundownContentObserver.ts @@ -58,8 +58,12 @@ export class RundownContentObserver { } ), PartInstances.observeChanges( - { rundownId: { $in: rundownIds }, reset: { $ne: true }, orphaned: 'deleted' }, - cache.DeletedPartInstances.link(), + { + rundownId: { $in: rundownIds }, + reset: { $ne: true }, + $or: [{ invalidReason: { $exists: true } }, { orphaned: 'deleted' }], + }, + cache.PartInstances.link(), { projection: partInstanceFieldSpecifier } ), ]) diff --git a/packages/blueprints-integration/src/context/onSetAsNextContext.ts b/packages/blueprints-integration/src/context/onSetAsNextContext.ts index 009d7052ad..e770d22bcb 100644 --- a/packages/blueprints-integration/src/context/onSetAsNextContext.ts +++ b/packages/blueprints-integration/src/context/onSetAsNextContext.ts @@ -1,5 +1,6 @@ import { IBlueprintMutatablePart, + IBlueprintMutatablePartInstance, IBlueprintPart, IBlueprintPartInstance, IBlueprintPiece, @@ -73,10 +74,16 @@ export interface IOnSetAsNextContext extends IShowStyleUserContext, IEventContex /** Update a piecesInstance */ updatePieceInstance(pieceInstanceId: string, piece: Partial): Promise - /** Update a partInstance */ + /** + * Update a partInstance + * @param part Which part to update + * @param props Properties of the Part itself + * @param instanceProps Properties of the PartInstance (runtime state) + */ updatePartInstance( part: 'current' | 'next', - props: Partial + props: Partial, + instanceProps?: Partial ): Promise /** diff --git a/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts b/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts index 6f10958eeb..a733c9228e 100644 --- a/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts +++ b/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts @@ -1,6 +1,7 @@ import { ReadonlyDeep } from 'type-fest' import { IBlueprintMutatablePart, + IBlueprintMutatablePartInstance, IBlueprintPart, IBlueprintPartInstance, IBlueprintPiece, @@ -67,10 +68,16 @@ export interface IPartAndPieceActionContext { /** Update a piecesInstance */ updatePieceInstance(pieceInstanceId: string, piece: Partial): Promise - /** Update a partInstance */ + /** + * Update a partInstance + * @param part Which part to update + * @param props Properties of the Part itself + * @param instanceProps Properties of the PartInstance (runtime state) + */ updatePartInstance( part: 'current' | 'next', - props: Partial + props: Partial, + instanceProps?: Partial ): Promise /** Inform core that a take out of the partinstance should be blocked until the specified time */ blockTakeUntil(time: Time | null): Promise diff --git a/packages/blueprints-integration/src/context/syncIngestChangesContext.ts b/packages/blueprints-integration/src/context/syncIngestChangesContext.ts index e6917d443b..1f877ffd7b 100644 --- a/packages/blueprints-integration/src/context/syncIngestChangesContext.ts +++ b/packages/blueprints-integration/src/context/syncIngestChangesContext.ts @@ -1,6 +1,7 @@ import type { IRundownUserContext } from './rundownContext.js' import type { IBlueprintMutatablePart, + IBlueprintMutatablePartInstance, IBlueprintPartInstance, IBlueprintPiece, IBlueprintPieceInstance, @@ -37,8 +38,15 @@ export interface ISyncIngestUpdateToPartInstanceContext extends IRundownUserCont // /** Remove a ActionInstance */ // removeActionInstances(...actionInstanceIds: string[]): string[] - /** Update a partInstance */ - updatePartInstance(props: Partial): IBlueprintPartInstance + /** + * Update a partInstance + * @param props Properties of the Part itself + * @param instanceProps Properties of the PartInstance (runtime state) + */ + updatePartInstance( + props: Partial, + instanceProps?: Partial + ): IBlueprintPartInstance /** Remove the partInstance. This is only valid when `playstatus: 'next'` */ removePartInstance(): void diff --git a/packages/blueprints-integration/src/documents/partInstance.ts b/packages/blueprints-integration/src/documents/partInstance.ts index 9f30ff1bfb..b4509cf9f2 100644 --- a/packages/blueprints-integration/src/documents/partInstance.ts +++ b/packages/blueprints-integration/src/documents/partInstance.ts @@ -1,10 +1,27 @@ import type { Time } from '../common.js' import type { IBlueprintPartDB } from './part.js' +import type { ITranslatableMessage } from '../translations.js' export type PartEndState = unknown +/** + * Properties of a PartInstance that can be modified at runtime by blueprints. + * These are runtime state properties, distinct from the planned Part properties. + */ +export interface IBlueprintMutatablePartInstance { + /** + * If set, this PartInstance exists and is valid as being next, but it cannot be taken in its current state. + * This can be used to block taking a PartInstance that requires user action to resolve. + * This is a runtime validation issue, distinct from the planned `invalidReason` on the Part itself. + */ + invalidReason?: ITranslatableMessage +} + /** The Part instance sent from Core */ -export interface IBlueprintPartInstance { +export interface IBlueprintPartInstance< + TPrivateData = unknown, + TPublicData = unknown, +> extends IBlueprintMutatablePartInstance { _id: string /** The segment ("Title") this line belongs to */ segmentId: string diff --git a/packages/corelib/src/dataModel/PartInstance.ts b/packages/corelib/src/dataModel/PartInstance.ts index 8c40e66e2f..b4b6baaa2a 100644 --- a/packages/corelib/src/dataModel/PartInstance.ts +++ b/packages/corelib/src/dataModel/PartInstance.ts @@ -1,7 +1,7 @@ import { PartEndState, Time } from '@sofie-automation/blueprints-integration' import { PartCalculatedTimings } from '../playout/timings.js' import { PartInstanceId, RundownId, RundownPlaylistActivationId, SegmentId, SegmentPlayoutId } from './Ids.js' -import { DBPart } from './Part.js' +import { DBPart, PartInvalidReason } from './Part.js' export interface DBPartInstance { _id: PartInstanceId @@ -40,6 +40,13 @@ export interface DBPartInstance { /** If taking out of the current part is blocked, this is the time it is blocked until */ blockTakeUntil?: number + + /** + * If set, this PartInstance exists and is valid as being next, but it cannot be taken in its current state. + * This can be used to block taking a PartInstance that requires user action to resolve. + * This is a runtime validation issue, distinct from the planned `invalidReason` on the Part itself. + */ + invalidReason?: PartInvalidReason } export interface PartInstanceTimings { diff --git a/packages/corelib/src/error.ts b/packages/corelib/src/error.ts index 3cbf71c558..38338de605 100644 --- a/packages/corelib/src/error.ts +++ b/packages/corelib/src/error.ts @@ -64,6 +64,7 @@ export enum UserErrorMessage { IdempotencyKeyAlreadyUsed = 48, RateLimitExceeded = 49, SystemSingleStudio = 50, + TakePartInstanceInvalid = 51, } const UserErrorMessagesTranslations: { [key in UserErrorMessage]: string } = { @@ -126,6 +127,7 @@ const UserErrorMessagesTranslations: { [key in UserErrorMessage]: string } = { [UserErrorMessage.IdempotencyKeyAlreadyUsed]: t(`Idempotency-Key is already used`), [UserErrorMessage.RateLimitExceeded]: t(`Rate limit exceeded`), [UserErrorMessage.SystemSingleStudio]: t(`System must have exactly one studio`), + [UserErrorMessage.TakePartInstanceInvalid]: t(`Part has issues and cannot be taken`), } export interface SerializedUserError { diff --git a/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts b/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts index 5cdf53ed78..741691b009 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts @@ -177,7 +177,25 @@ describe('Test blueprint api context', () => { await context.updatePartInstance('next', { title: 'My Part' } as Partial>) expect(mockActionService.updatePartInstance).toHaveBeenCalledTimes(1) - expect(mockActionService.updatePartInstance).toHaveBeenCalledWith('next', { title: 'My Part' }) + expect(mockActionService.updatePartInstance).toHaveBeenCalledWith('next', { title: 'My Part' }, {}) + }) + + test('updatePartInstance with instanceProps', async () => { + const { context, mockActionService } = await getTestee() + + await context.updatePartInstance( + 'next', + { title: 'My Part' } as Partial>, + { invalidReason: { key: 'test' } } + ) + expect(mockActionService.updatePartInstance).toHaveBeenCalledTimes(1) + expect(mockActionService.updatePartInstance).toHaveBeenCalledWith( + 'next', + { title: 'My Part' }, + { + invalidReason: { key: 'test' }, + } + ) }) test('manuallySelected when false', async () => { diff --git a/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts b/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts index 06319381fd..1bf6ea3d1f 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts @@ -192,7 +192,25 @@ describe('Test blueprint api context', () => { await context.updatePartInstance('next', { title: 'My Part' } as Partial>) expect(mockActionService.updatePartInstance).toHaveBeenCalledTimes(1) - expect(mockActionService.updatePartInstance).toHaveBeenCalledWith('next', { title: 'My Part' }) + expect(mockActionService.updatePartInstance).toHaveBeenCalledWith('next', { title: 'My Part' }, {}) + }) + + test('updatePartInstance with instanceProps', async () => { + const { context, mockActionService } = await getTestee() + + await context.updatePartInstance( + 'next', + { title: 'My Part' } as Partial>, + { invalidReason: { key: 'test' } } + ) + expect(mockActionService.updatePartInstance).toHaveBeenCalledTimes(1) + expect(mockActionService.updatePartInstance).toHaveBeenCalledWith( + 'next', + { title: 'My Part' }, + { + invalidReason: { key: 'test' }, + } + ) }) test('isRehearsal when true', async () => { diff --git a/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts b/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts index 1dcd4e99a1..bff1e48c27 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts @@ -163,7 +163,25 @@ describe('Test blueprint api context', () => { await context.updatePartInstance('next', { title: 'My Part' } as Partial>) expect(mockActionService.updatePartInstance).toHaveBeenCalledTimes(1) - expect(mockActionService.updatePartInstance).toHaveBeenCalledWith('next', { title: 'My Part' }) + expect(mockActionService.updatePartInstance).toHaveBeenCalledWith('next', { title: 'My Part' }, {}) + }) + + test('updatePartInstance with instanceProps', async () => { + const { context, mockActionService } = await getTestee() + + await context.updatePartInstance( + 'next', + { title: 'My Part' } as Partial>, + { invalidReason: { key: 'test' } } + ) + expect(mockActionService.updatePartInstance).toHaveBeenCalledTimes(1) + expect(mockActionService.updatePartInstance).toHaveBeenCalledWith( + 'next', + { title: 'My Part' }, + { + invalidReason: { key: 'test' }, + } + ) }) test('isRehearsal when true', async () => { diff --git a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts index 543d37d5df..d75b13260a 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -3,6 +3,7 @@ import { ContextInfo } from './CommonContext.js' import { ShowStyleUserContext } from './ShowStyleUserContext.js' import { IBlueprintMutatablePart, + IBlueprintMutatablePartInstance, IBlueprintPart, IBlueprintPartInstance, IBlueprintPiece, @@ -124,9 +125,10 @@ export class OnSetAsNextContext async updatePartInstance( part: 'current' | 'next', - props: Partial> + props: Partial>, + instanceProps: Partial = {} ): Promise> { - return this.partAndPieceInstanceService.updatePartInstance(part, props) + return this.partAndPieceInstanceService.updatePartInstance(part, props, instanceProps) } async removePieceInstances(part: 'current' | 'next', pieceInstanceIds: string[]): Promise { diff --git a/packages/job-worker/src/blueprints/context/OnTakeContext.ts b/packages/job-worker/src/blueprints/context/OnTakeContext.ts index 4dad8a18d4..5176998796 100644 --- a/packages/job-worker/src/blueprints/context/OnTakeContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTakeContext.ts @@ -1,5 +1,6 @@ import { IBlueprintMutatablePart, + IBlueprintMutatablePartInstance, IBlueprintPart, IBlueprintPartInstance, IBlueprintPiece, @@ -125,9 +126,10 @@ export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContex async updatePartInstance( part: 'current' | 'next', - props: Partial + props: Partial, + instanceProps: Partial = {} ): Promise { - return this.partAndPieceInstanceService.updatePartInstance(part, props) + return this.partAndPieceInstanceService.updatePartInstance(part, props, instanceProps) } async stopPiecesOnLayers(sourceLayerIds: string[], timeOffset?: number): Promise { diff --git a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts index d8289be7d9..fdd7e89a56 100644 --- a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts +++ b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts @@ -13,6 +13,7 @@ import { IBlueprintPieceInstance, OmitId, IBlueprintMutatablePart, + IBlueprintMutatablePartInstance, IBlueprintPartInstance, SomeContent, WithTimeline, @@ -23,6 +24,7 @@ import { convertPieceInstanceToBlueprints, convertPartInstanceToBlueprints, convertPartialBlueprintMutablePartToCore, + convertPartialBlueprintMutatablePartInstanceToCore, } from './lib.js' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { JobContext, JobStudio, ProcessedShowStyleCompound } from '../../jobs/index.js' @@ -166,7 +168,10 @@ export class SyncIngestUpdateToPartInstanceContext return convertPieceInstanceToBlueprints(pieceInstance.pieceInstance) } - updatePartInstance(updatePart: Partial): IBlueprintPartInstance { + updatePartInstance( + updatePart: Partial, + instanceProps: Partial = {} + ): IBlueprintPartInstance { if (!this.partInstance) throw new Error(`PartInstance has been removed`) // for autoNext, the new expectedDuration cannot be shorter than the time a part has been on-air for @@ -184,8 +189,20 @@ export class SyncIngestUpdateToPartInstanceContext updatePart, this.showStyleCompound.blueprintId ) + const playoutUpdatePartInstance = convertPartialBlueprintMutatablePartInstanceToCore( + instanceProps, + this.showStyleCompound.blueprintId + ) + + const partPropsUpdated = this.partInstance.updatePartProps(playoutUpdatePart) + let instancePropsUpdated = false + + if (playoutUpdatePartInstance) { + this.partInstance.setInvalidReason(playoutUpdatePartInstance.invalidReason) + instancePropsUpdated = true + } - if (!this.partInstance.updatePartProps(playoutUpdatePart)) { + if (!partPropsUpdated && !instancePropsUpdated) { throw new Error(`Cannot update PartInstance. Some valid properties must be defined`) } diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index 0510481b19..1100076cfc 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -2,6 +2,7 @@ import { IActionExecutionContext, IDataStoreActionExecutionContext, IBlueprintMutatablePart, + IBlueprintMutatablePartInstance, IBlueprintPart, IBlueprintPartInstance, IBlueprintPiece, @@ -204,9 +205,10 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct async updatePartInstance( part: 'current' | 'next', - props: Partial + props: Partial, + instanceProps: Partial = {} ): Promise { - return this.partAndPieceInstanceService.updatePartInstance(part, props) + return this.partAndPieceInstanceService.updatePartInstance(part, props, instanceProps) } async stopPiecesOnLayers(sourceLayerIds: string[], timeOffset?: number): Promise { diff --git a/packages/job-worker/src/blueprints/context/lib.ts b/packages/job-worker/src/blueprints/context/lib.ts index f16ee424c0..2478a5e462 100644 --- a/packages/job-worker/src/blueprints/context/lib.ts +++ b/packages/job-worker/src/blueprints/context/lib.ts @@ -1,6 +1,6 @@ import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' -import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { DBPart, PartInvalidReason } from '@sofie-automation/corelib/dist/dataModel/Part' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { deserializePieceTimelineObjectsBlob, @@ -35,6 +35,7 @@ import { IBlueprintAdLibPieceDB, IBlueprintConfig, IBlueprintMutatablePart, + IBlueprintMutatablePartInstance, IBlueprintPartDB, IBlueprintPartInstance, IBlueprintPiece, @@ -51,6 +52,7 @@ import { IBlueprintShowStyleVariant, IOutputLayer, ISourceLayer, + NoteSeverity, PieceAbSessionInfo, RundownPlaylistTiming, } from '@sofie-automation/blueprints-integration' @@ -215,6 +217,7 @@ export function convertPartInstanceToBlueprints(partInstance: ReadonlyDeep { + invalidReason?: PartInvalidReason +} + +/** + * Converts a partial IBlueprintMutatablePartInstance and wraps translatable messages with blueprint namespace + */ +export function convertPartialBlueprintMutatablePartInstanceToCore( + instanceProps: Partial, + blueprintId: BlueprintId +): Partial { + const result: Partial = { + ...instanceProps, + invalidReason: undefined, + } + + if (instanceProps.invalidReason) { + result.invalidReason = { + message: wrapTranslatableMessageFromBlueprints(instanceProps.invalidReason, [blueprintId]), + severity: NoteSeverity.ERROR, + } + } else if ('invalidReason' in instanceProps) { + // Explicitly clearing invalidReason + result.invalidReason = undefined + } else { + // Not touching invalidReason at all + delete result.invalidReason + } + + return result +} + export function createBlueprintQuickLoopInfo(playlist: ReadonlyDeep): BlueprintQuickLookInfo | null { const playlistLoopProps = playlist.quickLoop if (!playlistLoopProps) return null diff --git a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts index 8b7a270586..833161642e 100644 --- a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts +++ b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts @@ -3,6 +3,7 @@ import { PlayoutModel } from '../../../playout/model/PlayoutModel.js' import { PlayoutPartInstanceModel } from '../../../playout/model/PlayoutPartInstanceModel.js' import { IBlueprintMutatablePart, + IBlueprintMutatablePartInstance, IBlueprintPart, IBlueprintPartInstance, IBlueprintPiece, @@ -20,6 +21,7 @@ import { convertPartInstanceToBlueprints, convertPartToBlueprints, convertPartialBlueprintMutablePartToCore, + convertPartialBlueprintMutatablePartInstanceToCore, convertPieceInstanceToBlueprints, convertPieceToBlueprints, convertResolvedPieceInstanceToBlueprints, @@ -363,7 +365,8 @@ export class PartAndPieceInstanceActionService { async updatePartInstance( part: 'current' | 'next', - props: Partial + props: Partial, + instanceProps: Partial ): Promise { const partInstance = this.#getPartInstance(part) if (!partInstance) { @@ -371,8 +374,23 @@ export class PartAndPieceInstanceActionService { } const playoutUpdatePart = convertPartialBlueprintMutablePartToCore(props, this.showStyleCompound.blueprintId) + const playoutUpdatePartInstance = convertPartialBlueprintMutatablePartInstanceToCore( + instanceProps, + this.showStyleCompound.blueprintId + ) + + const partPropsUpdated = partInstance.updatePartProps(playoutUpdatePart) + let instancePropsUpdated = false + + if (playoutUpdatePartInstance && 'invalidReason' in playoutUpdatePartInstance) { + if (part !== 'next') { + throw new Error(`Can only set invalidReason on the next PartInstance`) + } + partInstance.setInvalidReason(playoutUpdatePartInstance.invalidReason) + instancePropsUpdated = true + } - if (!partInstance.updatePartProps(playoutUpdatePart)) { + if (!partPropsUpdated && !instancePropsUpdated) { throw new Error('Some valid properties must be defined') } diff --git a/packages/job-worker/src/blueprints/context/services/__tests__/PartAndPieceInstanceActionService.test.ts b/packages/job-worker/src/blueprints/context/services/__tests__/PartAndPieceInstanceActionService.test.ts index 5977eb1449..b796f3adc3 100644 --- a/packages/job-worker/src/blueprints/context/services/__tests__/PartAndPieceInstanceActionService.test.ts +++ b/packages/job-worker/src/blueprints/context/services/__tests__/PartAndPieceInstanceActionService.test.ts @@ -4,6 +4,7 @@ import { IBlueprintPart, IBlueprintPiece, IBlueprintPieceType, + NoteSeverity, PieceLifespan, } from '@sofie-automation/blueprints-integration' import { PlayoutModel } from '../../../../playout/model/PlayoutModel.js' @@ -1839,7 +1840,7 @@ describe('Test blueprint api context', () => { await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { const { service } = await getTestee(jobContext, playoutModel) - await expect(service.updatePartInstance('current', { title: 'new' })).rejects.toThrow( + await expect(service.updatePartInstance('current', { title: 'new' }, {})).rejects.toThrow( 'PartInstance could not be found' ) }) @@ -1848,17 +1849,17 @@ describe('Test blueprint api context', () => { await setPartInstances(jobContext, playlistId, partInstance, undefined) await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { const { service } = await getTestee(jobContext, playoutModel) - await expect(service.updatePartInstance('current', {})).rejects.toThrow( + await expect(service.updatePartInstance('current', {}, {})).rejects.toThrow( 'Some valid properties must be defined' ) await expect( - service.updatePartInstance('current', { _id: 'bad', nope: 'ok' } as any) + service.updatePartInstance('current', { _id: 'bad', nope: 'ok' } as any, {}) ).rejects.toThrow('Some valid properties must be defined') - await expect(service.updatePartInstance('next', { title: 'new' })).rejects.toThrow( + await expect(service.updatePartInstance('next', { title: 'new' }, {})).rejects.toThrow( 'PartInstance could not be found' ) - await service.updatePartInstance('current', { title: 'new' }) + await service.updatePartInstance('current', { title: 'new' }, {}) }) }) test('good', async () => { @@ -1886,7 +1887,7 @@ describe('Test blueprint api context', () => { classes: ['123'], badProperty: 9, // This will be dropped } - const resultPart = await service.updatePartInstance('next', partInstance0Delta) + const resultPart = await service.updatePartInstance('next', partInstance0Delta, {}) const partInstance1 = playoutModel.nextPartInstance! as PlayoutPartInstanceModelImpl expect(partInstance1).toBeTruthy() @@ -1907,6 +1908,54 @@ describe('Test blueprint api context', () => { expect(service.currentPartState).toEqual(ActionPartChange.NONE) }) }) + test('invalidReason on current - throws error', async () => { + const { jobContext, playlistId, rundownId } = await setupMyDefaultRundown() + + const partInstance = (await jobContext.mockCollections.PartInstances.findOne({ + rundownId, + })) as DBPartInstance + expect(partInstance).toBeTruthy() + + // Set a current part instance + await setPartInstances(jobContext, playlistId, partInstance, undefined) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { service } = await getTestee(jobContext, playoutModel) + + await expect( + service.updatePartInstance('current', {}, { invalidReason: { key: 'test' } }) + ).rejects.toThrow('Can only set invalidReason on the next PartInstance') + }) + }) + test('invalidReason on next - sets and clears', async () => { + const { jobContext, playlistId, rundownId } = await setupMyDefaultRundown() + + const partInstance = (await jobContext.mockCollections.PartInstances.findOne({ + rundownId, + })) as DBPartInstance + expect(partInstance).toBeTruthy() + + // Set as next part instance + await setPartInstances(jobContext, playlistId, undefined, partInstance) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { service } = await getTestee(jobContext, playoutModel) + + // Set invalidReason + const invalidReason = { key: 'test_error', args: { foo: 'bar' } } + await service.updatePartInstance('next', {}, { invalidReason }) + const partInstance1 = playoutModel.nextPartInstance! as PlayoutPartInstanceModelImpl + expect(partInstance1.partInstance.invalidReason).toEqual({ + message: { + ...invalidReason, + namespaces: [expect.any(String)], + }, + severity: NoteSeverity.ERROR, + }) + + // Clear invalidReason + await service.updatePartInstance('next', {}, { invalidReason: undefined }) + expect(partInstance1.partInstance.invalidReason).toBeUndefined() + }) + }) }) }) }) diff --git a/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts b/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts index 6cb43e43e1..a247fcb0f0 100644 --- a/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts +++ b/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts @@ -11,6 +11,7 @@ import { IBlueprintMutatablePart, PieceLifespan, Time } from '@sofie-automation/ import { PartCalculatedTimings } from '@sofie-automation/corelib/dist/playout/timings' import { PlayoutPieceInstanceModel } from './PlayoutPieceInstanceModel.js' import { CoreUserEditingDefinition } from '@sofie-automation/corelib/dist/dataModel/UserEditingDefinitions' +import { PartInvalidReason } from '@sofie-automation/corelib/dist/dataModel/Part' /** * Token returned when making a backup copy of a PlayoutPartInstanceModel @@ -56,6 +57,14 @@ export interface PlayoutPartInstanceModel { */ blockTakeUntil(timestamp: Time | null): void + /** + * Set the invalid reason for this PartInstance. + * This indicates a runtime validation issue that prevents taking the part. + * This is distinct from the planned `invalidReason` on the Part itself. + * @param reason The reason the part is invalid, or undefined to clear + */ + setInvalidReason(reason: PartInvalidReason | undefined): void + /** * Get a PieceInstance which belongs to this PartInstance * @param id Id of the PieceInstance diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts index 6294c5c00c..06e662494e 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts @@ -25,7 +25,7 @@ import { PlayoutPieceInstanceModel } from '../PlayoutPieceInstanceModel.js' import { PlayoutPieceInstanceModelImpl } from './PlayoutPieceInstanceModelImpl.js' import { EmptyPieceTimelineObjectsBlob } from '@sofie-automation/corelib/dist/dataModel/Piece' import _ from 'underscore' -import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { DBPart, PartInvalidReason } from '@sofie-automation/corelib/dist/dataModel/Part' import { PlayoutMutatablePartSampleKeys } from '../../../blueprints/context/lib.js' import { QuickLoopService } from '../services/QuickLoopService.js' @@ -217,6 +217,10 @@ export class PlayoutPartInstanceModelImpl implements PlayoutPartInstanceModel { this.#compareAndSetPartInstanceValue('blockTakeUntil', timestamp ?? undefined) } + setInvalidReason(reason: PartInvalidReason | undefined): void { + this.#compareAndSetPartInstanceValue('invalidReason', reason) + } + getPieceInstance(id: PieceInstanceId): PlayoutPieceInstanceModel | undefined { return this.pieceInstancesImpl.get(id) ?? undefined } diff --git a/packages/job-worker/src/playout/take.ts b/packages/job-worker/src/playout/take.ts index 18c1f24ccd..8e87898bc5 100644 --- a/packages/job-worker/src/playout/take.ts +++ b/packages/job-worker/src/playout/take.ts @@ -227,6 +227,10 @@ export async function performTakeToNextedPart( if (!takeRundown) throw new Error(`takeRundown: takeRundown not found! ("${takePartInstance.partInstance.rundownId}")`) + if (takePartInstance.partInstance.invalidReason) { + throw UserError.create(UserErrorMessage.TakePartInstanceInvalid) + } + const showStyle = await pShowStyle const blueprint = await context.getShowStyleBlueprint(showStyle._id) diff --git a/packages/webui/src/client/lib/partInstanceUtil.ts b/packages/webui/src/client/lib/partInstanceUtil.ts new file mode 100644 index 0000000000..c37dec3d51 --- /dev/null +++ b/packages/webui/src/client/lib/partInstanceUtil.ts @@ -0,0 +1,67 @@ +import { DBPart, PartInvalidReason } from '@sofie-automation/corelib/dist/dataModel/Part' + +/** + * Minimal interface for a PartInstance containing the properties needed for invalidReason checks. + */ +export interface PartInstanceLike { + part: Pick + invalidReason?: PartInvalidReason +} + +export interface PartInvalidReasonExt extends PartInvalidReason { + /** + * If this invalid reason came from the instance (playout) rather than the part (ingest) + */ + isInstanceInvalid: boolean +} + +/** + * Get the effective invalidReason for a PartInstance. + * + * If the Part has a planned invalidReason (from ingest), it takes precedence. + * Otherwise, returns the runtime invalidReason from the PartInstance (from playout). + * + * This distinction matters because: + * - Part.invalidReason is planned/static (set during ingest, shouldn't create real PartInstance) + * - PartInstance.invalidReason is runtime/dynamic (set during playout, can be fixed) + * + * @param partInstance The PartInstance object + * @returns The effective invalidReason to display, or undefined if none + */ +export function getEffectiveInvalidReason(partInstance: PartInstanceLike): PartInvalidReasonExt | undefined { + // Planned invalidReason (from Part/ingest) takes precedence + // It shouldn't be possible to create a real PartInstance of an invalid Part + if (partInstance.part.invalidReason) { + return { + ...partInstance.part.invalidReason, + isInstanceInvalid: false, + } + } + + // Runtime invalidReason (from PartInstance/playout) + if (partInstance.invalidReason) { + return { + ...partInstance.invalidReason, + isInstanceInvalid: true, + } + } + + return undefined +} + +/** + * Check if the effective state is "invalid" for a PartInstance. + * + * A PartInstance is considered invalid if either: + * - The Part has `invalid: true` (planned invalid, may not have an invalidReason) + * - The PartInstance has a runtime invalidReason (runtime invalid) + * + * Note: This is separate from getEffectiveInvalidReason because part.invalid can be true + * without an invalidReason being set (legacy behavior). + * + * @param partInstance The PartInstance object + * @returns true if the part should be shown as invalid + */ +export function isPartInstanceInvalid(partInstance: PartInstanceLike): boolean { + return !!partInstance.part.invalid || !!partInstance.invalidReason +} diff --git a/packages/webui/src/client/styles/rundownView.scss b/packages/webui/src/client/styles/rundownView.scss index ccc3e9d686..f2dfb35bbd 100644 --- a/packages/webui/src/client/styles/rundownView.scss +++ b/packages/webui/src/client/styles/rundownView.scss @@ -208,13 +208,8 @@ body.no-overflow { left: 0; bottom: 0; right: 0; - background: linear-gradient( - -45deg, - $color-status-fatal 33%, - transparent 33%, - transparent 66%, - $color-status-fatal 66% - ), + background: + linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%); @@ -1104,18 +1099,38 @@ svg.icon { } .segment-timeline__part { .segment-timeline__part__invalid-cover { - background-image: repeating-linear-gradient( + mix-blend-mode: color-burn; + background-image: + repeating-linear-gradient( 45deg, var(--invalid-reason-color-transparent) 0%, - var(--invalid-reason-color-transparent) 4px, - var(--invalid-reason-color-opaque) 4px, + var(--invalid-reason-color-transparent) 5px, + var(--invalid-reason-color-opaque) 5px, var(--invalid-reason-color-opaque) 8px ), repeating-linear-gradient( -45deg, var(--invalid-reason-color-transparent) 0%, - var(--invalid-reason-color-transparent) 4px, - var(--invalid-reason-color-opaque) 4px, + var(--invalid-reason-color-transparent) 5px, + var(--invalid-reason-color-opaque) 5px, + var(--invalid-reason-color-opaque) 8px + ) !important; + } + .segment-timeline__part__invalid-part-instance-cover { + mix-blend-mode: color-burn; + background-image: + repeating-linear-gradient( + 45deg, + var(--invalid-reason-color-transparent) 0%, + var(--invalid-reason-color-transparent) 6px, + var(--invalid-reason-color-opaque) 6px, + var(--invalid-reason-color-opaque) 8px + ), + repeating-linear-gradient( + -45deg, + var(--invalid-reason-color-transparent) 0%, + var(--invalid-reason-color-transparent) 6px, + var(--invalid-reason-color-opaque) 6px, var(--invalid-reason-color-opaque) 8px ) !important; } @@ -1386,7 +1401,8 @@ svg.icon { left: 2px; right: 2px; z-index: 3; - background: repeating-linear-gradient( + background: + repeating-linear-gradient( 45deg, var(--invalid-reason-color-opaque) 0, var(--invalid-reason-color-opaque) 5px, @@ -1568,19 +1584,47 @@ svg.icon { right: 1px; z-index: 10; pointer-events: all; - background-image: repeating-linear-gradient( + mix-blend-mode: color-burn; + background-image: + repeating-linear-gradient( 45deg, var(--invalid-reason-color-transparent) 0%, var(--invalid-reason-color-transparent) 5px, var(--invalid-reason-color-opaque) 5px, - var(--invalid-reason-color-opaque) 10px + var(--invalid-reason-color-opaque) 8px ), repeating-linear-gradient( -45deg, var(--invalid-reason-color-transparent) 0%, var(--invalid-reason-color-transparent) 5px, var(--invalid-reason-color-opaque) 5px, - var(--invalid-reason-color-opaque) 10px + var(--invalid-reason-color-opaque) 8px + ); + } + + .segment-timeline__part__invalid-part-instance-cover { + position: absolute; + top: 0; + left: 1px; + bottom: 0; + right: 1px; + z-index: 10; + pointer-events: all; + mix-blend-mode: color-burn; + background-image: + repeating-linear-gradient( + 45deg, + var(--invalid-reason-color-transparent) 0%, + var(--invalid-reason-color-transparent) 6px, + var(--invalid-reason-color-opaque) 6px, + var(--invalid-reason-color-opaque) 8px + ), + repeating-linear-gradient( + -45deg, + var(--invalid-reason-color-transparent) 0%, + var(--invalid-reason-color-transparent) 6px, + var(--invalid-reason-color-opaque) 6px, + var(--invalid-reason-color-opaque) 8px ); } diff --git a/packages/webui/src/client/ui/SegmentList/LinePart.tsx b/packages/webui/src/client/ui/SegmentList/LinePart.tsx index 0c89801e0d..67c79f56f9 100644 --- a/packages/webui/src/client/ui/SegmentList/LinePart.tsx +++ b/packages/webui/src/client/ui/SegmentList/LinePart.tsx @@ -17,6 +17,7 @@ import { LinePartTitle } from './LinePartTitle.js' import { TimingDataResolution, TimingTickResolution, useTiming } from '../RundownView/RundownTiming/withTiming.js' import { RundownTimingContext, getPartInstanceTimingId } from '../../lib/rundownTiming.js' import { LoopingIcon } from '../../lib/ui/icons/looping.js' +import { isPartInstanceInvalid } from '../../lib/partInstanceUtil.js' interface IProps { segment: SegmentUi @@ -80,6 +81,9 @@ export function LinePart({ const isInsideQuickLoop = (timingDurations.partsInQuickLoop || {})[timingId] const isOutsideActiveQuickLoop = isPlaylistLooping && !isInsideQuickLoop && !isNextPart && !hasAlreadyPlayed + // Check for both planned and runtime invalidReason + const isInvalid = isPartInstanceInvalid(part.instance) + const getPartContext = useCallback(() => { const partElement = document.querySelector('#' + SegmentTimelinePartElementId + part.instance._id) const partDocumentOffset = getElementDocumentOffset(partElement) @@ -137,7 +141,7 @@ export function LinePart({ 'segment-opl__part--has-played': hasAlreadyPlayed && (!isPlaylistLooping || !isInsideQuickLoop), 'segment-opl__part--outside-quickloop': isOutsideActiveQuickLoop, 'segment-opl__part--quickloop-start': isQuickLoopStart, - 'segment-opl__part--invalid': part.instance.part.invalid, + 'segment-opl__part--invalid': isInvalid, 'segment-opl__part--timing-sibling': isPreceededByTimingGroupSibling, }), //@ts-expect-error A Data attribute is perfectly fine diff --git a/packages/webui/src/client/ui/SegmentList/LinePartTimeline.tsx b/packages/webui/src/client/ui/SegmentList/LinePartTimeline.tsx index 57d534d855..2b09a93ef3 100644 --- a/packages/webui/src/client/ui/SegmentList/LinePartTimeline.tsx +++ b/packages/webui/src/client/ui/SegmentList/LinePartTimeline.tsx @@ -16,6 +16,7 @@ import { InvalidPartCover } from '../SegmentTimeline/Parts/InvalidPartCover.js' import { getPartInstanceTimingId } from '../../lib/rundownTiming.js' import { QuickLoopEnd } from './QuickLoopEnd.js' import { getShowHiddenSourceLayers } from '../../lib/localStorage.js' +import { getEffectiveInvalidReason, isPartInstanceInvalid } from '../../lib/partInstanceUtil.js' const TIMELINE_DEFAULT_BASE = 30 * 1000 @@ -100,7 +101,9 @@ export const LinePartTimeline: React.FC = function LinePartTimeline({ const willAutoNextIntoThisPart = isNext ? currentPartWillAutonext : part.willProbablyAutoNext const willAutoNextOut = !!part.instance.part.autoNext - const isInvalid = !!part.instance.part.invalid + // Check for both planned and runtime invalidReason + const effectiveInvalidReason = getEffectiveInvalidReason(part.instance) + const isInvalid = isPartInstanceInvalid(part.instance) const loop = mainPiece?.instance.piece.content?.loop const endsInFreeze = !part.instance.part.autoNext && !loop && !!mainPiece?.instance.piece.content?.sourceDuration @@ -140,8 +143,8 @@ export const LinePartTimeline: React.FC = function LinePartTimeline({ )} )} - {part.instance.part.invalid && !part.instance.part.gap && ( - + {isInvalid && !part.instance.part.gap && ( + )} {!isLive && !isInvalid && ( diff --git a/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboard.scss b/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboard.scss index fc01aa770f..e18edb3a41 100644 --- a/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboard.scss +++ b/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboard.scss @@ -606,20 +606,49 @@ $break-width: 35rem; right: 1px; z-index: 1; pointer-events: all; + mix-blend-mode: color-burn; + opacity: 100%; background-image: repeating-linear-gradient( 45deg, var(--invalid-reason-color-transparent) 0%, var(--invalid-reason-color-transparent) 5px, var(--invalid-reason-color-opaque) 5px, - var(--invalid-reason-color-opaque) 10px + var(--invalid-reason-color-opaque) 8px ), repeating-linear-gradient( -45deg, var(--invalid-reason-color-transparent) 0%, var(--invalid-reason-color-transparent) 5px, var(--invalid-reason-color-opaque) 5px, - var(--invalid-reason-color-opaque) 10px + var(--invalid-reason-color-opaque) 8px + ); + + } + > .segment-storyboard__part__invalid-part-instance-cover { + position: absolute; + top: 0; + left: 1px; + bottom: 0; + right: 1px; + z-index: 1; + pointer-events: all; + mix-blend-mode: color-burn; + opacity: 100%; + background-image: + repeating-linear-gradient( + 45deg, + var(--invalid-reason-color-transparent) 0%, + var(--invalid-reason-color-transparent) 6px, + var(--invalid-reason-color-opaque) 6px, + var(--invalid-reason-color-opaque) 8px + ), + repeating-linear-gradient( + -45deg, + var(--invalid-reason-color-transparent) 0%, + var(--invalid-reason-color-transparent) 6px, + var(--invalid-reason-color-opaque) 6px, + var(--invalid-reason-color-opaque) 8px ); } diff --git a/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPart.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPart.tsx index 3a3b4202a8..eb78406700 100644 --- a/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPart.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPart.tsx @@ -23,6 +23,7 @@ import { AutoNextStatus } from '../RundownView/RundownTiming/AutoNextStatus.js' import { RundownTimingContext, getPartInstanceTimingId } from '../../lib/rundownTiming.js' import { TimingDataResolution, TimingTickResolution, useTiming } from '../RundownView/RundownTiming/withTiming.js' import { LoopingIcon } from '../../lib/ui/icons/looping.js' +import { getEffectiveInvalidReason, isPartInstanceInvalid } from '../../lib/partInstanceUtil.js' interface IProps { className?: string @@ -124,7 +125,9 @@ export function StoryboardPart({ useRundownViewEventBusListener(RundownViewEvents.HIGHLIGHT, onHighlight) - const isInvalid = part.instance.part.invalid + // Get effective invalidReason: planned (Part) takes precedence over runtime (PartInstance) + const effectiveInvalidReason = getEffectiveInvalidReason(part.instance) + const isInvalid = isPartInstanceInvalid(part.instance) const isFloated = part.instance.part.floated const isInsideQuickLoop = timingDurations.partsInQuickLoop?.[getPartInstanceTimingId(part.instance)] ?? false const isOutsideActiveQuickLoop = !isInsideQuickLoop && isPlaylistLooping && !isNextPart @@ -140,7 +143,7 @@ export function StoryboardPart({ 'invert-flash': highlight, 'segment-storyboard__part--next': isNextPart, 'segment-storyboard__part--live': isLivePart, - 'segment-storyboard__part--invalid': part.instance.part.invalid, + 'segment-storyboard__part--invalid': isInvalid, 'segment-storyboard__part--outside-quickloop': isOutsideActiveQuickLoop, 'segment-storyboard__part--quickloop-start': isQuickLoopStart, 'segment-storyboard__part--quickloop-end': isQuickLoopEnd, @@ -177,7 +180,14 @@ export function StoryboardPart({ )} {isInvalid ? ( - + ) : null} {isFloated ?
: null}
{part.instance.part.title}
@@ -185,7 +195,7 @@ export function StoryboardPart({
): JSX.Element { +export function InvalidPartCover({ className, invalidReason }: Readonly): JSX.Element { const element = React.createRef() const previewContext = useContext(PreviewPopUpContext) @@ -19,11 +22,11 @@ export function InvalidPartCover({ className, part }: Readonly): JSX.Ele return } - if (part.invalidReason?.message && !previewSession.current) { + if (invalidReason?.message && !previewSession.current) { previewSession.current = previewContext.requestPreview(e.target as HTMLDivElement, [ { type: 'warning', - content: part.invalidReason?.message, + content: invalidReason.message, }, ]) } diff --git a/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index a3e12c01d0..c22b648390 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -41,6 +41,7 @@ import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' import { LIVE_LINE_TIME_PADDING } from '../Constants.js' import * as RundownResolver from '../../../lib/RundownResolver.js' import { Events as MOSEvents } from '../../../lib/data/mos/plugin-support.js' +import { getEffectiveInvalidReason, isPartInstanceInvalid } from '../../../lib/partInstanceUtil.js' export const SegmentTimelineLineElementId = 'rundown__segment__line__' export const SegmentTimelinePartElementId = 'rundown__segment__part__' @@ -662,6 +663,7 @@ export class SegmentTimelinePartClass extends React.Component )} {this.renderTimelineOutputGroups(this.props.part)} - {innerPart.invalid ? ( - + {isInvalid ? ( + ) : null} {innerPart.floated ?
: null} {this.props.playlist.nextTimeOffset && @@ -746,7 +760,7 @@ export class SegmentTimelinePartClass extends React.Component