diff --git a/packages/blueprints/src/base/showstyle/helpers/graphics.ts b/packages/blueprints/src/base/showstyle/helpers/graphics.ts index 116cef5f..ef5092ba 100644 --- a/packages/blueprints/src/base/showstyle/helpers/graphics.ts +++ b/packages/blueprints/src/base/showstyle/helpers/graphics.ts @@ -13,6 +13,7 @@ import { getOutputLayerForSourceLayer, SourceLayer } from '../applyconfig/layers import { getClipPlayerInput } from './clips.js' import { createVisionMixerObjects } from './visionMixer.js' import { TimelineBlueprintExt } from '../../studio/customTypes.js' +import { createPieceUserEditOperations } from './userEditOperations.js' export interface GraphicsResult { pieces: IBlueprintPiece[] @@ -85,6 +86,7 @@ function parseGraphic(config: StudioConfig, object: GraphicObject | SteppedGraph lifespan, sourceLayerId: sourceLayer, outputLayerId: getOutputLayerForSourceLayer(sourceLayer), + userEditOperations: createPieceUserEditOperations(), content: { timelineObjects: getGraphicTlObject(config, object, false), diff --git a/packages/blueprints/src/base/showstyle/helpers/script.ts b/packages/blueprints/src/base/showstyle/helpers/script.ts index 7367286e..395bf714 100644 --- a/packages/blueprints/src/base/showstyle/helpers/script.ts +++ b/packages/blueprints/src/base/showstyle/helpers/script.ts @@ -7,6 +7,7 @@ import { } from '@sofie-automation/blueprints-integration' import { literal } from '../../../common/util.js' import { getOutputLayerForSourceLayer, SourceLayer } from '../applyconfig/layers.js' +import { createPieceUserEditOperations } from './userEditOperations.js' function getFirstWords(input: string): string { const firstWordsMatch = (input + '').match(/^([\S]*[^\n]){1,3}/) @@ -40,6 +41,7 @@ export function createScriptPiece(script: string | undefined, extId: string): IB outputLayerId: getOutputLayerForSourceLayer(SourceLayer.Script), pieceType: IBlueprintPieceType.InTransition, lifespan: PieceLifespan.WithinPart, + userEditOperations: createPieceUserEditOperations(), privateData: { source: 'script', }, diff --git a/packages/blueprints/src/base/showstyle/helpers/userEditOperations.ts b/packages/blueprints/src/base/showstyle/helpers/userEditOperations.ts new file mode 100644 index 00000000..90c18dde --- /dev/null +++ b/packages/blueprints/src/base/showstyle/helpers/userEditOperations.ts @@ -0,0 +1,18 @@ +import { + DefaultUserOperationsTypes, + UserEditingType, + UserEditingDefinition, +} from '@sofie-automation/blueprints-integration' + +/** + * Creates standard user edit operations for pieces that support retiming + * @returns Array of user edit operations including retime functionality + */ +export function createPieceUserEditOperations(): UserEditingDefinition[] { + return [ + { + type: UserEditingType.SOFIE, + id: DefaultUserOperationsTypes.RETIME_PIECE, + }, + ] +} diff --git a/packages/blueprints/src/base/showstyle/part-adapters/camera.ts b/packages/blueprints/src/base/showstyle/part-adapters/camera.ts index db059fcc..274e3f87 100644 --- a/packages/blueprints/src/base/showstyle/part-adapters/camera.ts +++ b/packages/blueprints/src/base/showstyle/part-adapters/camera.ts @@ -13,6 +13,7 @@ import { getSourceInfoFromRaw } from '../helpers/sources.js' import { createVisionMixerObjects } from '../helpers/visionMixer.js' import { getOutputLayerForSourceLayer, SourceLayer } from '../applyconfig/layers.js' import { parseConfig } from '../helpers/config.js' +import { createPieceUserEditOperations } from '../helpers/userEditOperations.js' export function generateCameraPart(context: PartContext, part: PartProps): BlueprintResultPart { const config = parseConfig(context).studio @@ -29,6 +30,7 @@ export function generateCameraPart(context: PartContext, part: PartProps) lifespan: PieceLifespan.WithinPart, sourceLayerId: SourceLayer.DVE, outputLayerId: getOutputLayerForSourceLayer(SourceLayer.DVE), + userEditOperations: createPieceUserEditOperations(), prerollDuration: SUPER_SOURCE_LATENCY, content: { ...dveLayoutToContent(config, { boxes }, part.payload.inputs), @@ -206,6 +208,7 @@ export function generateDVEPart(context: PartContext, part: PartProps) lifespan: PieceLifespan.OutOnSegmentEnd, sourceLayerId: SourceLayer.DVE_RETAIN, outputLayerId: getOutputLayerForSourceLayer(SourceLayer.DVE_RETAIN), + userEditOperations: createPieceUserEditOperations(), prerollDuration: SUPER_SOURCE_LATENCY, content: { ...dveLayoutToContent(config, { boxes }, part.payload.inputs), diff --git a/packages/blueprints/src/base/showstyle/part-adapters/remote.ts b/packages/blueprints/src/base/showstyle/part-adapters/remote.ts index 1f6164da..ef748417 100644 --- a/packages/blueprints/src/base/showstyle/part-adapters/remote.ts +++ b/packages/blueprints/src/base/showstyle/part-adapters/remote.ts @@ -11,6 +11,7 @@ import { createVisionMixerObjects } from '../helpers/visionMixer.js' import { getOutputLayerForSourceLayer, SourceLayer } from '../applyconfig/layers.js' import { ObjectType } from '../../../common/definitions/objects.js' import { parseConfig } from '../helpers/config.js' +import { createPieceUserEditOperations } from '../helpers/userEditOperations.js' export function generateRemotePart(context: PartContext, part: PartProps): BlueprintResultPart { const config = parseConfig(context).studio @@ -27,6 +28,7 @@ export function generateRemotePart(context: PartContext, part: PartProps): BlueprintResultPart { const config = parseConfig(context).studio @@ -28,6 +29,7 @@ export function generateOpenerPart(context: PartContext, part: PartProps): BlueprintResultPart { const config = parseConfig(context).studio @@ -30,6 +31,7 @@ export function generateVOPart(context: PartContext, part: PartProps): lifespan: PieceLifespan.WithinPart, sourceLayerId: SourceLayer.VO, outputLayerId: getOutputLayerForSourceLayer(SourceLayer.VO), + userEditOperations: createPieceUserEditOperations(), content: { fileName: part.payload.clipProps.fileName, diff --git a/packages/blueprints/src/base/showstyle/part-adapters/vt.ts b/packages/blueprints/src/base/showstyle/part-adapters/vt.ts index f669bdb0..2005a190 100644 --- a/packages/blueprints/src/base/showstyle/part-adapters/vt.ts +++ b/packages/blueprints/src/base/showstyle/part-adapters/vt.ts @@ -18,6 +18,7 @@ import { createVisionMixerObjects } from '../helpers/visionMixer.js' import { getOutputLayerForSourceLayer, SourceLayer } from '../applyconfig/layers.js' import { TimelineBlueprintExt } from '../../studio/customTypes.js' import { parseConfig } from '../helpers/config.js' +import { createPieceUserEditOperations } from '../helpers/userEditOperations.js' export function generateVTPart(context: PartContext, part: PartProps): BlueprintResultPart { const config = parseConfig(context).studio @@ -34,6 +35,7 @@ export function generateVTPart(context: PartContext, part: PartProps): lifespan: PieceLifespan.WithinPart, sourceLayerId: SourceLayer.VT, outputLayerId: getOutputLayerForSourceLayer(SourceLayer.VT), + userEditOperations: createPieceUserEditOperations(), abSessions: [ { sessionName: part.payload.externalId, diff --git a/packages/blueprints/src/base/studio/applyConfig/index.ts b/packages/blueprints/src/base/studio/applyConfig/index.ts index 569c9997..2484ec76 100644 --- a/packages/blueprints/src/base/studio/applyConfig/index.ts +++ b/packages/blueprints/src/base/studio/applyConfig/index.ts @@ -39,6 +39,7 @@ export function applyConfig( allowHold: false, allowPieceDirectPlay: false, enableBuckets: false, + enableUserEdits: true, enableEvaluationForm: true, }, } @@ -75,8 +76,8 @@ function generatePlayoutDevices(config: BlueprintConfig): BlueprintResultApplySt options: literal({ type: TSR.DeviceType.CASPARCG, options: { - host: config.studio.casparcg.host, - port: config.studio.casparcg.port || 5250, + host: config.studio.casparcg?.host || '', + port: config.studio.casparcg?.port || 5250, }, }), }, diff --git a/packages/blueprints/src/base/studio/userEditOperations/processIngestData.ts b/packages/blueprints/src/base/studio/userEditOperations/processIngestData.ts index 8c71da6f..a6a08528 100644 --- a/packages/blueprints/src/base/studio/userEditOperations/processIngestData.ts +++ b/packages/blueprints/src/base/studio/userEditOperations/processIngestData.ts @@ -1,5 +1,6 @@ import { DefaultUserOperationEditProperties, + DefaultUserOperationRetimePiece, DefaultUserOperations, DefaultUserOperationsTypes, IngestChangeType, @@ -106,11 +107,142 @@ async function applyUserOperation( case DefaultUserOperationsTypes.UPDATE_PROPS: processUpdateProps(context, mutableIngestRundown, changes.operationTarget, changes) break + // Handle drag and drop retime operations: + case DefaultUserOperationsTypes.RETIME_PIECE: + processRetimePiece(context, mutableIngestRundown, changes.operationTarget, changes) + break default: context.logWarning(`Unknown operation: ${changes.operation.id}`) } } +/** + * Process piece retiming operations from the UI. + * + * This function handles drag-and-drop retime operations from the Sofie UI. + * + * It updates the piece timing in the ingest data structure based on the new inPoint provided and + * locks the segment/part to prevent NRCS updates. + * + * @param context - The ingest data processing context + * @param mutableIngestRundown - The mutable rundown being processed + * @param operationTarget - Target containing partExternalId and pieceExternalId + * @param changes - The user operation change containing timing information + */ +function processRetimePiece( + context: IProcessIngestDataContext, + mutableIngestRundown: BlueprintMutableIngestRundown, + operationTarget: UserOperationTarget, + changes: UserOperationChange +) { + // Extract piece timing information from the operation + const operation = changes.operation as DefaultUserOperationRetimePiece + + context.logDebug('Processing piece retime operation: ' + JSON.stringify(changes, null, 2)) + + // Ensure we have the required identifiers + if (!operationTarget.partExternalId || !operationTarget.pieceExternalId) { + context.logError('Retime piece operation missing part or piece external ID') + return + } + + // Find the part containing the piece + const partAndSegment = mutableIngestRundown.findPartAndSegment(operationTarget.partExternalId) + const { part, segment } = partAndSegment || {} + + if (!part?.payload) { + context.logError(`Part not found for retime operation: ${operationTarget.partExternalId}`) + return + } + + if (!segment) { + context.logError(`Segment not found for retime operation: ${operationTarget.segmentExternalId}`) + return + } + + // Parse the part payload to access pieces + const partPayload: any = part.payload + if (!partPayload.pieces || !Array.isArray(partPayload.pieces)) { + context.logError(`Part has no pieces array: ${operationTarget.partExternalId}`) + return + } + + context.logDebug('Original partPayload: ' + JSON.stringify(partPayload, null, 2)) + + // Find the specific piece to retime + const pieceIndex = partPayload.pieces.findIndex((piece: any) => piece.id === operationTarget.pieceExternalId) + if (pieceIndex === -1) { + context.logError(`Piece not found for retime: ${operationTarget.pieceExternalId}`) + return + } + + const piece = partPayload.pieces[pieceIndex] + const originalTime = piece.objectTime || 0 + + // Extract new timing from operation payload + const payload = operation.payload || {} + const newTime = payload.inPoint / 1000 // Convert milliseconds to seconds for comparison + + // Example payload structure for retime operations: + // "payload": { + // "segmentExternalId": "d26d22e2-4f4e-4d31-a0ca-de6f37ff9b3f", + // "partExternalId": "42077925-8d15-4a5d-abeb-a445ccee2984", + // "inPoint": 1061.4136732329084 + // } + + // Handle different retime operation types + if (payload.inPoint === undefined) { + context.logError('Retime piece operation missing inPoint in payload') + return + } + + // Check if there are any unknown values in the payload that need to be handled apart from inPoint, segmentExternalId, partExternalId + const knownKeys = ['inPoint', 'segmentExternalId', 'partExternalId'] + const unknownKeys = Object.keys(payload).filter((key) => !knownKeys.includes(key)) + if (unknownKeys.length > 0) { + context.logWarning(`Retime piece operation has unknown keys in payload: ${unknownKeys.join(', ')}`) + } + + // Check if there are actually changes to apply + const timeDifference = Math.abs(newTime - originalTime) + if (timeDifference < 0.005) { + // Less than 5ms difference - consider it unchanged + context.logDebug( + `No significant timing changes needed for piece ${operationTarget.pieceExternalId} (${timeDifference}s difference)` + ) + return + } + + // Apply the retime changes to the ingest data structure + // Note: Ingest pieces use objectTime, not enable.start + const updatedPiece = { + ...piece, + objectTime: newTime, + } + + // Lock segment to prevent NRCS updates: + segment.setUserEditState(BlueprintUserOperationTypes.LOCK_SEGMENT_NRCS_UPDATES, true) + + // Mark both segment and part as user-edited + segment.setUserEditState(BlueprintUserOperationTypes.USER_EDITED, true) + part.setUserEditState(BlueprintUserOperationTypes.USER_EDITED, true) + + // Update the piece in the part payload + partPayload.pieces[pieceIndex] = updatedPiece + + // Mark the part as modified using replacePayload + part.replacePayload(partPayload) + + // Store the retime operation as a user edit state + // Each retime gets a unique key to ensure state changes trigger persistence + // This is a horrible hack. + // segment.setUserEditState(pieceRetimeKey, true) + const pieceRetimeKey = `${operation.id}_${operationTarget.pieceExternalId}_${Date.now()}` + part.setUserEditState(pieceRetimeKey, true) + + context.logDebug(`Marked segment and part as user-edited and created unique retime state: ${pieceRetimeKey}`) +} + function processUpdateProps( context: IProcessIngestDataContext, mutableIngestRundown: BlueprintMutableIngestRundown, @@ -218,6 +350,7 @@ function updatePieceProps( const lifespan = operation.payload.globalProperties['lifespan'] context.logDebug('Update piece ' + operationTarget.pieceExternalId + ': ' + lifespan) + // TODO: Do we actually update anything here? We just seem to log. } function changeSource(