1use myko::prelude::*;
2use std::{
3 collections::{HashMap, HashSet},
4 sync::Arc,
5};
6
7use myko::command::{CommandContext, CommandError, CommandHandler};
8use myko::entities::client::ClientStatus;
9use myko_macros::myko_command;
10
11use crate::cam_pref::{CamPref, CamPrefId};
12use crate::camera_home::{CameraHome, CameraHomeId};
13use crate::collection::{
14 resolve_collection_path, Collection, CollectionId, CollectionQuery, GetCollectionsByIds,
15 GetCollectionsByQuery,
16};
17use crate::collection_membership::{
18 CollectionMembership, CollectionMembershipId, CollectionMembershipQuery,
19 GetCollectionMembershipsByIds, GetCollectionMembershipsByQuery,
20};
21use crate::control_lock::{ControlLock, ControlLockId, GetControlLockById};
22use crate::frame_capture::FrameCaptureRequestId;
23use crate::frame_capture_status::FrameCaptureStatusId;
24use crate::frame_capture_target::{
25 FrameCaptureTargetSummaryId, GetFrameCaptureTargetSummarysByIds,
26};
27use crate::legacy_capture_run::{
28 CaptureRunQuery as LegacyCaptureRunQuery, GetCaptureRunsByQuery as GetLegacyCaptureRunsByQuery,
29};
30use crate::legacy_shot_list::{
31 GetShotListsByQuery as GetLegacyShotListsByQuery, ShotListQuery as LegacyShotListQuery,
32};
33use crate::recording_job::{
34 CreativeStatus, DeliveryStatus, GetRecordingJobsByIds, GetRecordingJobsByQuery, RecordingJob,
35 RecordingJobId, RecordingJobQuery, Take, TakeState,
36};
37use crate::recording_job_request::{RecordingJobRequest, RecordingJobRequestId};
38use crate::recording_job_status::{
39 GetRecordingJobStatussByIds, RecordingJobStatus, RecordingJobStatusId,
40};
41use crate::recording_plan::{RecordingJobAction, RecordingJobPhase, ShotEntryPlan};
42use crate::recording_request::{RecordingRequest, RecordingRequestId};
43use crate::recording_status::{RecordingState, RecordingStatus, RecordingStatusId};
44use crate::shot::{
45 effective_library_id, GetShotsByIds, GetShotsByQuery, Shot, ShotDiscovery, ShotId, ShotKind,
46 ShotQuery, DEFAULT_SHOT_HOLD_DURATION_MS, DEFAULT_SHOT_LIBRARY_ID,
47 DEFAULT_SHOT_ROTATION_SPEED_DEG_S, DEFAULT_SHOT_TRANSLATION_SPEED_CM_S,
48};
49use crate::stream::{Stream, StreamId};
50use crate::timeline::{
51 GetTimelinesByIds, GetTimelinesByQuery, ShotDirection, ShotEntry, ShotEntryMode, Timeline,
52 TimelineId, TimelineQuery,
53};
54use crate::viewer::{GetViewerById, GetViewersByQuery, Viewer, ViewerId, ViewerQuery};
55use crate::StoredCaptureContext;
56
57const MAX_OTIO_IMPORT_BYTES: usize = 10 * 1024 * 1024;
58const MAX_DISCOVERED_SHOTS: usize = 10_000;
59const MAX_COLLECTION_DEPTH: usize = 16;
60const MAX_COLLECTION_NAME_CHARS: usize = 128;
61
62fn require_uuid_v7_collection_ids() -> bool {
63 std::env::var("PULSE_PIXELSTREAM_REQUIRE_UUID_V7_COLLECTION_IDS").is_ok_and(|value| {
64 matches!(
65 value.trim().to_ascii_lowercase().as_str(),
66 "1" | "true" | "yes"
67 )
68 })
69}
70
71fn normalized_timeline_name(name: &str) -> String {
72 name.trim().to_lowercase()
73}
74
75fn normalized_collection_name(name: &str) -> String {
76 name.trim().to_lowercase()
77}
78
79fn migrate_legacy_timelines(
86 ctx: &CommandContext,
87 shots: &[Shot],
88) -> Result<Vec<Timeline>, CommandError> {
89 let existing_rows = ctx
90 .exec_query(GetTimelinesByQuery(TimelineQuery::default()))?
91 .into_iter()
92 .map(|timeline| timeline.as_ref().clone())
93 .collect::<Vec<_>>();
94 let mut timelines_by_id = existing_rows
95 .iter()
96 .cloned()
97 .map(|timeline| (timeline.id.clone(), timeline))
98 .collect::<HashMap<_, _>>();
99 let mut id_migrations = HashMap::<String, String>::new();
100 for legacy in ctx.exec_query(GetLegacyShotListsByQuery(LegacyShotListQuery::default()))? {
101 let legacy_id = legacy.id.to_string();
102 let mut timeline = legacy.as_ref().clone().into_timeline();
103 let timeline_id = timeline.id.to_string();
104 if legacy_id != timeline_id {
105 id_migrations.insert(legacy_id, timeline_id);
106 }
107 if let Some(current) = timelines_by_id.get_mut(&timeline.id) {
108 for entry in timeline.entries.drain(..) {
112 for direction in entry.directions() {
113 if current.entries.iter().any(|existing| {
114 existing.shot_id == entry.shot_id
115 && existing.directions().contains(direction)
116 }) {
117 continue;
118 }
119 let mut entry = entry.clone();
120 entry.entry_id.clear();
121 entry.direction = ShotEntryMode::from_direction(*direction);
122 current.entries.push(entry);
123 }
124 }
125 current.revision = current.revision.max(timeline.revision);
126 current.normalize_entries();
127 current.backfill_entry_parameters(shots);
128 } else {
129 timeline.backfill_entry_parameters(shots);
130 timelines_by_id.insert(timeline.id.clone(), timeline);
131 }
132 }
133 for timeline in timelines_by_id.values() {
134 let changed = existing_rows
135 .iter()
136 .find(|existing| existing.id == timeline.id)
137 != Some(timeline);
138 if changed {
139 ctx.emit_set(timeline)?;
140 }
141 }
142
143 for membership in ctx.exec_query(GetCollectionMembershipsByQuery(
146 CollectionMembershipQuery::default(),
147 ))? {
148 let timeline_id = id_migrations
149 .get(&membership.timeline_id)
150 .cloned()
151 .unwrap_or_else(|| membership.timeline_id.clone());
152 let replacement_id = CollectionMembershipId::from(CollectionMembership::stable_id(
153 &membership.collection_id,
154 &timeline_id,
155 ));
156 if replacement_id == membership.id && timeline_id == membership.timeline_id {
157 continue;
158 }
159 ctx.emit_set(&CollectionMembership {
160 id: replacement_id,
161 collection_id: membership.collection_id.clone(),
162 timeline_id,
163 sort_order: membership.sort_order,
164 })?;
165 ctx.emit_del(membership.as_ref())?;
166 }
167 for legacy_id in id_migrations.keys() {
168 if let Some(old_timeline) = ctx.exec_query_first(GetTimelinesByIds {
169 ids: vec![TimelineId::from(legacy_id.clone())],
170 })? {
171 ctx.emit_del(old_timeline.as_ref())?;
172 }
173 }
174 Ok(timelines_by_id.into_values().collect())
175}
176
177fn migrate_legacy_recording_jobs(ctx: &CommandContext) -> Result<(), CommandError> {
178 for legacy in ctx.exec_query(GetLegacyCaptureRunsByQuery(LegacyCaptureRunQuery::default()))? {
179 let job = legacy.to_recording_job();
180 if ctx
181 .exec_query_first(GetRecordingJobsByIds {
182 ids: vec![job.id.clone()],
183 })?
184 .is_none()
185 {
186 ctx.emit_set(&job)?;
187 }
188 }
189 Ok(())
190}
191
192#[myko_command(TimelineId)]
196pub struct MigrateOtioTimelines {}
197
198impl CommandHandler for MigrateOtioTimelines {
199 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
200 let shots = ctx
201 .exec_query(GetShotsByQuery(ShotQuery::default()))?
202 .into_iter()
203 .map(|shot| shot.as_ref().clone())
204 .collect::<Vec<_>>();
205 let timelines = migrate_legacy_timelines(&ctx, &shots)?;
206 apply_timeline_reconciliation(
207 &ctx,
208 reconcile_timelines(DEFAULT_SHOT_LIBRARY_ID, &shots, timelines),
209 )?;
210 migrate_legacy_recording_jobs(&ctx)?;
211 Ok(TimelineId::from("otio-timeline-migration"))
212 }
213}
214fn timeline_candidate_is_better(candidate: &Timeline, current: &Timeline) -> bool {
215 let candidate_rank = (
216 u8::from(!candidate.library_id.trim().is_empty()),
217 u8::from(candidate.streamer_id.trim().is_empty()),
218 );
219 let current_rank = (
220 u8::from(!current.library_id.trim().is_empty()),
221 u8::from(current.streamer_id.trim().is_empty()),
222 );
223 candidate_rank > current_rank
224 || (candidate_rank == current_rank && candidate.id.to_string() < current.id.to_string())
225}
226
227fn preferred_shot_ids(library_id: &str, shots: &[Shot]) -> HashMap<String, String> {
228 let mut preferred = HashMap::<(u8, String), &Shot>::new();
229 for candidate in shots
230 .iter()
231 .filter(|shot| shot.effective_library_id() == library_id)
232 {
233 let key = (
234 shot_kind_key(&candidate.kind),
235 candidate.target_name.clone(),
236 );
237 let replace = preferred.get(&key).is_none_or(|current| {
238 let candidate_rank = u8::from(!candidate.library_id.trim().is_empty());
239 let current_rank = u8::from(!current.library_id.trim().is_empty());
240 candidate_rank > current_rank
241 || (candidate_rank == current_rank
242 && candidate.id.to_string() < current.id.to_string())
243 });
244 if replace {
245 preferred.insert(key, candidate);
246 }
247 }
248
249 shots
250 .iter()
251 .filter(|shot| shot.effective_library_id() == library_id)
252 .filter_map(|shot| {
253 preferred
254 .get(&(shot_kind_key(&shot.kind), shot.target_name.clone()))
255 .map(|winner| (shot.id.to_string(), winner.id.to_string()))
256 })
257 .collect()
258}
259
260#[derive(Default)]
261struct TimelineReconciliation {
262 upserts: Vec<Timeline>,
263 deletes: Vec<Timeline>,
264 id_migrations: HashMap<String, String>,
265}
266
267fn apply_timeline_reconciliation(
268 ctx: &CommandContext,
269 reconciliation: TimelineReconciliation,
270) -> Result<(), CommandError> {
271 for timeline in &reconciliation.upserts {
272 ctx.emit_set(timeline)?;
273 }
274
275 if !reconciliation.id_migrations.is_empty() {
276 let memberships = ctx
277 .exec_query(GetCollectionMembershipsByQuery(
278 CollectionMembershipQuery::default(),
279 ))?
280 .into_iter()
281 .map(|membership| membership.as_ref().clone())
282 .collect::<Vec<_>>();
283 for membership in &memberships {
284 let Some(timeline_id) = reconciliation.id_migrations.get(&membership.timeline_id)
285 else {
286 continue;
287 };
288 let replacement_id = CollectionMembershipId::from(CollectionMembership::stable_id(
289 &membership.collection_id,
290 timeline_id,
291 ));
292 let sort_order = memberships
293 .iter()
294 .filter(|candidate| candidate.id == replacement_id)
295 .map(|candidate| candidate.sort_order)
296 .chain(std::iter::once(membership.sort_order))
297 .min()
298 .unwrap_or(membership.sort_order);
299 let replaces_membership = replacement_id != membership.id;
300 ctx.emit_set(&CollectionMembership {
301 id: replacement_id,
302 collection_id: membership.collection_id.clone(),
303 timeline_id: timeline_id.clone(),
304 sort_order,
305 })?;
306 if replaces_membership {
307 ctx.emit_del(membership)?;
308 }
309 }
310 }
311
312 for timeline in &reconciliation.deletes {
313 ctx.emit_del(timeline)?;
314 }
315 Ok(())
316}
317
318fn reconcile_timelines(
322 library_id: &str,
323 shots: &[Shot],
324 timelines: Vec<Timeline>,
325) -> TimelineReconciliation {
326 let library_id = effective_library_id(library_id);
327 let preferred_shots = preferred_shot_ids(library_id, shots);
328 let mut groups = HashMap::<String, Vec<Timeline>>::new();
329 for timeline in timelines
330 .into_iter()
331 .filter(|timeline| timeline.effective_library_id() == library_id)
332 {
333 let name = if timeline.has_legacy_default_identity() && timeline.has_legacy_default_name() {
334 "\0legacy-default-timeline".to_owned()
335 } else {
336 normalized_timeline_name(&timeline.name)
337 };
338 if !name.is_empty() {
339 groups.entry(name).or_default().push(timeline);
340 }
341 }
342
343 let mut reconciliation = TimelineReconciliation::default();
344 for (_, mut group) in groups {
345 group.sort_by_key(|timeline| timeline.id.to_string());
346 let preferred = group
347 .iter()
348 .reduce(|current, candidate| {
349 if timeline_candidate_is_better(candidate, current) {
350 candidate
351 } else {
352 current
353 }
354 })
355 .expect("timeline groups are non-empty");
356 let is_legacy_default = group.iter().any(|timeline| {
357 timeline.has_legacy_default_identity() && timeline.has_legacy_default_name()
358 });
359 let canonical_id = if is_legacy_default {
360 TimelineId::from(Timeline::legacy_default_id(library_id))
361 } else {
362 preferred.id.clone()
363 };
364
365 let mut entries = preferred
370 .entries
371 .iter()
372 .cloned()
373 .map(|mut entry| {
374 entry.shot_id = preferred_shots
375 .get(&entry.shot_id)
376 .cloned()
377 .unwrap_or(entry.shot_id);
378 entry
379 })
380 .collect::<Vec<_>>();
381 for timeline in group.iter().filter(|timeline| timeline.id != preferred.id) {
382 for source in &timeline.entries {
383 let shot_id = preferred_shots
384 .get(&source.shot_id)
385 .cloned()
386 .unwrap_or_else(|| source.shot_id.clone());
387 for direction in source.directions() {
388 let already_present = entries.iter().any(|entry| {
389 entry.shot_id == shot_id && entry.directions().contains(direction)
390 });
391 if !already_present {
392 let mut entry = source.clone();
393 entry.entry_id.clear();
394 entry.shot_id = shot_id.clone();
395 entry.direction = ShotEntryMode::from_direction(*direction);
396 entries.push(entry);
397 }
398 }
399 }
400 }
401 let revision = group
402 .iter()
403 .map(|timeline| timeline.revision)
404 .max()
405 .unwrap_or(0);
406 let mut canonical = Timeline {
407 id: canonical_id.clone(),
408 library_id: library_id.to_owned(),
409 streamer_id: String::new(),
410 name: if is_legacy_default && preferred.has_legacy_default_name() {
411 "Migrated timeline".to_owned()
412 } else {
413 preferred.name.trim().to_owned()
414 },
415 revision,
416 entries,
417 sort_order: group
418 .iter()
419 .map(|timeline| timeline.sort_order)
420 .min()
421 .unwrap_or(preferred.sort_order),
422 };
423 canonical.normalize_entries();
424 canonical.backfill_entry_parameters(shots);
425
426 for timeline in &group {
427 if timeline.id != canonical_id {
428 reconciliation
429 .id_migrations
430 .insert(timeline.id.to_string(), canonical_id.to_string());
431 }
432 }
433
434 if group.iter().find(|timeline| timeline.id == canonical_id) != Some(&canonical) {
435 reconciliation.upserts.push(canonical);
436 }
437 reconciliation.deletes.extend(
438 group
439 .into_iter()
440 .filter(|timeline| timeline.id != canonical_id),
441 );
442 }
443 reconciliation
444}
445
446fn ensure_unique_timeline_name(
447 ctx: &CommandContext,
448 library_id: &str,
449 id: &TimelineId,
450 name: &str,
451) -> Result<(), CommandError> {
452 let normalized_name = normalized_timeline_name(name);
453 if normalized_name.is_empty() {
454 return Err(command_error(ctx, "Timeline name cannot be empty"));
455 }
456 let duplicate = ctx
457 .exec_query(GetTimelinesByQuery(TimelineQuery::default()))?
458 .into_iter()
459 .any(|timeline| {
460 timeline.id != *id
461 && timeline.effective_library_id() == effective_library_id(library_id)
462 && normalized_timeline_name(&timeline.name) == normalized_name
463 });
464 if duplicate {
465 return Err(command_error(
466 ctx,
467 format!("A timeline named ‘{}’ already exists", name.trim()),
468 ));
469 }
470 Ok(())
471}
472
473fn shot_kind_key(kind: &ShotKind) -> u8 {
474 match kind {
475 ShotKind::Moving => 0,
476 ShotKind::Static => 1,
477 }
478}
479
480fn discovered_shots_to_create(
481 library_id: &str,
482 discoveries: Vec<ShotDiscovery>,
483 existing: &[Shot],
484) -> Vec<Shot> {
485 let library_id = effective_library_id(library_id);
486 let mut known = existing
487 .iter()
488 .filter(|shot| shot.effective_library_id() == library_id)
489 .map(|shot| (shot_kind_key(&shot.kind), shot.target_name.clone()))
490 .collect::<HashSet<_>>();
491 let mut next_index = existing
492 .iter()
493 .filter(|shot| shot.effective_library_id() == library_id)
494 .map(|shot| shot.shot_index)
495 .max()
496 .map_or(0, |index| index.saturating_add(1));
497
498 discoveries
499 .into_iter()
500 .filter_map(|discovery| {
501 let name = discovery.name.trim();
502 let target_name = discovery.target_name.trim();
503 if name.is_empty() || target_name.is_empty() {
504 return None;
505 }
506 let key = (shot_kind_key(&discovery.kind), target_name.to_owned());
507 if !known.insert(key) {
508 return None;
509 }
510 let shot = Shot {
511 id: ShotId::from(Shot::stable_id(library_id, &discovery.kind, target_name)),
512 library_id: library_id.to_owned(),
513 streamer_id: String::new(),
514 name: name.to_owned(),
515 kind: discovery.kind,
516 target_name: target_name.to_owned(),
517 translation_speed_cm_s: DEFAULT_SHOT_TRANSLATION_SPEED_CM_S,
518 rotation_speed_deg_s: DEFAULT_SHOT_ROTATION_SPEED_DEG_S,
519 hold_duration_ms: DEFAULT_SHOT_HOLD_DURATION_MS,
520 travel_duration_ms: crate::DEFAULT_SHOT_TRAVEL_DURATION_MS,
521 default_entry_mode: ShotEntryMode::Forward,
522 shot_index: next_index,
523 };
524 next_index = next_index.saturating_add(1);
525 Some(shot)
526 })
527 .collect()
528}
529
530#[myko_command]
534pub struct DiscoverShots {
535 #[serde(default)]
536 pub library_id: String,
537 pub shots: Vec<ShotDiscovery>,
538}
539
540impl CommandHandler for DiscoverShots {
541 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
542 if self.shots.len() > MAX_DISCOVERED_SHOTS {
543 return Err(command_error(
544 &ctx,
545 format!("shot discovery exceeds the {MAX_DISCOVERED_SHOTS} target limit"),
546 ));
547 }
548 let mut existing = ctx
549 .exec_query(GetShotsByQuery(ShotQuery::default()))?
550 .into_iter()
551 .map(|shot| shot.as_ref().clone())
552 .collect::<Vec<_>>();
553 let created = discovered_shots_to_create(&self.library_id, self.shots, &existing);
554 for shot in &created {
555 ctx.emit_set(shot)?;
556 }
557 existing.extend(created);
558
559 let timelines = ctx
560 .exec_query(GetTimelinesByQuery(TimelineQuery::default()))?
561 .into_iter()
562 .map(|timeline| timeline.as_ref().clone())
563 .collect();
564 let reconciliation = reconcile_timelines(&self.library_id, &existing, timelines);
565 apply_timeline_reconciliation(&ctx, reconciliation)
566 }
567}
568
569#[myko_command(ShotId)]
572pub struct SetShot {
573 pub shot_id: ShotId,
574 #[serde(default)]
575 pub library_id: String,
576 #[serde(default)]
577 pub streamer_id: String,
578 pub name: String,
579 #[serde(alias = "target_kind")]
580 pub kind: ShotKind,
581 pub target_name: String,
582 pub translation_speed_cm_s: f32,
583 pub rotation_speed_deg_s: f32,
584 pub hold_duration_ms: u64,
585 #[serde(default = "crate::default_shot_travel_duration_ms")]
586 pub travel_duration_ms: u64,
587 #[serde(
588 default,
589 alias = "defaultClipMode",
590 alias = "defaultListMode",
591 alias = "enabled",
592 alias = "batchMode"
593 )]
594 pub default_entry_mode: ShotEntryMode,
595 #[serde(rename = "sortOrder", alias = "shotIndex")]
598 pub shot_index: u32,
599}
600
601impl CommandHandler for SetShot {
602 fn execute(self, ctx: CommandContext) -> Result<ShotId, CommandError> {
603 let id = self.shot_id;
604 ctx.emit_set(&Shot {
605 id: id.clone(),
606 library_id: effective_library_id(&self.library_id).to_owned(),
607 streamer_id: self.streamer_id,
608 name: self.name,
609 kind: self.kind,
610 target_name: self.target_name,
611 translation_speed_cm_s: self.translation_speed_cm_s,
612 rotation_speed_deg_s: self.rotation_speed_deg_s,
613 hold_duration_ms: self.hold_duration_ms,
614 travel_duration_ms: self.travel_duration_ms,
615 default_entry_mode: self.default_entry_mode,
616 shot_index: self.shot_index,
617 })?;
618 Ok(id)
619 }
620}
621
622#[myko_command(TimelineId)]
626pub struct SetTimeline {
627 #[serde(alias = "shotListId")]
628 pub timeline_id: TimelineId,
629 #[serde(default)]
630 pub library_id: String,
631 #[serde(default)]
632 pub streamer_id: String,
633 pub name: String,
634 #[serde(alias = "clips", alias = "cues")]
635 pub entries: Vec<ShotEntry>,
636 pub sort_order: u32,
637}
638
639impl CommandHandler for SetTimeline {
640 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
641 let id = self.timeline_id;
642 ensure_unique_timeline_name(&ctx, &self.library_id, &id, &self.name)?;
643 let current = ctx.exec_query_first(GetTimelinesByIds {
644 ids: vec![id.clone()],
645 })?;
646 let revision = current.map(|current| current.next_revision()).unwrap_or(1);
647 let mut timeline = Timeline {
648 id: id.clone(),
649 library_id: effective_library_id(&self.library_id).to_owned(),
650 streamer_id: self.streamer_id,
651 name: self.name.trim().to_owned(),
652 revision,
653 entries: self.entries,
654 sort_order: self.sort_order,
655 };
656 timeline.normalize_entries();
657 let shots = ctx
658 .exec_query(GetShotsByQuery(ShotQuery::default()))?
659 .into_iter()
660 .map(|shot| shot.as_ref().clone())
661 .collect::<Vec<_>>();
662 timeline.backfill_entry_parameters(&shots);
663 ctx.emit_set(&timeline)?;
664 Ok(id)
665 }
666}
667
668fn timeline_for_edit(
681 ctx: &CommandContext,
682 timeline_id: &TimelineId,
683) -> Result<Timeline, CommandError> {
684 ctx.exec_query_first(GetTimelinesByIds {
685 ids: vec![timeline_id.clone()],
686 })?
687 .map(|timeline| timeline.as_ref().clone())
688 .ok_or_else(|| command_error(ctx, "That timeline no longer exists"))
689}
690
691fn commit_timeline(ctx: &CommandContext, mut timeline: Timeline) -> Result<(), CommandError> {
693 timeline.revision = timeline.next_revision();
694 timeline.normalize_entries();
695 let shots = ctx
696 .exec_query(GetShotsByQuery(ShotQuery::default()))?
697 .into_iter()
698 .map(|shot| shot.as_ref().clone())
699 .collect::<Vec<_>>();
700 timeline.backfill_entry_parameters(&shots);
701 ctx.emit_set(&timeline)?;
702 Ok(())
703}
704
705#[myko_command(TimelineId)]
707pub struct AddShotEntry {
708 pub timeline_id: TimelineId,
709 pub shot_id: String,
710 #[serde(default)]
711 pub direction: ShotDirection,
712 pub entry_id: String,
714 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub position: Option<u32>,
717}
718
719impl CommandHandler for AddShotEntry {
720 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
721 let mut timeline = timeline_for_edit(&ctx, &self.timeline_id)?;
722 if timeline.entry_position(&self.entry_id).is_some() {
724 return Ok(self.timeline_id);
725 }
726 let shot = ctx
727 .exec_query_first(GetShotsByIds {
728 ids: vec![ShotId::from(self.shot_id.clone())],
729 })?
730 .ok_or_else(|| command_error(&ctx, "That shot no longer exists"))?;
731 timeline.insert_shot_entry(
732 shot.as_ref(),
733 self.direction,
734 self.entry_id,
735 self.position.map(|position| position as usize),
736 );
737 commit_timeline(&ctx, timeline)?;
738 Ok(self.timeline_id)
739 }
740}
741
742#[myko_command(TimelineId)]
745pub struct RemoveShotEntry {
746 pub timeline_id: TimelineId,
747 pub entry_id: String,
748}
749
750impl CommandHandler for RemoveShotEntry {
751 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
752 let mut timeline = timeline_for_edit(&ctx, &self.timeline_id)?;
753 if timeline.entry_position(&self.entry_id).is_none() {
754 return Ok(self.timeline_id);
756 }
757 timeline.remove_entry(&self.entry_id);
758 commit_timeline(&ctx, timeline)?;
759 Ok(self.timeline_id)
760 }
761}
762
763#[myko_command(TimelineId)]
765pub struct MoveShotEntry {
766 pub timeline_id: TimelineId,
767 pub entry_id: String,
768 pub position: u32,
770}
771
772impl CommandHandler for MoveShotEntry {
773 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
774 let mut timeline = timeline_for_edit(&ctx, &self.timeline_id)?;
775 let Some(current) = timeline.entry_position(&self.entry_id) else {
776 return Err(command_error(
777 &ctx,
778 "That shot is no longer in this timeline",
779 ));
780 };
781 let target = (self.position as usize).min(timeline.entries.len().saturating_sub(1));
782 if current == target {
783 return Ok(self.timeline_id);
784 }
785 timeline.move_entry(&self.entry_id, target);
786 commit_timeline(&ctx, timeline)?;
787 Ok(self.timeline_id)
788 }
789}
790
791#[myko_command(TimelineId)]
793pub struct SetShotEntryDirection {
794 pub timeline_id: TimelineId,
795 pub entry_id: String,
796 pub direction: ShotDirection,
797}
798
799impl CommandHandler for SetShotEntryDirection {
800 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
801 let mut timeline = timeline_for_edit(&ctx, &self.timeline_id)?;
802 if timeline.entry_position(&self.entry_id).is_none() {
803 return Err(command_error(
804 &ctx,
805 "That shot is no longer in this timeline",
806 ));
807 }
808 timeline.set_entry_direction(&self.entry_id, self.direction);
809 commit_timeline(&ctx, timeline)?;
810 Ok(self.timeline_id)
811 }
812}
813
814#[myko_command(TimelineId)]
817pub struct RemoveTimeline {
818 #[serde(alias = "shotListId")]
819 pub timeline_id: TimelineId,
820}
821
822impl CommandHandler for RemoveTimeline {
823 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
824 let id = self.timeline_id;
825 let current = ctx
826 .exec_query_first(GetTimelinesByIds {
827 ids: vec![id.clone()],
828 })?
829 .ok_or_else(|| command_error(&ctx, format!("Timeline {id} does not exist")))?;
830 for membership in ctx
831 .exec_query(GetCollectionMembershipsByQuery(
832 CollectionMembershipQuery::default(),
833 ))?
834 .into_iter()
835 .filter(|membership| membership.timeline_id == id.to_string())
836 {
837 ctx.emit_del(membership.as_ref())?;
838 }
839 ctx.emit_del(current.as_ref())?;
840 Ok(id)
841 }
842}
843
844#[myko_command(CollectionId)]
848pub struct SetCollection {
849 pub collection_id: CollectionId,
850 #[serde(default)]
851 pub library_id: String,
852 #[serde(default)]
853 pub parent_id: String,
854 pub name: String,
855 #[serde(default)]
856 pub sort_order: u32,
857 #[serde(default)]
858 pub metadata: HashMap<String, String>,
859}
860
861impl CommandHandler for SetCollection {
862 fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
863 let id = self.collection_id;
864 let library_id = effective_library_id(&self.library_id).to_owned();
865 let name = self.name.trim().to_owned();
866 if name.is_empty() {
867 return Err(command_error(&ctx, "Collection name cannot be empty"));
868 }
869 if name.chars().count() > MAX_COLLECTION_NAME_CHARS {
870 return Err(command_error(
871 &ctx,
872 format!("Collection names are limited to {MAX_COLLECTION_NAME_CHARS} characters"),
873 ));
874 }
875 let mut collections = ctx
876 .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
877 .into_iter()
878 .map(|collection| collection.as_ref().clone())
879 .collect::<Vec<_>>();
880 if !collections.iter().any(|collection| collection.id == id)
881 && !crate::collection::is_uuid_v7(id.as_ref())
882 {
883 if require_uuid_v7_collection_ids() {
884 return Err(command_error(
885 &ctx,
886 "New collection IDs must be UUIDv7; existing legacy collections remain editable",
887 ));
888 }
889 eprintln!(
890 "accepted legacy native collection id during UUIDv7 rollout: {}",
891 id.as_ref()
892 );
893 }
894 if collections.iter().any(|collection| {
895 collection.id != id
896 && collection.library_id == library_id
897 && collection.parent_id == self.parent_id
898 && normalized_collection_name(&collection.name) == normalized_collection_name(&name)
899 }) {
900 return Err(command_error(
901 &ctx,
902 format!("A collection named ‘{name}’ already exists here"),
903 ));
904 }
905 if !self.parent_id.trim().is_empty() {
906 let parent = collections
907 .iter()
908 .find(|collection| collection.id.to_string() == self.parent_id)
909 .ok_or_else(|| command_error(&ctx, "Parent collection does not exist"))?;
910 if parent.library_id != library_id {
911 return Err(command_error(
912 &ctx,
913 "A collection cannot be moved between libraries",
914 ));
915 }
916 if parent.id == id {
917 return Err(command_error(&ctx, "A collection cannot contain itself"));
918 }
919 let parent_path = resolve_collection_path(&self.parent_id, &collections)
920 .map_err(|error| command_error(&ctx, error))?;
921 if parent_path.len() >= MAX_COLLECTION_DEPTH {
922 return Err(command_error(
923 &ctx,
924 format!("Collections are limited to {MAX_COLLECTION_DEPTH} levels"),
925 ));
926 }
927 if parent_path
928 .iter()
929 .any(|segment| segment.collection_id == id.to_string())
930 {
931 return Err(command_error(
932 &ctx,
933 "A collection cannot be moved inside one of its descendants",
934 ));
935 }
936 }
937 let collection = Collection {
938 id: id.clone(),
939 library_id,
940 parent_id: self.parent_id,
941 name,
942 sort_order: self.sort_order,
943 metadata: self.metadata,
944 };
945 if let Some(current) = collections.iter_mut().find(|row| row.id == id) {
946 *current = collection.clone();
947 } else {
948 collections.push(collection.clone());
949 }
950 resolve_collection_path(id.as_ref(), &collections)
952 .map_err(|error| command_error(&ctx, error))?;
953 ctx.emit_set(&collection)?;
954 Ok(id)
955 }
956}
957
958#[myko_command(CollectionId)]
962pub struct RemoveCollection {
963 pub collection_id: CollectionId,
964}
965
966impl CommandHandler for RemoveCollection {
967 fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
968 let id = self.collection_id;
969 let current = ctx
970 .exec_query_first(GetCollectionsByIds {
971 ids: vec![id.clone()],
972 })?
973 .ok_or_else(|| command_error(&ctx, format!("Collection {id} does not exist")))?;
974 let has_children = ctx
975 .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
976 .into_iter()
977 .any(|collection| collection.parent_id == id.to_string());
978 if has_children {
979 return Err(command_error(
980 &ctx,
981 "Move or remove child collections before deleting this collection",
982 ));
983 }
984 for membership in ctx
985 .exec_query(GetCollectionMembershipsByQuery(
986 CollectionMembershipQuery::default(),
987 ))?
988 .into_iter()
989 .filter(|membership| membership.collection_id == id.to_string())
990 {
991 ctx.emit_del(membership.as_ref())?;
992 }
993 ctx.emit_del(current.as_ref())?;
994 Ok(id)
995 }
996}
997
998#[myko_command(CollectionMembershipId)]
1000pub struct SetCollectionMembership {
1001 pub collection_id: String,
1002 #[serde(alias = "shotListId")]
1003 pub timeline_id: String,
1004 pub included: bool,
1005 #[serde(default)]
1006 pub sort_order: u32,
1007}
1008
1009impl CommandHandler for SetCollectionMembership {
1010 fn execute(self, ctx: CommandContext) -> Result<CollectionMembershipId, CommandError> {
1011 let id = CollectionMembershipId::from(CollectionMembership::stable_id(
1012 &self.collection_id,
1013 &self.timeline_id,
1014 ));
1015 let existing = ctx.exec_query_first(GetCollectionMembershipsByIds {
1016 ids: vec![id.clone()],
1017 })?;
1018 if !self.included {
1019 if let Some(existing) = existing {
1020 ctx.emit_del(existing.as_ref())?;
1021 }
1022 return Ok(id);
1023 }
1024 let collection = ctx
1025 .exec_query_first(GetCollectionsByIds {
1026 ids: vec![CollectionId::from(self.collection_id.clone())],
1027 })?
1028 .ok_or_else(|| command_error(&ctx, "Collection does not exist"))?;
1029 let timeline = ctx
1030 .exec_query_first(GetTimelinesByIds {
1031 ids: vec![TimelineId::from(self.timeline_id.clone())],
1032 })?
1033 .ok_or_else(|| command_error(&ctx, "Timeline does not exist"))?;
1034 if collection.library_id != timeline.effective_library_id() {
1035 return Err(command_error(
1036 &ctx,
1037 "Collection and timeline belong to different libraries",
1038 ));
1039 }
1040 ctx.emit_set(&CollectionMembership {
1041 id: id.clone(),
1042 collection_id: self.collection_id,
1043 timeline_id: self.timeline_id,
1044 sort_order: self.sort_order,
1045 })?;
1046 Ok(id)
1047 }
1048}
1049
1050#[myko_command(TimelineId)]
1054pub struct ImportTimelineOtio {
1055 pub otio_json: String,
1056 pub sort_order: u32,
1057}
1058
1059impl CommandHandler for ImportTimelineOtio {
1060 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
1061 if self.otio_json.len() > MAX_OTIO_IMPORT_BYTES {
1062 return Err(command_error(
1063 &ctx,
1064 format!(
1065 "OTIO import exceeds the {} byte limit",
1066 MAX_OTIO_IMPORT_BYTES
1067 ),
1068 ));
1069 }
1070 let imported = crate::import_timeline_otio_json(&self.otio_json)
1071 .map_err(|error| command_error(&ctx, error.to_string()))?;
1072 if imported.shots.len() > 10_000 {
1073 return Err(command_error(&ctx, "OTIO import contains too many shots"));
1074 }
1075 ensure_unique_timeline_name(
1076 &ctx,
1077 imported.timeline.effective_library_id(),
1078 &imported.timeline.id,
1079 &imported.timeline.name,
1080 )?;
1081
1082 let mut timeline = imported.timeline;
1083 timeline.backfill_entry_parameters(&imported.shots);
1084 for shot in imported.shots {
1085 ctx.emit_set(&shot)?;
1086 }
1087
1088 if let Some(current) = ctx.exec_query_first(GetTimelinesByIds {
1089 ids: vec![timeline.id.clone()],
1090 })? {
1091 timeline.revision = timeline.revision.max(current.next_revision());
1092 }
1093 timeline.sort_order = self.sort_order;
1094 timeline.normalize_entries();
1095 let id = timeline.id.clone();
1096 ctx.emit_set(&timeline)?;
1097 Ok(id)
1098 }
1099}
1100
1101#[myko_command(CollectionId)]
1104pub struct ImportCollectionOtio {
1105 pub otio_json: String,
1106}
1107
1108impl CommandHandler for ImportCollectionOtio {
1109 fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
1110 if self.otio_json.len() > MAX_OTIO_IMPORT_BYTES {
1111 return Err(command_error(
1112 &ctx,
1113 format!(
1114 "OTIO import exceeds the {} byte limit",
1115 MAX_OTIO_IMPORT_BYTES
1116 ),
1117 ));
1118 }
1119 let mut imported = crate::import_collection_otio_json(&self.otio_json)
1120 .map_err(|error| command_error(&ctx, error.to_string()))?;
1121 if imported.collections.is_empty() {
1122 return Err(command_error(&ctx, "OTIO collection is empty"));
1123 }
1124 if imported.collections.len() > 10_000
1125 || imported.memberships.len() > 100_000
1126 || imported.timelines.len() > 10_000
1127 || imported.shots.len() > 10_000
1128 {
1129 return Err(command_error(&ctx, "OTIO collection exceeds import limits"));
1130 }
1131 let root_id = imported.collections[0].id.clone();
1132 let imported_collection_ids = imported
1133 .collections
1134 .iter()
1135 .map(|collection| collection.id.to_string())
1136 .collect::<HashSet<_>>();
1137 let existing_collections = ctx
1138 .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
1139 .into_iter()
1140 .map(|collection| collection.as_ref().clone())
1141 .collect::<Vec<_>>();
1142 for collection in &imported.collections {
1143 let duplicate = imported
1144 .collections
1145 .iter()
1146 .chain(
1147 existing_collections
1148 .iter()
1149 .filter(|existing| !imported_collection_ids.contains(existing.id.as_ref())),
1150 )
1151 .any(|candidate| {
1152 candidate.id != collection.id
1153 && candidate.library_id == collection.library_id
1154 && candidate.parent_id == collection.parent_id
1155 && normalized_collection_name(&candidate.name)
1156 == normalized_collection_name(&collection.name)
1157 });
1158 if duplicate {
1159 return Err(command_error(
1160 &ctx,
1161 format!(
1162 "A collection named ‘{}’ already exists at the imported location",
1163 collection.name
1164 ),
1165 ));
1166 }
1167 }
1168
1169 let imported_timeline_ids = imported
1170 .timelines
1171 .iter()
1172 .map(|timeline| timeline.id.to_string())
1173 .collect::<HashSet<_>>();
1174 let existing_timelines = ctx
1175 .exec_query(GetTimelinesByQuery(TimelineQuery::default()))?
1176 .into_iter()
1177 .map(|timeline| timeline.as_ref().clone())
1178 .collect::<Vec<_>>();
1179 for timeline in &imported.timelines {
1180 let duplicate = imported
1181 .timelines
1182 .iter()
1183 .chain(
1184 existing_timelines
1185 .iter()
1186 .filter(|existing| !imported_timeline_ids.contains(existing.id.as_ref())),
1187 )
1188 .any(|candidate| {
1189 candidate.id != timeline.id
1190 && candidate.effective_library_id() == timeline.effective_library_id()
1191 && normalized_timeline_name(&candidate.name)
1192 == normalized_timeline_name(&timeline.name)
1193 });
1194 if duplicate {
1195 return Err(command_error(
1196 &ctx,
1197 format!("A timeline named ‘{}’ already exists", timeline.name),
1198 ));
1199 }
1200 }
1201
1202 for shot in imported.shots {
1203 ctx.emit_set(&shot)?;
1204 }
1205 for timeline in &mut imported.timelines {
1206 timeline.normalize_entries();
1207 if let Some(current) = existing_timelines
1208 .iter()
1209 .find(|current| current.id == timeline.id)
1210 {
1211 timeline.revision = timeline.revision.max(current.next_revision());
1212 }
1213 ctx.emit_set(timeline)?;
1214 }
1215 for collection in imported.collections {
1216 ctx.emit_set(&collection)?;
1217 }
1218 for membership in imported.memberships {
1219 ctx.emit_set(&membership)?;
1220 }
1221 Ok(root_id)
1222 }
1223}
1224
1225fn command_error(ctx: &CommandContext, message: impl Into<String>) -> CommandError {
1226 CommandError {
1227 tx: ctx.tx().to_string(),
1228 command_id: ctx.command_id.to_string(),
1229 message: message.into(),
1230 }
1231}
1232
1233#[myko_command(RecordingJobRequestId)]
1235pub struct ControlRecordingJob {
1236 pub streamer_id: String,
1237 #[serde(alias = "runId")]
1238 pub job_id: String,
1239 pub command_id: String,
1240 pub action: RecordingJobAction,
1241 #[serde(default, skip_serializing_if = "Option::is_none")]
1243 #[ts(type = "unknown")]
1244 pub capture_context: Option<crate::CaptureContext>,
1245 #[serde(default, alias = "shotListId")]
1248 pub timeline_id: String,
1249 #[serde(default)]
1252 pub collection_id: String,
1253 #[serde(default, alias = "shots", alias = "items")]
1256 pub entries: Vec<ShotEntryPlan>,
1257 #[serde(default)]
1263 pub entry_ids: Vec<String>,
1264 #[serde(default)]
1265 pub preset_duration_ms: u64,
1266 #[serde(default)]
1267 pub translation_speed_cm_s: f32,
1268 #[serde(default)]
1269 pub rotation_speed_deg_s: f32,
1270 #[serde(default)]
1271 pub requested_at_ms: u64,
1272}
1273
1274impl CommandHandler for ControlRecordingJob {
1275 fn execute(self, ctx: CommandContext) -> Result<RecordingJobRequestId, CommandError> {
1276 let (
1277 timeline_id,
1278 timeline_name,
1279 timeline_revision,
1280 collection_id,
1281 collection_path,
1282 entries,
1283 ) = if self.action == RecordingJobAction::StartView {
1284 if self.job_id.trim().is_empty() {
1290 return Err(command_error(
1291 &ctx,
1292 "StartView requires a stable RecordingJob id",
1293 ));
1294 }
1295 let label = self
1296 .entries
1297 .first()
1298 .map(|entry| entry.name.trim().to_owned())
1299 .filter(|name| !name.is_empty())
1300 .unwrap_or_else(|| "freefly".to_owned());
1301 let entry = ShotEntryPlan {
1302 entry_id: "view".to_owned(),
1303 shot_id: String::new(),
1304 name: label,
1305 shot_index: None,
1306 kind: crate::ShotKind::Static,
1307 target_name: String::new(),
1308 translation_speed_cm_s: 0.0,
1309 rotation_speed_deg_s: 0.0,
1310 hold_duration_ms: 0,
1311 travel_duration_ms: 0,
1312 direction: crate::ShotDirection::default(),
1313 next_take_number: 1,
1314 open_ended: true,
1315 };
1316 (
1317 String::new(),
1318 String::new(),
1319 0,
1320 String::new(),
1321 Vec::new(),
1322 vec![entry],
1323 )
1324 } else if self.action == RecordingJobAction::Start {
1325 if self.capture_context.is_none() {
1326 return Err(command_error(
1327 &ctx,
1328 "Start requires editorial capture context",
1329 ));
1330 }
1331 if self.timeline_id.trim().is_empty() {
1332 return Err(CommandError {
1333 tx: ctx.tx().to_string(),
1334 command_id: ctx.command_id.to_string(),
1335 message: "Start requires a persistent Timeline id".to_owned(),
1336 });
1337 }
1338 if self.job_id.trim().is_empty() {
1339 return Err(CommandError {
1340 tx: ctx.tx().to_string(),
1341 command_id: ctx.command_id.to_string(),
1342 message: "Start requires a stable RecordingJob id".to_owned(),
1343 });
1344 }
1345 let timeline_id = TimelineId::from(self.timeline_id);
1346 let current = ctx
1347 .exec_query_first(GetTimelinesByIds {
1348 ids: vec![timeline_id.clone()],
1349 })?
1350 .ok_or_else(|| CommandError {
1351 tx: ctx.tx().to_string(),
1352 command_id: ctx.command_id.to_string(),
1353 message: format!("Timeline {timeline_id} does not exist"),
1354 })?;
1355 let mut timeline = (*current).clone();
1356 let (collection_id, collection_path) = if self.collection_id.trim().is_empty() {
1357 (String::new(), Vec::new())
1358 } else {
1359 let membership_id = CollectionMembershipId::from(CollectionMembership::stable_id(
1360 &self.collection_id,
1361 timeline_id.as_ref(),
1362 ));
1363 if ctx
1364 .exec_query_first(GetCollectionMembershipsByIds {
1365 ids: vec![membership_id],
1366 })?
1367 .is_none()
1368 {
1369 return Err(command_error(
1370 &ctx,
1371 "Timeline is not assigned to the selected collection",
1372 ));
1373 }
1374 let collections = ctx
1375 .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
1376 .into_iter()
1377 .map(|collection| collection.as_ref().clone())
1378 .collect::<Vec<_>>();
1379 let path = resolve_collection_path(&self.collection_id, &collections)
1380 .map_err(|error| command_error(&ctx, error))?;
1381 (self.collection_id.clone(), path)
1382 };
1383 let revision = timeline.revision;
1384 let name = timeline.name.clone();
1385 let shot_rows = ctx
1386 .exec_query(GetShotsByQuery(ShotQuery::default()))?
1387 .into_iter()
1388 .map(|shot| shot.as_ref().clone())
1389 .collect::<Vec<_>>();
1390 let timeline_changed = timeline.backfill_entry_parameters(&shot_rows);
1391 let mut entries = crate::resolve_timeline_plans(&timeline, &shot_rows)
1392 .map_err(|error| command_error(&ctx, error))?;
1393 if entries.is_empty() {
1394 return Err(command_error(&ctx, "Timeline has no shot entries"));
1395 }
1396 if !self.entry_ids.is_empty() {
1400 let wanted: std::collections::HashSet<&str> =
1401 self.entry_ids.iter().map(String::as_str).collect();
1402 entries.retain(|entry| wanted.contains(entry.entry_id.as_str()));
1403 if entries.len() != self.entry_ids.len() {
1404 return Err(command_error(
1405 &ctx,
1406 "one or more requested shot entries are not in this timeline",
1407 ));
1408 }
1409 }
1410 let prior_jobs = ctx
1411 .exec_query(GetRecordingJobsByQuery(RecordingJobQuery::default()))?
1412 .into_iter()
1413 .map(|job| job.as_ref().clone())
1414 .collect::<Vec<_>>();
1415 for entry in &mut entries {
1416 entry.next_take_number = crate::next_take_number_for_entry(
1417 &prior_jobs,
1418 &collection_id,
1419 timeline_id.as_ref(),
1420 entry,
1421 );
1422 }
1423 if timeline_changed {
1424 ctx.emit_set(&timeline)?;
1425 }
1426 (
1427 timeline_id.to_string(),
1428 name,
1429 revision,
1430 collection_id,
1431 collection_path,
1432 entries,
1433 )
1434 } else {
1435 (
1436 String::new(),
1437 String::new(),
1438 0,
1439 String::new(),
1440 Vec::new(),
1441 self.entries,
1442 )
1443 };
1444 let id: RecordingJobRequestId = self.streamer_id.clone().into();
1445 let capture_context = self.capture_context.map(StoredCaptureContext::from);
1446 ctx.emit_set(&RecordingJobRequest {
1447 id: id.clone(),
1448 streamer_id: self.streamer_id.clone(),
1449 job_id: self.job_id.clone(),
1450 command_id: self.command_id,
1451 action: self.action.clone(),
1452 capture_context: capture_context.clone(),
1453 timeline_id: timeline_id.clone(),
1454 timeline_name: timeline_name.clone(),
1455 timeline_revision,
1456 collection_id: collection_id.clone(),
1457 collection_path: collection_path.clone(),
1458 entries: entries.clone(),
1459 preset_duration_ms: self.preset_duration_ms,
1460 translation_speed_cm_s: self.translation_speed_cm_s,
1461 rotation_speed_deg_s: self.rotation_speed_deg_s,
1462 requested_at_ms: self.requested_at_ms,
1463 })?;
1464 if self.action == RecordingJobAction::Start {
1465 ctx.emit_set(&RecordingJob {
1466 id: RecordingJobId::from(self.job_id.clone()),
1467 job_id: self.job_id,
1468 streamer_id: self.streamer_id,
1469 capture_context,
1470 timeline_id,
1471 timeline_name,
1472 timeline_revision,
1473 collection_id,
1474 collection_path,
1475 phase: RecordingJobPhase::Idle,
1476 pause_requested: false,
1477 entries,
1478 takes: Vec::new(),
1479 error: String::new(),
1480 started_at_ms: 0,
1481 updated_at_ms: self.requested_at_ms,
1482 elapsed_ms: 0,
1483 estimated_total_ms: 0,
1484 estimated_remaining_ms: 0,
1485 })?;
1486 }
1487 Ok(id)
1488 }
1489}
1490
1491#[myko_command(RecordingJobStatusId)]
1493pub struct SetRecordingJobStatus {
1494 pub streamer_id: String,
1495 #[serde(alias = "runId")]
1496 pub job_id: String,
1497 #[serde(default, skip_serializing_if = "Option::is_none")]
1498 #[ts(type = "unknown")]
1499 pub capture_context: Option<crate::CaptureContext>,
1500 #[serde(default, alias = "shotListId")]
1501 pub timeline_id: String,
1502 #[serde(default, alias = "shotListName")]
1503 pub timeline_name: String,
1504 #[serde(default, alias = "timelineVersion", alias = "shotListVersion")]
1505 pub timeline_revision: u32,
1506 #[serde(default)]
1507 pub collection_id: String,
1508 #[serde(default)]
1509 pub collection_path: Vec<crate::CollectionPathSegment>,
1510 pub phase: RecordingJobPhase,
1511 #[serde(default)]
1512 pub pause_requested: bool,
1513 #[serde(default, alias = "shots", alias = "items")]
1514 pub entries: Vec<ShotEntryPlan>,
1515 #[serde(default)]
1516 pub index: u32,
1517 #[serde(default)]
1518 pub completed: u32,
1519 #[serde(default)]
1520 pub error: String,
1521 #[serde(default)]
1522 pub updated_at_ms: u64,
1523 #[serde(default)]
1524 pub elapsed_ms: u64,
1525 #[serde(default)]
1526 pub estimated_total_ms: u64,
1527 #[serde(default)]
1528 pub estimated_remaining_ms: u64,
1529 #[serde(default)]
1530 pub takes: Vec<Take>,
1531 #[serde(default)]
1532 pub started_at_ms: u64,
1533}
1534
1535fn recording_job_status_is_heartbeat_only(
1543 candidate: &RecordingJobStatus,
1544 stored: &RecordingJobStatus,
1545) -> bool {
1546 let mut probe = candidate.clone();
1547 probe.updated_at_ms = stored.updated_at_ms;
1548 probe == *stored
1549}
1550
1551fn target_summary_is_heartbeat_only(
1553 candidate: &crate::FrameCaptureTargetSummary,
1554 stored: &crate::FrameCaptureTargetSummary,
1555) -> bool {
1556 let mut probe = candidate.clone();
1557 probe.updated_at_ms = stored.updated_at_ms;
1558 probe == *stored
1559}
1560
1561impl CommandHandler for SetRecordingJobStatus {
1562 fn execute(self, ctx: CommandContext) -> Result<RecordingJobStatusId, CommandError> {
1563 let id: RecordingJobStatusId = self.streamer_id.clone().into();
1564 let mut takes = self.takes;
1565 let job_id = self.job_id.clone();
1566 let existing_job = if !job_id.trim().is_empty() {
1567 ctx.exec_query_first(GetRecordingJobsByIds {
1568 ids: vec![RecordingJobId::from(job_id.clone())],
1569 })?
1570 } else {
1571 None
1572 };
1573 if let Some(existing) = &existing_job {
1574 for take in &mut takes {
1575 let accepted = existing.takes.iter().any(|saved| {
1576 saved.take_id == take.take_id
1577 && saved.capture.as_ref().is_some_and(|capture| {
1578 capture.creative_status == CreativeStatus::Accepted
1579 })
1580 });
1581 if accepted {
1582 if let Some(capture) = &mut take.capture {
1583 capture.creative_status = CreativeStatus::Accepted;
1584 }
1585 }
1586 }
1587 }
1588 let mirror_present = existing_job.is_some();
1589 let capture_context = self
1590 .capture_context
1591 .map(StoredCaptureContext::from)
1592 .or_else(|| existing_job.and_then(|job| job.capture_context.clone()));
1593 let status = RecordingJobStatus {
1594 id: id.clone(),
1595 streamer_id: self.streamer_id,
1596 job_id: job_id.clone(),
1597 capture_context,
1598 timeline_id: self.timeline_id,
1599 timeline_name: self.timeline_name,
1600 timeline_revision: self.timeline_revision,
1601 collection_id: self.collection_id,
1602 collection_path: self.collection_path,
1603 phase: self.phase,
1604 pause_requested: self.pause_requested,
1605 entries: self.entries,
1606 index: self.index,
1607 completed: self.completed,
1608 error: self.error,
1609 updated_at_ms: self.updated_at_ms,
1610 elapsed_ms: self.elapsed_ms,
1611 estimated_total_ms: self.estimated_total_ms,
1612 estimated_remaining_ms: self.estimated_remaining_ms,
1613 takes,
1614 started_at_ms: self.started_at_ms,
1615 };
1616 let unchanged = ctx
1627 .exec_query_first(GetRecordingJobStatussByIds {
1628 ids: vec![id.clone()],
1629 })?
1630 .is_some_and(|existing| recording_job_status_is_heartbeat_only(&status, &existing));
1631 if unchanged && (job_id.trim().is_empty() || mirror_present) {
1634 return Ok(id);
1635 }
1636 ctx.emit_set(&status)?;
1637 if !job_id.trim().is_empty() {
1638 ctx.emit_set(&RecordingJob {
1639 id: RecordingJobId::from(job_id.clone()),
1640 job_id,
1641 streamer_id: status.streamer_id.clone(),
1642 capture_context: status.capture_context.clone(),
1643 timeline_id: status.timeline_id.clone(),
1644 timeline_name: status.timeline_name.clone(),
1645 timeline_revision: status.timeline_revision,
1646 collection_id: status.collection_id.clone(),
1647 collection_path: status.collection_path.clone(),
1648 phase: status.phase.clone(),
1649 pause_requested: status.pause_requested,
1650 entries: status.entries.clone(),
1651 takes: status.takes.clone(),
1652 error: status.error.clone(),
1653 started_at_ms: status.started_at_ms,
1654 updated_at_ms: status.updated_at_ms,
1655 elapsed_ms: status.elapsed_ms,
1656 estimated_total_ms: status.estimated_total_ms,
1657 estimated_remaining_ms: status.estimated_remaining_ms,
1658 })?;
1659 }
1660 Ok(id)
1661 }
1662}
1663
1664#[myko_command(RecordingJobId)]
1667pub struct AcceptTake {
1668 pub job_id: String,
1669 pub take_id: String,
1670}
1671
1672impl CommandHandler for AcceptTake {
1673 fn execute(self, ctx: CommandContext) -> Result<RecordingJobId, CommandError> {
1674 let id = RecordingJobId::from(self.job_id);
1675 let current = ctx
1676 .exec_query_first(GetRecordingJobsByIds {
1677 ids: vec![id.clone()],
1678 })?
1679 .ok_or_else(|| command_error(&ctx, format!("Recording job {id} does not exist")))?;
1680 let mut job = current.as_ref().clone();
1681 let take = job
1682 .takes
1683 .iter_mut()
1684 .find(|take| take.take_id == self.take_id)
1685 .ok_or_else(|| command_error(&ctx, "Take does not exist"))?;
1686 let Some(capture) = &mut take.capture else {
1687 return Err(command_error(&ctx, "Take has no Capture to accept"));
1688 };
1689 if take.state != TakeState::Completed
1690 || capture.delivery_status != DeliveryStatus::Delivered
1691 {
1692 return Err(command_error(
1693 &ctx,
1694 "Only delivered Captures can be accepted",
1695 ));
1696 }
1697 capture.creative_status = CreativeStatus::Accepted;
1698 ctx.emit_set(&job)?;
1699 Ok(id)
1700 }
1701}
1702
1703#[myko_command(CamPrefId)]
1708pub struct SetCamPref {
1709 pub focal: f32,
1710 pub aperture: f32,
1711 pub focus_method: String,
1712 pub focus_dist: f32,
1713 pub base_speed: f32,
1714 pub look_scale: f32,
1715 pub invert: bool,
1716 pub glide: bool,
1717 pub glide_secs: f32,
1718 pub motion_blur: f32,
1719 pub rail_speed: f32,
1720}
1721
1722impl CommandHandler for SetCamPref {
1723 fn execute(self, ctx: CommandContext) -> Result<CamPrefId, CommandError> {
1724 let id = CamPref::row_id();
1725 let pref = CamPref {
1726 id: id.clone(),
1727 focal: self.focal,
1728 aperture: self.aperture,
1729 focus_method: self.focus_method,
1730 focus_dist: self.focus_dist,
1731 base_speed: self.base_speed,
1732 look_scale: self.look_scale,
1733 invert: self.invert,
1734 glide: self.glide,
1735 glide_secs: self.glide_secs,
1736 motion_blur: self.motion_blur,
1737 rail_speed: self.rail_speed,
1738 };
1739 ctx.emit_set(&pref)?;
1740 Ok(id)
1741 }
1742}
1743
1744#[myko_command(CameraHomeId)]
1747pub struct SetCameraHome {
1748 pub stream_id: String,
1749 pub location_x: f32,
1750 pub location_y: f32,
1751 pub location_z: f32,
1752 pub rotation_pitch: f32,
1753 pub rotation_yaw: f32,
1754 pub rotation_roll: f32,
1755 pub focal_length: f32,
1756}
1757
1758impl CommandHandler for SetCameraHome {
1759 fn execute(self, ctx: CommandContext) -> Result<CameraHomeId, CommandError> {
1760 if self.stream_id.trim().is_empty() {
1761 return Err(command_error(&ctx, "Camera Home requires a stream id"));
1762 }
1763 let values = [
1764 self.location_x,
1765 self.location_y,
1766 self.location_z,
1767 self.rotation_pitch,
1768 self.rotation_yaw,
1769 self.rotation_roll,
1770 self.focal_length,
1771 ];
1772 if values.iter().any(|value| !value.is_finite()) {
1773 return Err(command_error(&ctx, "Camera Home values must be finite"));
1774 }
1775 if !(1.0..=1000.0).contains(&self.focal_length) {
1776 return Err(command_error(
1777 &ctx,
1778 "Camera Home focal length must be between 1 and 1000 mm",
1779 ));
1780 }
1781
1782 let id = CameraHome::row_id(&self.stream_id);
1783 ctx.emit_set(&CameraHome {
1784 id: id.clone(),
1785 stream_id: self.stream_id,
1786 location_x: self.location_x,
1787 location_y: self.location_y,
1788 location_z: self.location_z,
1789 rotation_pitch: self.rotation_pitch,
1790 rotation_yaw: self.rotation_yaw,
1791 rotation_roll: self.rotation_roll,
1792 focal_length: self.focal_length,
1793 })?;
1794 Ok(id)
1795 }
1796}
1797
1798#[myko_command(StreamId)]
1802pub struct SetStreamName {
1803 pub stream_id: StreamId,
1804 pub name: String,
1805}
1806
1807impl CommandHandler for SetStreamName {
1808 fn execute(self, ctx: CommandContext) -> Result<StreamId, CommandError> {
1809 let id = self.stream_id.clone();
1810 let stream = Stream {
1811 id: id.clone(),
1812 name: self.name,
1813 };
1814 ctx.emit_set(&stream)?;
1815 Ok(id)
1816 }
1817}
1818
1819#[myko_command(RecordingRequestId)]
1824pub struct SetRecording {
1825 pub streamer_id: String,
1826 pub active: bool,
1827 #[serde(default)]
1828 pub capture_kind: crate::RecordingKind,
1829 #[serde(default)]
1830 pub rig: String,
1831 #[serde(default)]
1832 pub preset: String,
1833 #[serde(default)]
1834 pub stream_name: String,
1835 #[serde(default, skip_serializing_if = "Option::is_none")]
1836 pub travel_direction: Option<ShotDirection>,
1837 #[serde(default, skip_serializing_if = "Option::is_none")]
1838 pub shot_index: Option<u32>,
1839 #[serde(default)]
1840 pub take_number: u32,
1841 #[serde(default, alias = "clipId", alias = "cueId")]
1842 pub entry_id: String,
1843 #[serde(default, alias = "shotListId")]
1844 pub timeline_id: String,
1845 #[serde(default, alias = "shotListName")]
1846 pub timeline_name: String,
1847 #[serde(default, alias = "shotListVersion")]
1848 pub timeline_revision: u32,
1849 #[serde(default)]
1850 pub collection_path: Vec<crate::CollectionPathSegment>,
1851 #[serde(default)]
1852 pub requested_at_ms: u64,
1853}
1854impl CommandHandler for SetRecording {
1855 fn execute(self, ctx: CommandContext) -> Result<RecordingRequestId, CommandError> {
1856 let id: RecordingRequestId = self.streamer_id.clone().into();
1857 let req = RecordingRequest {
1858 id: id.clone(),
1859 streamer_id: self.streamer_id,
1860 active: self.active,
1861 capture_kind: self.capture_kind,
1862 rig: self.rig,
1863 preset: self.preset,
1864 stream_name: self.stream_name,
1865 travel_direction: self.travel_direction,
1866 shot_index: self.shot_index,
1867 take_number: self.take_number,
1868 entry_id: self.entry_id,
1869 timeline_id: self.timeline_id,
1870 timeline_name: self.timeline_name,
1871 timeline_revision: self.timeline_revision,
1872 collection_path: self.collection_path,
1873 requested_at_ms: self.requested_at_ms,
1874 };
1875 ctx.emit_set(&req)?;
1876 Ok(id)
1877 }
1878}
1879
1880#[myko_command(RecordingStatusId)]
1882pub struct SetRecordingStatus {
1883 pub streamer_id: String,
1884 pub state: RecordingState,
1885 #[serde(default)]
1886 pub file_name: String,
1887 #[serde(default)]
1890 pub nas_path: String,
1891 #[serde(default)]
1893 pub dropbox_path: String,
1894 #[serde(default)]
1895 pub error: String,
1896 #[serde(default)]
1897 pub started_at_ms: u64,
1898}
1899impl CommandHandler for SetRecordingStatus {
1900 fn execute(self, ctx: CommandContext) -> Result<RecordingStatusId, CommandError> {
1901 let id: RecordingStatusId = self.streamer_id.clone().into();
1902 let st = RecordingStatus {
1903 id: id.clone(),
1904 streamer_id: self.streamer_id,
1905 state: self.state,
1906 file_name: self.file_name,
1907 nas_path: self.nas_path,
1908 dropbox_path: self.dropbox_path,
1909 error: self.error,
1910 started_at_ms: self.started_at_ms,
1911 };
1912 ctx.emit_set(&st)?;
1913 Ok(id)
1914 }
1915}
1916
1917#[myko_command(ViewerId)]
1919pub struct JoinStream {
1920 pub stream_id: StreamId,
1921 pub viewer_id: String,
1922 pub name: String,
1923 pub color: String,
1924 #[serde(default, skip_serializing_if = "Option::is_none")]
1925 pub identity_issuer: Option<String>,
1926 #[serde(default, skip_serializing_if = "Option::is_none")]
1927 pub identity_subject: Option<String>,
1928 #[serde(default, skip_serializing_if = "Option::is_none")]
1929 pub avatar_url: Option<String>,
1930}
1931
1932impl CommandHandler for JoinStream {
1933 fn execute(self, ctx: CommandContext) -> Result<ViewerId, CommandError> {
1934 let client_id = ctx
1935 .client_id()
1936 .map(|id| myko::entities::client::ClientId::from(id.to_owned()));
1937 let conn = client_id
1941 .as_ref()
1942 .map(|c| c.to_string())
1943 .unwrap_or_else(|| self.viewer_id.clone());
1944 let id = Viewer::row_id(&self.stream_id, &conn);
1945 let stream_id = self.stream_id.clone();
1946 let viewer = Viewer {
1947 id: id.clone(),
1948 stream_id: self.stream_id,
1949 viewer_id: self.viewer_id,
1950 name: self.name,
1951 color: self.color,
1952 identity_issuer: self.identity_issuer,
1953 identity_subject: self.identity_subject,
1954 avatar_url: self.avatar_url,
1955 cursor: None,
1956 client_id,
1958 };
1959 ctx.emit_set(&viewer)?;
1960 reconcile_control(&ctx, &stream_id)?;
1963 Ok(id)
1964 }
1965}
1966
1967#[myko_command]
1974pub struct LeaveStream {
1975 pub stream_id: StreamId,
1976 pub viewer_id: String,
1977}
1978
1979impl CommandHandler for LeaveStream {
1980 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
1981 let conn = ctx
1982 .client_id()
1983 .map(|client_id| client_id.to_string())
1984 .unwrap_or(self.viewer_id);
1985 let id = Viewer::row_id(&self.stream_id, &conn);
1986 if let Some(viewer) = ctx.exec_report(GetViewerById { id })? {
1987 ctx.emit_del(&*viewer)?;
1988 }
1989 reconcile_control(&ctx, &self.stream_id)
1990 }
1991}
1992
1993#[myko_command]
1995pub struct UpdateCursor {
1996 pub stream_id: StreamId,
1997 pub viewer_id: String,
1998 pub cursor: Option<(f32, f32)>,
1999}
2000
2001impl CommandHandler for UpdateCursor {
2002 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
2003 let conn = ctx
2005 .client_id()
2006 .map(|c| c.to_string())
2007 .unwrap_or_else(|| self.viewer_id.clone());
2008 let id = Viewer::row_id(&self.stream_id, &conn);
2009 if let Some(viewer) = ctx.exec_report(GetViewerById { id })? {
2010 let updated = Viewer {
2011 cursor: self.cursor,
2012 ..(*viewer).clone()
2013 };
2014 ctx.emit_set(&updated)?;
2015 }
2016 Ok(())
2017 }
2018}
2019
2020#[myko_command(ControlLockId)]
2022pub struct AcquireControl {
2023 pub stream_id: StreamId,
2024 pub viewer_id: String,
2025}
2026
2027impl CommandHandler for AcquireControl {
2028 fn execute(self, ctx: CommandContext) -> Result<ControlLockId, CommandError> {
2029 let id = ControlLock::row_id(&self.stream_id);
2030 let lock = ControlLock {
2031 id: id.clone(),
2032 stream_id: self.stream_id,
2033 viewer_id: self.viewer_id,
2034 client_id: ctx
2035 .client_id()
2036 .map(|id| myko::entities::client::ClientId::from(id.to_owned())),
2037 };
2038 ctx.emit_set(&lock)?;
2039 Ok(id)
2040 }
2041}
2042
2043#[myko_command]
2046pub struct ReleaseControl {
2047 pub stream_id: StreamId,
2048 pub viewer_id: String,
2049}
2050
2051impl CommandHandler for ReleaseControl {
2052 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
2053 let id = ControlLock::row_id(&self.stream_id);
2054 if let Some(lock) = ctx.exec_report(GetControlLockById { id })? {
2055 if lock.viewer_id == self.viewer_id {
2056 ctx.emit_del(&*lock)?;
2057 }
2058 }
2059 Ok(())
2060 }
2061}
2062
2063fn people_present(viewers: &[Arc<Viewer>]) -> Vec<&str> {
2080 let mut people: Vec<&str> = viewers.iter().map(|v| v.viewer_id.as_str()).collect();
2081 people.sort_unstable();
2082 people.dedup();
2083 people
2084}
2085
2086fn viewer_is_live(ctx: &CommandContext, viewer: &Viewer) -> Result<bool, CommandError> {
2097 let Some(client_id) = viewer.client_id.clone() else {
2098 return Ok(false);
2099 };
2100 Ok(ctx.exec_report(ClientStatus { client_id })?.online)
2101}
2102
2103fn reconcile_control(ctx: &CommandContext, stream_id: &StreamId) -> Result<(), CommandError> {
2104 let stored: Vec<Arc<Viewer>> = ctx.exec_query(GetViewersByQuery(ViewerQuery {
2105 stream_id: Some(IdFilter::Eq(stream_id.clone())),
2106 ..Default::default()
2107 }))?;
2108 let mut viewers = Vec::with_capacity(stored.len());
2113 for viewer in stored {
2114 if viewer_is_live(ctx, &viewer)? {
2115 viewers.push(viewer);
2116 }
2117 }
2118 let id = ControlLock::row_id(stream_id);
2119
2120 if let Some(lock) = ctx.exec_report(GetControlLockById { id: id.clone() })? {
2122 let holder_present = viewers.iter().any(|v| v.viewer_id == lock.viewer_id);
2123 if !holder_present {
2124 ctx.emit_del(&*lock)?;
2125 }
2126 }
2127
2128 let people = people_present(&viewers);
2130 if let [sole_vid] = people.as_slice() {
2131 let held_by_sole = ctx
2132 .exec_report(GetControlLockById { id: id.clone() })?
2133 .as_deref()
2134 .is_some_and(|l| l.viewer_id.as_str() == *sole_vid);
2135 if !held_by_sole {
2136 let conn = viewers
2138 .iter()
2139 .find(|v| v.viewer_id.as_str() == *sole_vid)
2140 .expect("present");
2141 ctx.emit_set(&ControlLock {
2142 id,
2143 stream_id: stream_id.clone(),
2144 viewer_id: (*sole_vid).to_string(),
2145 client_id: conn.client_id.clone(),
2146 })?;
2147 }
2148 }
2149 Ok(())
2150}
2151
2152#[myko_command]
2155pub struct AutoAssignControl {
2156 pub stream_id: StreamId,
2157}
2158
2159impl CommandHandler for AutoAssignControl {
2160 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
2161 reconcile_control(&ctx, &self.stream_id)
2162 }
2163}
2164
2165#[myko_command(FrameCaptureRequestId)]
2173pub struct SubmitFrameCapture {
2174 pub streamer_id: String,
2175 pub capture_id: String,
2176 pub command_id: String,
2177 #[ts(type = "unknown")]
2178 pub capture_context: crate::CaptureContext,
2179 pub target: crate::FrameCaptureTarget,
2180 #[serde(default)]
2182 pub force: bool,
2183 #[serde(default)]
2184 pub requested_at_ms: u64,
2185}
2186
2187impl CommandHandler for SubmitFrameCapture {
2188 fn execute(self, ctx: CommandContext) -> Result<FrameCaptureRequestId, CommandError> {
2189 if self.streamer_id.trim().is_empty() {
2190 return Err(command_error(&ctx, "Frame capture requires a stream"));
2191 }
2192 if self.capture_id.trim().is_empty() {
2193 return Err(command_error(&ctx, "Frame capture requires a capture id"));
2194 }
2195 if self.target.cluster_name.trim().is_empty() {
2196 return Err(command_error(
2197 &ctx,
2198 "Frame capture requires an explicit target cluster",
2199 ));
2200 }
2201 let id: FrameCaptureRequestId = self.streamer_id.clone().into();
2204 ctx.emit_set(&crate::FrameCaptureRequest {
2205 id: id.clone(),
2206 streamer_id: self.streamer_id,
2207 capture_id: self.capture_id,
2208 command_id: self.command_id,
2209 capture_context: self.capture_context.into(),
2210 target: self.target,
2211 force: self.force,
2212 requested_at_ms: self.requested_at_ms,
2213 })?;
2214 Ok(id)
2215 }
2216}
2217
2218#[myko_command(FrameCaptureStatusId)]
2220pub struct SetFrameCaptureStatus {
2221 pub streamer_id: String,
2222 #[serde(default)]
2223 pub capture_id: String,
2224 pub phase: crate::FrameCapturePhase,
2225 #[serde(default, skip_serializing_if = "Option::is_none")]
2226 #[ts(type = "unknown")]
2227 pub capture_context: Option<crate::CaptureContext>,
2228 #[serde(default)]
2229 pub target: crate::FrameCaptureTarget,
2230 #[serde(default)]
2231 pub observed_generation: String,
2232 #[serde(default)]
2233 pub receipts: Vec<crate::FrameCaptureReceipt>,
2234 #[serde(default)]
2235 pub error: String,
2236 #[serde(default)]
2237 pub forceable: bool,
2238 #[serde(default)]
2239 pub updated_at_ms: u64,
2240}
2241
2242impl CommandHandler for SetFrameCaptureStatus {
2243 fn execute(self, ctx: CommandContext) -> Result<FrameCaptureStatusId, CommandError> {
2244 let id: FrameCaptureStatusId = self.streamer_id.clone().into();
2245 ctx.emit_set(&crate::FrameCaptureStatus {
2246 id: id.clone(),
2247 streamer_id: self.streamer_id,
2248 capture_id: self.capture_id,
2249 phase: self.phase,
2250 capture_context: self.capture_context.map(StoredCaptureContext::from),
2251 target: self.target,
2252 observed_generation: self.observed_generation,
2253 receipts: self.receipts,
2254 error: self.error,
2255 forceable: self.forceable,
2256 updated_at_ms: self.updated_at_ms,
2257 })?;
2258 Ok(id)
2259 }
2260}
2261
2262#[myko_command(FrameCaptureTargetSummaryId)]
2266pub struct SetFrameCaptureTargetSummary {
2267 pub cluster_name: String,
2268 #[serde(default)]
2269 pub generation: String,
2270 #[serde(default)]
2271 pub capturable: bool,
2272 #[serde(default)]
2273 pub status: String,
2274 #[serde(default)]
2275 pub updated_at_ms: u64,
2276}
2277
2278impl CommandHandler for SetFrameCaptureTargetSummary {
2279 fn execute(self, ctx: CommandContext) -> Result<FrameCaptureTargetSummaryId, CommandError> {
2280 if self.cluster_name.trim().is_empty() {
2281 return Err(command_error(
2282 &ctx,
2283 "Target summary requires a cluster name",
2284 ));
2285 }
2286 let id: FrameCaptureTargetSummaryId = self.cluster_name.clone().into();
2287 let summary = crate::FrameCaptureTargetSummary {
2288 id: id.clone(),
2289 cluster_name: self.cluster_name,
2290 generation: self.generation,
2291 capturable: self.capturable,
2292 status: self.status,
2293 updated_at_ms: self.updated_at_ms,
2294 };
2295 let unchanged = ctx
2300 .exec_query_first(GetFrameCaptureTargetSummarysByIds {
2301 ids: vec![id.clone()],
2302 })?
2303 .is_some_and(|existing| target_summary_is_heartbeat_only(&summary, &existing));
2304 if unchanged {
2305 return Ok(id);
2306 }
2307 ctx.emit_set(&summary)?;
2308 Ok(id)
2309 }
2310}
2311
2312#[cfg(test)]
2313mod discovery_tests {
2314 use super::*;
2315 use crate::shot::DEFAULT_SHOT_LIBRARY_ID;
2316
2317 fn tuned_legacy_shot() -> Shot {
2318 Shot {
2319 id: ShotId::from("render-11:moving:Floor Dolly"),
2320 library_id: String::new(),
2321 streamer_id: "render-11".to_owned(),
2322 name: "Floor Dolly".to_owned(),
2323 kind: ShotKind::Moving,
2324 target_name: "Floor Dolly".to_owned(),
2325 translation_speed_cm_s: 17.0,
2326 rotation_speed_deg_s: 3.5,
2327 hold_duration_ms: 8_000,
2328 travel_duration_ms: 41_000,
2329 default_entry_mode: ShotEntryMode::Both,
2330 shot_index: 9,
2331 }
2332 }
2333
2334 fn legacy_timeline(id: &str, streamer_id: &str, name: &str, shot_id: &str) -> Timeline {
2335 Timeline {
2336 id: TimelineId::from(id),
2337 library_id: String::new(),
2338 streamer_id: streamer_id.to_owned(),
2339 name: name.to_owned(),
2340 revision: 0,
2341 entries: if shot_id.is_empty() {
2342 Vec::new()
2343 } else {
2344 vec![ShotEntry {
2345 shot_id: shot_id.to_owned(),
2346 direction: ShotEntryMode::Forward,
2347 ..Default::default()
2348 }]
2349 },
2350 sort_order: 0,
2351 }
2352 }
2353
2354 #[test]
2355 fn discovery_creates_only_missing_targets_and_preserves_tuned_legacy_rows() {
2356 let existing = vec![tuned_legacy_shot()];
2357 let discovered = vec![
2358 ShotDiscovery {
2359 name: "Floor Dolly".to_owned(),
2360 kind: ShotKind::Moving,
2361 target_name: "Floor Dolly".to_owned(),
2362 },
2363 ShotDiscovery {
2364 name: "Hero Push Forward".to_owned(),
2365 kind: ShotKind::Moving,
2366 target_name: "Hero Push Forward".to_owned(),
2367 },
2368 ShotDiscovery {
2369 name: "Hero Push Forward".to_owned(),
2370 kind: ShotKind::Moving,
2371 target_name: "Hero Push Forward".to_owned(),
2372 },
2373 ];
2374
2375 let created = discovered_shots_to_create(DEFAULT_SHOT_LIBRARY_ID, discovered, &existing);
2376 assert_eq!(created.len(), 1);
2377 assert_eq!(created[0].name, "Hero Push Forward");
2378 assert_eq!(created[0].shot_index, 10);
2379 assert_eq!(
2380 created[0].translation_speed_cm_s,
2381 DEFAULT_SHOT_TRANSLATION_SPEED_CM_S
2382 );
2383 assert_eq!(
2384 created[0].rotation_speed_deg_s,
2385 DEFAULT_SHOT_ROTATION_SPEED_DEG_S
2386 );
2387 assert_eq!(created[0].default_entry_mode, ShotEntryMode::Forward);
2388 assert_eq!(existing[0].translation_speed_cm_s, 17.0);
2389 assert_eq!(existing[0].rotation_speed_deg_s, 3.5);
2390 }
2391
2392 #[test]
2393 fn discovery_reconciles_live_legacy_timeline_duplicates_without_losing_clips() {
2394 let render_shot = tuned_legacy_shot();
2395 let mut studio_shot = tuned_legacy_shot();
2396 studio_shot.id = ShotId::from("Studio A:moving:Floor Dolly");
2397 studio_shot.streamer_id = "Studio A".to_owned();
2398 let timelines = vec![
2399 legacy_timeline(
2400 "render-11:timeline:default",
2401 "render-11",
2402 "Default shot list",
2403 render_shot.id.as_ref(),
2404 ),
2405 legacy_timeline(
2406 "Studio A:timeline:default",
2407 "Studio A",
2408 "Default timeline",
2409 studio_shot.id.as_ref(),
2410 ),
2411 legacy_timeline(
2412 "timeline-old",
2413 "render-11",
2414 "Supercut",
2415 render_shot.id.as_ref(),
2416 ),
2417 Timeline {
2418 id: TimelineId::from("timeline-shared"),
2419 library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
2420 streamer_id: "Studio A".to_owned(),
2421 name: " superCUT ".to_owned(),
2422 revision: 2,
2423 entries: vec![ShotEntry {
2424 shot_id: studio_shot.id.to_string(),
2425 direction: ShotEntryMode::Reverse,
2426 ..Default::default()
2427 }],
2428 sort_order: 1,
2429 },
2430 ];
2431
2432 let result = reconcile_timelines(
2433 DEFAULT_SHOT_LIBRARY_ID,
2434 &[render_shot, studio_shot],
2435 timelines,
2436 );
2437
2438 assert_eq!(result.upserts.len(), 2);
2439 assert_eq!(result.deletes.len(), 3);
2440 let default = result
2441 .upserts
2442 .iter()
2443 .find(|timeline| timeline.id.to_string() == Timeline::shared_legacy_default_id())
2444 .expect("canonical shared default");
2445 assert_eq!(default.library_id, DEFAULT_SHOT_LIBRARY_ID);
2446 assert!(default.streamer_id.is_empty());
2447 assert_eq!(default.name, "Migrated timeline");
2448 assert_eq!(default.entries.len(), 1);
2449 assert_eq!(default.entries[0].shot_id, "Studio A:moving:Floor Dolly");
2450 assert_eq!(result.id_migrations.len(), 3);
2451 assert_eq!(
2452 result.id_migrations.get("render-11:timeline:default"),
2453 Some(&Timeline::shared_legacy_default_id())
2454 );
2455
2456 let supercut = result
2457 .upserts
2458 .iter()
2459 .find(|timeline| timeline.id.as_ref() == "timeline-shared")
2460 .expect("explicit shared timeline wins");
2461 assert_eq!(supercut.name, "superCUT");
2462 assert_eq!(supercut.revision, 2);
2463 assert_eq!(supercut.entries.len(), 2);
2464 assert_eq!(supercut.entries[0].direction, ShotEntryMode::Reverse);
2465 assert_eq!(supercut.entries[1].direction, ShotEntryMode::Forward);
2466 assert!(supercut
2467 .entries
2468 .iter()
2469 .all(|entry| !entry.entry_id.is_empty()));
2470 }
2471
2472 #[test]
2473 fn reconciliation_is_idempotent_for_canonical_timelines() {
2474 let shot = tuned_legacy_shot();
2475 let mut timeline = Timeline {
2476 id: TimelineId::from("shared:timeline:supercut"),
2477 library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
2478 streamer_id: String::new(),
2479 name: "Supercut".to_owned(),
2480 revision: 1,
2481 entries: vec![ShotEntry {
2482 shot_id: shot.id.to_string(),
2483 direction: ShotEntryMode::Forward,
2484 ..Default::default()
2485 }],
2486 sort_order: 0,
2487 };
2488 timeline.normalize_entries();
2489 assert!(timeline.backfill_entry_parameters(std::slice::from_ref(&shot)));
2490 assert!(!timeline.backfill_entry_parameters(std::slice::from_ref(&shot)));
2491 let result = reconcile_timelines(DEFAULT_SHOT_LIBRARY_ID, &[shot], vec![timeline]);
2492 assert!(result.upserts.is_empty());
2493 assert!(result.deletes.is_empty());
2494 }
2495
2496 #[test]
2497 fn reconciliation_snapshots_only_missing_legacy_clip_parameters() {
2498 let shot = tuned_legacy_shot();
2499 let mut timeline =
2500 legacy_timeline("shared:timeline:supercut", "", "Supercut", shot.id.as_ref());
2501 timeline.library_id = DEFAULT_SHOT_LIBRARY_ID.to_owned();
2502 timeline.entries[0].translation_speed_cm_s = Some(4.5);
2503
2504 let result = reconcile_timelines(
2505 DEFAULT_SHOT_LIBRARY_ID,
2506 std::slice::from_ref(&shot),
2507 vec![timeline],
2508 );
2509 assert_eq!(result.upserts.len(), 1);
2510 let entry = &result.upserts[0].entries[0];
2511 assert_eq!(entry.translation_speed_cm_s, Some(4.5));
2512 assert_eq!(entry.rotation_speed_deg_s, Some(shot.rotation_speed_deg_s));
2513 assert_eq!(entry.hold_duration_ms, Some(shot.hold_duration_ms));
2514 assert_eq!(
2515 entry.travel_duration_ms,
2516 Some(shot.effective_travel_duration_ms())
2517 );
2518 assert_eq!(entry.shot_index, Some(shot.shot_index));
2519 }
2520}
2521
2522#[cfg(test)]
2523mod heartbeat_suppression_tests {
2524 use super::*;
2525 use crate::recording_job_status::RecordingJobStatus;
2526
2527 fn status() -> RecordingJobStatus {
2528 RecordingJobStatus {
2529 id: "render-13".into(),
2530 streamer_id: "render-13".to_owned(),
2531 job_id: "job-1".to_owned(),
2532 capture_context: None,
2533 timeline_id: "supercut".to_owned(),
2534 timeline_name: "Supercut".to_owned(),
2535 timeline_revision: 1,
2536 collection_id: String::new(),
2537 collection_path: Vec::new(),
2538 phase: RecordingJobPhase::Traveling,
2539 pause_requested: false,
2540 entries: Vec::new(),
2541 index: 0,
2542 completed: 0,
2543 error: String::new(),
2544 updated_at_ms: 1_000,
2545 elapsed_ms: 5_000,
2546 estimated_total_ms: 10_000,
2547 estimated_remaining_ms: 5_000,
2548 takes: Vec::new(),
2549 started_at_ms: 500,
2550 }
2551 }
2552
2553 fn summary() -> crate::FrameCaptureTargetSummary {
2554 crate::FrameCaptureTargetSummary {
2555 id: "0of12_rx11".into(),
2556 cluster_name: "0of12_rx11".to_owned(),
2557 generation: "3350".to_owned(),
2558 capturable: false,
2559 status: "stopped".to_owned(),
2560 updated_at_ms: 1_000,
2561 }
2562 }
2563
2564 #[test]
2565 fn a_newer_clock_alone_is_not_a_change() {
2566 let stored = status();
2567 let mut republished = stored.clone();
2568 republished.updated_at_ms = 9_999;
2569 assert!(recording_job_status_is_heartbeat_only(
2570 &republished,
2571 &stored
2572 ));
2573 }
2574
2575 #[test]
2576 fn real_progress_still_writes() {
2577 let stored = status();
2578 for mutate in [
2579 (|s: &mut RecordingJobStatus| s.phase = RecordingJobPhase::Complete)
2580 as fn(&mut RecordingJobStatus),
2581 |s: &mut RecordingJobStatus| s.elapsed_ms = 6_000,
2582 |s: &mut RecordingJobStatus| s.completed = 1,
2583 |s: &mut RecordingJobStatus| s.error = "disk full".to_owned(),
2584 |s: &mut RecordingJobStatus| s.pause_requested = true,
2585 |s: &mut RecordingJobStatus| s.estimated_remaining_ms = 4_000,
2586 ] {
2587 let mut candidate = stored.clone();
2588 candidate.updated_at_ms = 9_999;
2589 mutate(&mut candidate);
2590 assert!(
2591 !recording_job_status_is_heartbeat_only(&candidate, &stored),
2592 "a changed field must still be written"
2593 );
2594 }
2595 }
2596
2597 #[test]
2598 fn target_summaries_follow_the_same_rule() {
2599 let stored = summary();
2600 let mut polled = stored.clone();
2601 polled.updated_at_ms = 9_999;
2602 assert!(target_summary_is_heartbeat_only(&polled, &stored));
2603
2604 for mutate in [
2605 (|s: &mut crate::FrameCaptureTargetSummary| s.capturable = true)
2606 as fn(&mut crate::FrameCaptureTargetSummary),
2607 |s: &mut crate::FrameCaptureTargetSummary| s.generation = "3351".to_owned(),
2608 |s: &mut crate::FrameCaptureTargetSummary| s.status = "running".to_owned(),
2609 ] {
2610 let mut candidate = stored.clone();
2611 candidate.updated_at_ms = 9_999;
2612 mutate(&mut candidate);
2613 assert!(!target_summary_is_heartbeat_only(&candidate, &stored));
2614 }
2615 }
2616}
2617
2618#[cfg(test)]
2619mod presence_tests {
2620 use std::sync::Arc;
2621
2622 use super::people_present;
2623 use crate::viewer::Viewer;
2624
2625 fn viewer(viewer_id: &str, client: &str) -> Arc<Viewer> {
2626 Arc::new(Viewer {
2627 id: format!("s1:{client}").into(),
2628 stream_id: "s1".into(),
2629 viewer_id: viewer_id.to_owned(),
2630 name: "Anonymous Cheetah".to_owned(),
2631 color: "#F87171".to_owned(),
2632 identity_issuer: None,
2633 identity_subject: None,
2634 avatar_url: None,
2635 cursor: None,
2636 client_id: Some(client.to_owned().into()),
2637 })
2638 }
2639
2640 #[test]
2641 fn tabs_of_one_person_are_one_person() {
2642 let viewers = vec![viewer("max", "conn-a"), viewer("max", "conn-b")];
2644 assert_eq!(people_present(&viewers), vec!["max"]);
2645 }
2646
2647 #[test]
2648 fn distinct_people_are_counted_separately_and_sorted() {
2649 let viewers = vec![
2650 viewer("zoe", "conn-c"),
2651 viewer("max", "conn-a"),
2652 viewer("max", "conn-b"),
2653 ];
2654 assert_eq!(people_present(&viewers), vec!["max", "zoe"]);
2655 }
2656
2657 #[test]
2658 fn a_stream_whose_connections_all_died_has_nobody_present() {
2659 assert!(people_present(&[]).is_empty());
2664 }
2665}