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, GetShotsByQuery, Shot, ShotDiscovery, ShotId, ShotKind, ShotQuery,
46 DEFAULT_SHOT_HOLD_DURATION_MS, DEFAULT_SHOT_LIBRARY_ID, DEFAULT_SHOT_ROTATION_SPEED_DEG_S,
47 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
668#[myko_command(TimelineId)]
671pub struct RemoveTimeline {
672 #[serde(alias = "shotListId")]
673 pub timeline_id: TimelineId,
674}
675
676impl CommandHandler for RemoveTimeline {
677 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
678 let id = self.timeline_id;
679 let current = ctx
680 .exec_query_first(GetTimelinesByIds {
681 ids: vec![id.clone()],
682 })?
683 .ok_or_else(|| command_error(&ctx, format!("Timeline {id} does not exist")))?;
684 for membership in ctx
685 .exec_query(GetCollectionMembershipsByQuery(
686 CollectionMembershipQuery::default(),
687 ))?
688 .into_iter()
689 .filter(|membership| membership.timeline_id == id.to_string())
690 {
691 ctx.emit_del(membership.as_ref())?;
692 }
693 ctx.emit_del(current.as_ref())?;
694 Ok(id)
695 }
696}
697
698#[myko_command(CollectionId)]
702pub struct SetCollection {
703 pub collection_id: CollectionId,
704 #[serde(default)]
705 pub library_id: String,
706 #[serde(default)]
707 pub parent_id: String,
708 pub name: String,
709 #[serde(default)]
710 pub sort_order: u32,
711 #[serde(default)]
712 pub metadata: HashMap<String, String>,
713}
714
715impl CommandHandler for SetCollection {
716 fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
717 let id = self.collection_id;
718 let library_id = effective_library_id(&self.library_id).to_owned();
719 let name = self.name.trim().to_owned();
720 if name.is_empty() {
721 return Err(command_error(&ctx, "Collection name cannot be empty"));
722 }
723 if name.chars().count() > MAX_COLLECTION_NAME_CHARS {
724 return Err(command_error(
725 &ctx,
726 format!("Collection names are limited to {MAX_COLLECTION_NAME_CHARS} characters"),
727 ));
728 }
729 let mut collections = ctx
730 .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
731 .into_iter()
732 .map(|collection| collection.as_ref().clone())
733 .collect::<Vec<_>>();
734 if !collections.iter().any(|collection| collection.id == id)
735 && !crate::collection::is_uuid_v7(id.as_ref())
736 {
737 if require_uuid_v7_collection_ids() {
738 return Err(command_error(
739 &ctx,
740 "New collection IDs must be UUIDv7; existing legacy collections remain editable",
741 ));
742 }
743 eprintln!(
744 "accepted legacy native collection id during UUIDv7 rollout: {}",
745 id.as_ref()
746 );
747 }
748 if collections.iter().any(|collection| {
749 collection.id != id
750 && collection.library_id == library_id
751 && collection.parent_id == self.parent_id
752 && normalized_collection_name(&collection.name) == normalized_collection_name(&name)
753 }) {
754 return Err(command_error(
755 &ctx,
756 format!("A collection named ‘{name}’ already exists here"),
757 ));
758 }
759 if !self.parent_id.trim().is_empty() {
760 let parent = collections
761 .iter()
762 .find(|collection| collection.id.to_string() == self.parent_id)
763 .ok_or_else(|| command_error(&ctx, "Parent collection does not exist"))?;
764 if parent.library_id != library_id {
765 return Err(command_error(
766 &ctx,
767 "A collection cannot be moved between libraries",
768 ));
769 }
770 if parent.id == id {
771 return Err(command_error(&ctx, "A collection cannot contain itself"));
772 }
773 let parent_path = resolve_collection_path(&self.parent_id, &collections)
774 .map_err(|error| command_error(&ctx, error))?;
775 if parent_path.len() >= MAX_COLLECTION_DEPTH {
776 return Err(command_error(
777 &ctx,
778 format!("Collections are limited to {MAX_COLLECTION_DEPTH} levels"),
779 ));
780 }
781 if parent_path
782 .iter()
783 .any(|segment| segment.collection_id == id.to_string())
784 {
785 return Err(command_error(
786 &ctx,
787 "A collection cannot be moved inside one of its descendants",
788 ));
789 }
790 }
791 let collection = Collection {
792 id: id.clone(),
793 library_id,
794 parent_id: self.parent_id,
795 name,
796 sort_order: self.sort_order,
797 metadata: self.metadata,
798 };
799 if let Some(current) = collections.iter_mut().find(|row| row.id == id) {
800 *current = collection.clone();
801 } else {
802 collections.push(collection.clone());
803 }
804 resolve_collection_path(id.as_ref(), &collections)
806 .map_err(|error| command_error(&ctx, error))?;
807 ctx.emit_set(&collection)?;
808 Ok(id)
809 }
810}
811
812#[myko_command(CollectionId)]
816pub struct RemoveCollection {
817 pub collection_id: CollectionId,
818}
819
820impl CommandHandler for RemoveCollection {
821 fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
822 let id = self.collection_id;
823 let current = ctx
824 .exec_query_first(GetCollectionsByIds {
825 ids: vec![id.clone()],
826 })?
827 .ok_or_else(|| command_error(&ctx, format!("Collection {id} does not exist")))?;
828 let has_children = ctx
829 .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
830 .into_iter()
831 .any(|collection| collection.parent_id == id.to_string());
832 if has_children {
833 return Err(command_error(
834 &ctx,
835 "Move or remove child collections before deleting this collection",
836 ));
837 }
838 for membership in ctx
839 .exec_query(GetCollectionMembershipsByQuery(
840 CollectionMembershipQuery::default(),
841 ))?
842 .into_iter()
843 .filter(|membership| membership.collection_id == id.to_string())
844 {
845 ctx.emit_del(membership.as_ref())?;
846 }
847 ctx.emit_del(current.as_ref())?;
848 Ok(id)
849 }
850}
851
852#[myko_command(CollectionMembershipId)]
854pub struct SetCollectionMembership {
855 pub collection_id: String,
856 #[serde(alias = "shotListId")]
857 pub timeline_id: String,
858 pub included: bool,
859 #[serde(default)]
860 pub sort_order: u32,
861}
862
863impl CommandHandler for SetCollectionMembership {
864 fn execute(self, ctx: CommandContext) -> Result<CollectionMembershipId, CommandError> {
865 let id = CollectionMembershipId::from(CollectionMembership::stable_id(
866 &self.collection_id,
867 &self.timeline_id,
868 ));
869 let existing = ctx.exec_query_first(GetCollectionMembershipsByIds {
870 ids: vec![id.clone()],
871 })?;
872 if !self.included {
873 if let Some(existing) = existing {
874 ctx.emit_del(existing.as_ref())?;
875 }
876 return Ok(id);
877 }
878 let collection = ctx
879 .exec_query_first(GetCollectionsByIds {
880 ids: vec![CollectionId::from(self.collection_id.clone())],
881 })?
882 .ok_or_else(|| command_error(&ctx, "Collection does not exist"))?;
883 let timeline = ctx
884 .exec_query_first(GetTimelinesByIds {
885 ids: vec![TimelineId::from(self.timeline_id.clone())],
886 })?
887 .ok_or_else(|| command_error(&ctx, "Timeline does not exist"))?;
888 if collection.library_id != timeline.effective_library_id() {
889 return Err(command_error(
890 &ctx,
891 "Collection and timeline belong to different libraries",
892 ));
893 }
894 ctx.emit_set(&CollectionMembership {
895 id: id.clone(),
896 collection_id: self.collection_id,
897 timeline_id: self.timeline_id,
898 sort_order: self.sort_order,
899 })?;
900 Ok(id)
901 }
902}
903
904#[myko_command(TimelineId)]
908pub struct ImportTimelineOtio {
909 pub otio_json: String,
910 pub sort_order: u32,
911}
912
913impl CommandHandler for ImportTimelineOtio {
914 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
915 if self.otio_json.len() > MAX_OTIO_IMPORT_BYTES {
916 return Err(command_error(
917 &ctx,
918 format!(
919 "OTIO import exceeds the {} byte limit",
920 MAX_OTIO_IMPORT_BYTES
921 ),
922 ));
923 }
924 let imported = crate::import_timeline_otio_json(&self.otio_json)
925 .map_err(|error| command_error(&ctx, error.to_string()))?;
926 if imported.shots.len() > 10_000 {
927 return Err(command_error(&ctx, "OTIO import contains too many shots"));
928 }
929 ensure_unique_timeline_name(
930 &ctx,
931 imported.timeline.effective_library_id(),
932 &imported.timeline.id,
933 &imported.timeline.name,
934 )?;
935
936 let mut timeline = imported.timeline;
937 timeline.backfill_entry_parameters(&imported.shots);
938 for shot in imported.shots {
939 ctx.emit_set(&shot)?;
940 }
941
942 if let Some(current) = ctx.exec_query_first(GetTimelinesByIds {
943 ids: vec![timeline.id.clone()],
944 })? {
945 timeline.revision = timeline.revision.max(current.next_revision());
946 }
947 timeline.sort_order = self.sort_order;
948 timeline.normalize_entries();
949 let id = timeline.id.clone();
950 ctx.emit_set(&timeline)?;
951 Ok(id)
952 }
953}
954
955#[myko_command(CollectionId)]
958pub struct ImportCollectionOtio {
959 pub otio_json: String,
960}
961
962impl CommandHandler for ImportCollectionOtio {
963 fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
964 if self.otio_json.len() > MAX_OTIO_IMPORT_BYTES {
965 return Err(command_error(
966 &ctx,
967 format!(
968 "OTIO import exceeds the {} byte limit",
969 MAX_OTIO_IMPORT_BYTES
970 ),
971 ));
972 }
973 let mut imported = crate::import_collection_otio_json(&self.otio_json)
974 .map_err(|error| command_error(&ctx, error.to_string()))?;
975 if imported.collections.is_empty() {
976 return Err(command_error(&ctx, "OTIO collection is empty"));
977 }
978 if imported.collections.len() > 10_000
979 || imported.memberships.len() > 100_000
980 || imported.timelines.len() > 10_000
981 || imported.shots.len() > 10_000
982 {
983 return Err(command_error(&ctx, "OTIO collection exceeds import limits"));
984 }
985 let root_id = imported.collections[0].id.clone();
986 let imported_collection_ids = imported
987 .collections
988 .iter()
989 .map(|collection| collection.id.to_string())
990 .collect::<HashSet<_>>();
991 let existing_collections = ctx
992 .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
993 .into_iter()
994 .map(|collection| collection.as_ref().clone())
995 .collect::<Vec<_>>();
996 for collection in &imported.collections {
997 let duplicate = imported
998 .collections
999 .iter()
1000 .chain(
1001 existing_collections
1002 .iter()
1003 .filter(|existing| !imported_collection_ids.contains(existing.id.as_ref())),
1004 )
1005 .any(|candidate| {
1006 candidate.id != collection.id
1007 && candidate.library_id == collection.library_id
1008 && candidate.parent_id == collection.parent_id
1009 && normalized_collection_name(&candidate.name)
1010 == normalized_collection_name(&collection.name)
1011 });
1012 if duplicate {
1013 return Err(command_error(
1014 &ctx,
1015 format!(
1016 "A collection named ‘{}’ already exists at the imported location",
1017 collection.name
1018 ),
1019 ));
1020 }
1021 }
1022
1023 let imported_timeline_ids = imported
1024 .timelines
1025 .iter()
1026 .map(|timeline| timeline.id.to_string())
1027 .collect::<HashSet<_>>();
1028 let existing_timelines = ctx
1029 .exec_query(GetTimelinesByQuery(TimelineQuery::default()))?
1030 .into_iter()
1031 .map(|timeline| timeline.as_ref().clone())
1032 .collect::<Vec<_>>();
1033 for timeline in &imported.timelines {
1034 let duplicate = imported
1035 .timelines
1036 .iter()
1037 .chain(
1038 existing_timelines
1039 .iter()
1040 .filter(|existing| !imported_timeline_ids.contains(existing.id.as_ref())),
1041 )
1042 .any(|candidate| {
1043 candidate.id != timeline.id
1044 && candidate.effective_library_id() == timeline.effective_library_id()
1045 && normalized_timeline_name(&candidate.name)
1046 == normalized_timeline_name(&timeline.name)
1047 });
1048 if duplicate {
1049 return Err(command_error(
1050 &ctx,
1051 format!("A timeline named ‘{}’ already exists", timeline.name),
1052 ));
1053 }
1054 }
1055
1056 for shot in imported.shots {
1057 ctx.emit_set(&shot)?;
1058 }
1059 for timeline in &mut imported.timelines {
1060 timeline.normalize_entries();
1061 if let Some(current) = existing_timelines
1062 .iter()
1063 .find(|current| current.id == timeline.id)
1064 {
1065 timeline.revision = timeline.revision.max(current.next_revision());
1066 }
1067 ctx.emit_set(timeline)?;
1068 }
1069 for collection in imported.collections {
1070 ctx.emit_set(&collection)?;
1071 }
1072 for membership in imported.memberships {
1073 ctx.emit_set(&membership)?;
1074 }
1075 Ok(root_id)
1076 }
1077}
1078
1079fn command_error(ctx: &CommandContext, message: impl Into<String>) -> CommandError {
1080 CommandError {
1081 tx: ctx.tx().to_string(),
1082 command_id: ctx.command_id.to_string(),
1083 message: message.into(),
1084 }
1085}
1086
1087#[myko_command(RecordingJobRequestId)]
1089pub struct ControlRecordingJob {
1090 pub streamer_id: String,
1091 #[serde(alias = "runId")]
1092 pub job_id: String,
1093 pub command_id: String,
1094 pub action: RecordingJobAction,
1095 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 #[ts(type = "unknown")]
1098 pub capture_context: Option<crate::CaptureContext>,
1099 #[serde(default, alias = "shotListId")]
1102 pub timeline_id: String,
1103 #[serde(default)]
1106 pub collection_id: String,
1107 #[serde(default, alias = "shots", alias = "items")]
1110 pub entries: Vec<ShotEntryPlan>,
1111 #[serde(default)]
1117 pub entry_ids: Vec<String>,
1118 #[serde(default)]
1119 pub preset_duration_ms: u64,
1120 #[serde(default)]
1121 pub translation_speed_cm_s: f32,
1122 #[serde(default)]
1123 pub rotation_speed_deg_s: f32,
1124 #[serde(default)]
1125 pub requested_at_ms: u64,
1126}
1127
1128impl CommandHandler for ControlRecordingJob {
1129 fn execute(self, ctx: CommandContext) -> Result<RecordingJobRequestId, CommandError> {
1130 let (
1131 timeline_id,
1132 timeline_name,
1133 timeline_revision,
1134 collection_id,
1135 collection_path,
1136 entries,
1137 ) = if self.action == RecordingJobAction::StartView {
1138 if self.job_id.trim().is_empty() {
1144 return Err(command_error(
1145 &ctx,
1146 "StartView requires a stable RecordingJob id",
1147 ));
1148 }
1149 let label = self
1150 .entries
1151 .first()
1152 .map(|entry| entry.name.trim().to_owned())
1153 .filter(|name| !name.is_empty())
1154 .unwrap_or_else(|| "freefly".to_owned());
1155 let entry = ShotEntryPlan {
1156 entry_id: "view".to_owned(),
1157 shot_id: String::new(),
1158 name: label,
1159 shot_index: None,
1160 kind: crate::ShotKind::Static,
1161 target_name: String::new(),
1162 translation_speed_cm_s: 0.0,
1163 rotation_speed_deg_s: 0.0,
1164 hold_duration_ms: 0,
1165 travel_duration_ms: 0,
1166 direction: crate::ShotDirection::default(),
1167 next_take_number: 1,
1168 open_ended: true,
1169 };
1170 (
1171 String::new(),
1172 String::new(),
1173 0,
1174 String::new(),
1175 Vec::new(),
1176 vec![entry],
1177 )
1178 } else if self.action == RecordingJobAction::Start {
1179 if self.capture_context.is_none() {
1180 return Err(command_error(
1181 &ctx,
1182 "Start requires editorial capture context",
1183 ));
1184 }
1185 if self.timeline_id.trim().is_empty() {
1186 return Err(CommandError {
1187 tx: ctx.tx().to_string(),
1188 command_id: ctx.command_id.to_string(),
1189 message: "Start requires a persistent Timeline id".to_owned(),
1190 });
1191 }
1192 if self.job_id.trim().is_empty() {
1193 return Err(CommandError {
1194 tx: ctx.tx().to_string(),
1195 command_id: ctx.command_id.to_string(),
1196 message: "Start requires a stable RecordingJob id".to_owned(),
1197 });
1198 }
1199 let timeline_id = TimelineId::from(self.timeline_id);
1200 let current = ctx
1201 .exec_query_first(GetTimelinesByIds {
1202 ids: vec![timeline_id.clone()],
1203 })?
1204 .ok_or_else(|| CommandError {
1205 tx: ctx.tx().to_string(),
1206 command_id: ctx.command_id.to_string(),
1207 message: format!("Timeline {timeline_id} does not exist"),
1208 })?;
1209 let mut timeline = (*current).clone();
1210 let (collection_id, collection_path) = if self.collection_id.trim().is_empty() {
1211 (String::new(), Vec::new())
1212 } else {
1213 let membership_id = CollectionMembershipId::from(CollectionMembership::stable_id(
1214 &self.collection_id,
1215 timeline_id.as_ref(),
1216 ));
1217 if ctx
1218 .exec_query_first(GetCollectionMembershipsByIds {
1219 ids: vec![membership_id],
1220 })?
1221 .is_none()
1222 {
1223 return Err(command_error(
1224 &ctx,
1225 "Timeline is not assigned to the selected collection",
1226 ));
1227 }
1228 let collections = ctx
1229 .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
1230 .into_iter()
1231 .map(|collection| collection.as_ref().clone())
1232 .collect::<Vec<_>>();
1233 let path = resolve_collection_path(&self.collection_id, &collections)
1234 .map_err(|error| command_error(&ctx, error))?;
1235 (self.collection_id.clone(), path)
1236 };
1237 let revision = timeline.revision;
1238 let name = timeline.name.clone();
1239 let shot_rows = ctx
1240 .exec_query(GetShotsByQuery(ShotQuery::default()))?
1241 .into_iter()
1242 .map(|shot| shot.as_ref().clone())
1243 .collect::<Vec<_>>();
1244 let timeline_changed = timeline.backfill_entry_parameters(&shot_rows);
1245 let mut entries = crate::resolve_timeline_plans(&timeline, &shot_rows)
1246 .map_err(|error| command_error(&ctx, error))?;
1247 if entries.is_empty() {
1248 return Err(command_error(&ctx, "Timeline has no shot entries"));
1249 }
1250 if !self.entry_ids.is_empty() {
1254 let wanted: std::collections::HashSet<&str> =
1255 self.entry_ids.iter().map(String::as_str).collect();
1256 entries.retain(|entry| wanted.contains(entry.entry_id.as_str()));
1257 if entries.len() != self.entry_ids.len() {
1258 return Err(command_error(
1259 &ctx,
1260 "one or more requested shot entries are not in this timeline",
1261 ));
1262 }
1263 }
1264 let prior_jobs = ctx
1265 .exec_query(GetRecordingJobsByQuery(RecordingJobQuery::default()))?
1266 .into_iter()
1267 .map(|job| job.as_ref().clone())
1268 .collect::<Vec<_>>();
1269 for entry in &mut entries {
1270 entry.next_take_number = crate::next_take_number_for_entry(
1271 &prior_jobs,
1272 &collection_id,
1273 timeline_id.as_ref(),
1274 entry,
1275 );
1276 }
1277 if timeline_changed {
1278 ctx.emit_set(&timeline)?;
1279 }
1280 (
1281 timeline_id.to_string(),
1282 name,
1283 revision,
1284 collection_id,
1285 collection_path,
1286 entries,
1287 )
1288 } else {
1289 (
1290 String::new(),
1291 String::new(),
1292 0,
1293 String::new(),
1294 Vec::new(),
1295 self.entries,
1296 )
1297 };
1298 let id: RecordingJobRequestId = self.streamer_id.clone().into();
1299 let capture_context = self.capture_context.map(StoredCaptureContext::from);
1300 ctx.emit_set(&RecordingJobRequest {
1301 id: id.clone(),
1302 streamer_id: self.streamer_id.clone(),
1303 job_id: self.job_id.clone(),
1304 command_id: self.command_id,
1305 action: self.action.clone(),
1306 capture_context: capture_context.clone(),
1307 timeline_id: timeline_id.clone(),
1308 timeline_name: timeline_name.clone(),
1309 timeline_revision,
1310 collection_id: collection_id.clone(),
1311 collection_path: collection_path.clone(),
1312 entries: entries.clone(),
1313 preset_duration_ms: self.preset_duration_ms,
1314 translation_speed_cm_s: self.translation_speed_cm_s,
1315 rotation_speed_deg_s: self.rotation_speed_deg_s,
1316 requested_at_ms: self.requested_at_ms,
1317 })?;
1318 if self.action == RecordingJobAction::Start {
1319 ctx.emit_set(&RecordingJob {
1320 id: RecordingJobId::from(self.job_id.clone()),
1321 job_id: self.job_id,
1322 streamer_id: self.streamer_id,
1323 capture_context,
1324 timeline_id,
1325 timeline_name,
1326 timeline_revision,
1327 collection_id,
1328 collection_path,
1329 phase: RecordingJobPhase::Idle,
1330 pause_requested: false,
1331 entries,
1332 takes: Vec::new(),
1333 error: String::new(),
1334 started_at_ms: 0,
1335 updated_at_ms: self.requested_at_ms,
1336 elapsed_ms: 0,
1337 estimated_total_ms: 0,
1338 estimated_remaining_ms: 0,
1339 })?;
1340 }
1341 Ok(id)
1342 }
1343}
1344
1345#[myko_command(RecordingJobStatusId)]
1347pub struct SetRecordingJobStatus {
1348 pub streamer_id: String,
1349 #[serde(alias = "runId")]
1350 pub job_id: String,
1351 #[serde(default, skip_serializing_if = "Option::is_none")]
1352 #[ts(type = "unknown")]
1353 pub capture_context: Option<crate::CaptureContext>,
1354 #[serde(default, alias = "shotListId")]
1355 pub timeline_id: String,
1356 #[serde(default, alias = "shotListName")]
1357 pub timeline_name: String,
1358 #[serde(default, alias = "timelineVersion", alias = "shotListVersion")]
1359 pub timeline_revision: u32,
1360 #[serde(default)]
1361 pub collection_id: String,
1362 #[serde(default)]
1363 pub collection_path: Vec<crate::CollectionPathSegment>,
1364 pub phase: RecordingJobPhase,
1365 #[serde(default)]
1366 pub pause_requested: bool,
1367 #[serde(default, alias = "shots", alias = "items")]
1368 pub entries: Vec<ShotEntryPlan>,
1369 #[serde(default)]
1370 pub index: u32,
1371 #[serde(default)]
1372 pub completed: u32,
1373 #[serde(default)]
1374 pub error: String,
1375 #[serde(default)]
1376 pub updated_at_ms: u64,
1377 #[serde(default)]
1378 pub elapsed_ms: u64,
1379 #[serde(default)]
1380 pub estimated_total_ms: u64,
1381 #[serde(default)]
1382 pub estimated_remaining_ms: u64,
1383 #[serde(default)]
1384 pub takes: Vec<Take>,
1385 #[serde(default)]
1386 pub started_at_ms: u64,
1387}
1388
1389fn recording_job_status_is_heartbeat_only(
1397 candidate: &RecordingJobStatus,
1398 stored: &RecordingJobStatus,
1399) -> bool {
1400 let mut probe = candidate.clone();
1401 probe.updated_at_ms = stored.updated_at_ms;
1402 probe == *stored
1403}
1404
1405fn target_summary_is_heartbeat_only(
1407 candidate: &crate::FrameCaptureTargetSummary,
1408 stored: &crate::FrameCaptureTargetSummary,
1409) -> bool {
1410 let mut probe = candidate.clone();
1411 probe.updated_at_ms = stored.updated_at_ms;
1412 probe == *stored
1413}
1414
1415impl CommandHandler for SetRecordingJobStatus {
1416 fn execute(self, ctx: CommandContext) -> Result<RecordingJobStatusId, CommandError> {
1417 let id: RecordingJobStatusId = self.streamer_id.clone().into();
1418 let mut takes = self.takes;
1419 let job_id = self.job_id.clone();
1420 let existing_job = if !job_id.trim().is_empty() {
1421 ctx.exec_query_first(GetRecordingJobsByIds {
1422 ids: vec![RecordingJobId::from(job_id.clone())],
1423 })?
1424 } else {
1425 None
1426 };
1427 if let Some(existing) = &existing_job {
1428 for take in &mut takes {
1429 let accepted = existing.takes.iter().any(|saved| {
1430 saved.take_id == take.take_id
1431 && saved.capture.as_ref().is_some_and(|capture| {
1432 capture.creative_status == CreativeStatus::Accepted
1433 })
1434 });
1435 if accepted {
1436 if let Some(capture) = &mut take.capture {
1437 capture.creative_status = CreativeStatus::Accepted;
1438 }
1439 }
1440 }
1441 }
1442 let mirror_present = existing_job.is_some();
1443 let capture_context = self
1444 .capture_context
1445 .map(StoredCaptureContext::from)
1446 .or_else(|| existing_job.and_then(|job| job.capture_context.clone()));
1447 let status = RecordingJobStatus {
1448 id: id.clone(),
1449 streamer_id: self.streamer_id,
1450 job_id: job_id.clone(),
1451 capture_context,
1452 timeline_id: self.timeline_id,
1453 timeline_name: self.timeline_name,
1454 timeline_revision: self.timeline_revision,
1455 collection_id: self.collection_id,
1456 collection_path: self.collection_path,
1457 phase: self.phase,
1458 pause_requested: self.pause_requested,
1459 entries: self.entries,
1460 index: self.index,
1461 completed: self.completed,
1462 error: self.error,
1463 updated_at_ms: self.updated_at_ms,
1464 elapsed_ms: self.elapsed_ms,
1465 estimated_total_ms: self.estimated_total_ms,
1466 estimated_remaining_ms: self.estimated_remaining_ms,
1467 takes,
1468 started_at_ms: self.started_at_ms,
1469 };
1470 let unchanged = ctx
1481 .exec_query_first(GetRecordingJobStatussByIds {
1482 ids: vec![id.clone()],
1483 })?
1484 .is_some_and(|existing| recording_job_status_is_heartbeat_only(&status, &existing));
1485 if unchanged && (job_id.trim().is_empty() || mirror_present) {
1488 return Ok(id);
1489 }
1490 ctx.emit_set(&status)?;
1491 if !job_id.trim().is_empty() {
1492 ctx.emit_set(&RecordingJob {
1493 id: RecordingJobId::from(job_id.clone()),
1494 job_id,
1495 streamer_id: status.streamer_id.clone(),
1496 capture_context: status.capture_context.clone(),
1497 timeline_id: status.timeline_id.clone(),
1498 timeline_name: status.timeline_name.clone(),
1499 timeline_revision: status.timeline_revision,
1500 collection_id: status.collection_id.clone(),
1501 collection_path: status.collection_path.clone(),
1502 phase: status.phase.clone(),
1503 pause_requested: status.pause_requested,
1504 entries: status.entries.clone(),
1505 takes: status.takes.clone(),
1506 error: status.error.clone(),
1507 started_at_ms: status.started_at_ms,
1508 updated_at_ms: status.updated_at_ms,
1509 elapsed_ms: status.elapsed_ms,
1510 estimated_total_ms: status.estimated_total_ms,
1511 estimated_remaining_ms: status.estimated_remaining_ms,
1512 })?;
1513 }
1514 Ok(id)
1515 }
1516}
1517
1518#[myko_command(RecordingJobId)]
1521pub struct AcceptTake {
1522 pub job_id: String,
1523 pub take_id: String,
1524}
1525
1526impl CommandHandler for AcceptTake {
1527 fn execute(self, ctx: CommandContext) -> Result<RecordingJobId, CommandError> {
1528 let id = RecordingJobId::from(self.job_id);
1529 let current = ctx
1530 .exec_query_first(GetRecordingJobsByIds {
1531 ids: vec![id.clone()],
1532 })?
1533 .ok_or_else(|| command_error(&ctx, format!("Recording job {id} does not exist")))?;
1534 let mut job = current.as_ref().clone();
1535 let take = job
1536 .takes
1537 .iter_mut()
1538 .find(|take| take.take_id == self.take_id)
1539 .ok_or_else(|| command_error(&ctx, "Take does not exist"))?;
1540 let Some(capture) = &mut take.capture else {
1541 return Err(command_error(&ctx, "Take has no Capture to accept"));
1542 };
1543 if take.state != TakeState::Completed
1544 || capture.delivery_status != DeliveryStatus::Delivered
1545 {
1546 return Err(command_error(
1547 &ctx,
1548 "Only delivered Captures can be accepted",
1549 ));
1550 }
1551 capture.creative_status = CreativeStatus::Accepted;
1552 ctx.emit_set(&job)?;
1553 Ok(id)
1554 }
1555}
1556
1557#[myko_command(CamPrefId)]
1562pub struct SetCamPref {
1563 pub focal: f32,
1564 pub aperture: f32,
1565 pub focus_method: String,
1566 pub focus_dist: f32,
1567 pub base_speed: f32,
1568 pub look_scale: f32,
1569 pub invert: bool,
1570 pub glide: bool,
1571 pub glide_secs: f32,
1572 pub motion_blur: f32,
1573 pub rail_speed: f32,
1574}
1575
1576impl CommandHandler for SetCamPref {
1577 fn execute(self, ctx: CommandContext) -> Result<CamPrefId, CommandError> {
1578 let id = CamPref::row_id();
1579 let pref = CamPref {
1580 id: id.clone(),
1581 focal: self.focal,
1582 aperture: self.aperture,
1583 focus_method: self.focus_method,
1584 focus_dist: self.focus_dist,
1585 base_speed: self.base_speed,
1586 look_scale: self.look_scale,
1587 invert: self.invert,
1588 glide: self.glide,
1589 glide_secs: self.glide_secs,
1590 motion_blur: self.motion_blur,
1591 rail_speed: self.rail_speed,
1592 };
1593 ctx.emit_set(&pref)?;
1594 Ok(id)
1595 }
1596}
1597
1598#[myko_command(CameraHomeId)]
1601pub struct SetCameraHome {
1602 pub stream_id: String,
1603 pub location_x: f32,
1604 pub location_y: f32,
1605 pub location_z: f32,
1606 pub rotation_pitch: f32,
1607 pub rotation_yaw: f32,
1608 pub rotation_roll: f32,
1609 pub focal_length: f32,
1610}
1611
1612impl CommandHandler for SetCameraHome {
1613 fn execute(self, ctx: CommandContext) -> Result<CameraHomeId, CommandError> {
1614 if self.stream_id.trim().is_empty() {
1615 return Err(command_error(&ctx, "Camera Home requires a stream id"));
1616 }
1617 let values = [
1618 self.location_x,
1619 self.location_y,
1620 self.location_z,
1621 self.rotation_pitch,
1622 self.rotation_yaw,
1623 self.rotation_roll,
1624 self.focal_length,
1625 ];
1626 if values.iter().any(|value| !value.is_finite()) {
1627 return Err(command_error(&ctx, "Camera Home values must be finite"));
1628 }
1629 if !(1.0..=1000.0).contains(&self.focal_length) {
1630 return Err(command_error(
1631 &ctx,
1632 "Camera Home focal length must be between 1 and 1000 mm",
1633 ));
1634 }
1635
1636 let id = CameraHome::row_id(&self.stream_id);
1637 ctx.emit_set(&CameraHome {
1638 id: id.clone(),
1639 stream_id: self.stream_id,
1640 location_x: self.location_x,
1641 location_y: self.location_y,
1642 location_z: self.location_z,
1643 rotation_pitch: self.rotation_pitch,
1644 rotation_yaw: self.rotation_yaw,
1645 rotation_roll: self.rotation_roll,
1646 focal_length: self.focal_length,
1647 })?;
1648 Ok(id)
1649 }
1650}
1651
1652#[myko_command(StreamId)]
1656pub struct SetStreamName {
1657 pub stream_id: StreamId,
1658 pub name: String,
1659}
1660
1661impl CommandHandler for SetStreamName {
1662 fn execute(self, ctx: CommandContext) -> Result<StreamId, CommandError> {
1663 let id = self.stream_id.clone();
1664 let stream = Stream {
1665 id: id.clone(),
1666 name: self.name,
1667 };
1668 ctx.emit_set(&stream)?;
1669 Ok(id)
1670 }
1671}
1672
1673#[myko_command(RecordingRequestId)]
1678pub struct SetRecording {
1679 pub streamer_id: String,
1680 pub active: bool,
1681 #[serde(default)]
1682 pub capture_kind: crate::RecordingKind,
1683 #[serde(default)]
1684 pub rig: String,
1685 #[serde(default)]
1686 pub preset: String,
1687 #[serde(default)]
1688 pub stream_name: String,
1689 #[serde(default, skip_serializing_if = "Option::is_none")]
1690 pub travel_direction: Option<ShotDirection>,
1691 #[serde(default, skip_serializing_if = "Option::is_none")]
1692 pub shot_index: Option<u32>,
1693 #[serde(default)]
1694 pub take_number: u32,
1695 #[serde(default, alias = "clipId", alias = "cueId")]
1696 pub entry_id: String,
1697 #[serde(default, alias = "shotListId")]
1698 pub timeline_id: String,
1699 #[serde(default, alias = "shotListName")]
1700 pub timeline_name: String,
1701 #[serde(default, alias = "shotListVersion")]
1702 pub timeline_revision: u32,
1703 #[serde(default)]
1704 pub collection_path: Vec<crate::CollectionPathSegment>,
1705 #[serde(default)]
1706 pub requested_at_ms: u64,
1707}
1708impl CommandHandler for SetRecording {
1709 fn execute(self, ctx: CommandContext) -> Result<RecordingRequestId, CommandError> {
1710 let id: RecordingRequestId = self.streamer_id.clone().into();
1711 let req = RecordingRequest {
1712 id: id.clone(),
1713 streamer_id: self.streamer_id,
1714 active: self.active,
1715 capture_kind: self.capture_kind,
1716 rig: self.rig,
1717 preset: self.preset,
1718 stream_name: self.stream_name,
1719 travel_direction: self.travel_direction,
1720 shot_index: self.shot_index,
1721 take_number: self.take_number,
1722 entry_id: self.entry_id,
1723 timeline_id: self.timeline_id,
1724 timeline_name: self.timeline_name,
1725 timeline_revision: self.timeline_revision,
1726 collection_path: self.collection_path,
1727 requested_at_ms: self.requested_at_ms,
1728 };
1729 ctx.emit_set(&req)?;
1730 Ok(id)
1731 }
1732}
1733
1734#[myko_command(RecordingStatusId)]
1736pub struct SetRecordingStatus {
1737 pub streamer_id: String,
1738 pub state: RecordingState,
1739 #[serde(default)]
1740 pub file_name: String,
1741 #[serde(default)]
1742 pub error: String,
1743 #[serde(default)]
1744 pub started_at_ms: u64,
1745}
1746impl CommandHandler for SetRecordingStatus {
1747 fn execute(self, ctx: CommandContext) -> Result<RecordingStatusId, CommandError> {
1748 let id: RecordingStatusId = self.streamer_id.clone().into();
1749 let st = RecordingStatus {
1750 id: id.clone(),
1751 streamer_id: self.streamer_id,
1752 state: self.state,
1753 file_name: self.file_name,
1754 error: self.error,
1755 started_at_ms: self.started_at_ms,
1756 };
1757 ctx.emit_set(&st)?;
1758 Ok(id)
1759 }
1760}
1761
1762#[myko_command(ViewerId)]
1764pub struct JoinStream {
1765 pub stream_id: StreamId,
1766 pub viewer_id: String,
1767 pub name: String,
1768 pub color: String,
1769 #[serde(default, skip_serializing_if = "Option::is_none")]
1770 pub identity_issuer: Option<String>,
1771 #[serde(default, skip_serializing_if = "Option::is_none")]
1772 pub identity_subject: Option<String>,
1773 #[serde(default, skip_serializing_if = "Option::is_none")]
1774 pub avatar_url: Option<String>,
1775}
1776
1777impl CommandHandler for JoinStream {
1778 fn execute(self, ctx: CommandContext) -> Result<ViewerId, CommandError> {
1779 let client_id = ctx
1780 .client_id()
1781 .map(|id| myko::entities::client::ClientId::from(id.to_owned()));
1782 let conn = client_id
1786 .as_ref()
1787 .map(|c| c.to_string())
1788 .unwrap_or_else(|| self.viewer_id.clone());
1789 let id = Viewer::row_id(&self.stream_id, &conn);
1790 let stream_id = self.stream_id.clone();
1791 let viewer = Viewer {
1792 id: id.clone(),
1793 stream_id: self.stream_id,
1794 viewer_id: self.viewer_id,
1795 name: self.name,
1796 color: self.color,
1797 identity_issuer: self.identity_issuer,
1798 identity_subject: self.identity_subject,
1799 avatar_url: self.avatar_url,
1800 cursor: None,
1801 client_id,
1803 };
1804 ctx.emit_set(&viewer)?;
1805 reconcile_control(&ctx, &stream_id)?;
1808 Ok(id)
1809 }
1810}
1811
1812#[myko_command]
1819pub struct LeaveStream {
1820 pub stream_id: StreamId,
1821 pub viewer_id: String,
1822}
1823
1824impl CommandHandler for LeaveStream {
1825 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
1826 let conn = ctx
1827 .client_id()
1828 .map(|client_id| client_id.to_string())
1829 .unwrap_or(self.viewer_id);
1830 let id = Viewer::row_id(&self.stream_id, &conn);
1831 if let Some(viewer) = ctx.exec_report(GetViewerById { id })? {
1832 ctx.emit_del(&*viewer)?;
1833 }
1834 reconcile_control(&ctx, &self.stream_id)
1835 }
1836}
1837
1838#[myko_command]
1840pub struct UpdateCursor {
1841 pub stream_id: StreamId,
1842 pub viewer_id: String,
1843 pub cursor: Option<(f32, f32)>,
1844}
1845
1846impl CommandHandler for UpdateCursor {
1847 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
1848 let conn = ctx
1850 .client_id()
1851 .map(|c| c.to_string())
1852 .unwrap_or_else(|| self.viewer_id.clone());
1853 let id = Viewer::row_id(&self.stream_id, &conn);
1854 if let Some(viewer) = ctx.exec_report(GetViewerById { id })? {
1855 let updated = Viewer {
1856 cursor: self.cursor,
1857 ..(*viewer).clone()
1858 };
1859 ctx.emit_set(&updated)?;
1860 }
1861 Ok(())
1862 }
1863}
1864
1865#[myko_command(ControlLockId)]
1867pub struct AcquireControl {
1868 pub stream_id: StreamId,
1869 pub viewer_id: String,
1870}
1871
1872impl CommandHandler for AcquireControl {
1873 fn execute(self, ctx: CommandContext) -> Result<ControlLockId, CommandError> {
1874 let id = ControlLock::row_id(&self.stream_id);
1875 let lock = ControlLock {
1876 id: id.clone(),
1877 stream_id: self.stream_id,
1878 viewer_id: self.viewer_id,
1879 client_id: ctx
1880 .client_id()
1881 .map(|id| myko::entities::client::ClientId::from(id.to_owned())),
1882 };
1883 ctx.emit_set(&lock)?;
1884 Ok(id)
1885 }
1886}
1887
1888#[myko_command]
1891pub struct ReleaseControl {
1892 pub stream_id: StreamId,
1893 pub viewer_id: String,
1894}
1895
1896impl CommandHandler for ReleaseControl {
1897 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
1898 let id = ControlLock::row_id(&self.stream_id);
1899 if let Some(lock) = ctx.exec_report(GetControlLockById { id })? {
1900 if lock.viewer_id == self.viewer_id {
1901 ctx.emit_del(&*lock)?;
1902 }
1903 }
1904 Ok(())
1905 }
1906}
1907
1908fn people_present(viewers: &[Arc<Viewer>]) -> Vec<&str> {
1925 let mut people: Vec<&str> = viewers.iter().map(|v| v.viewer_id.as_str()).collect();
1926 people.sort_unstable();
1927 people.dedup();
1928 people
1929}
1930
1931fn viewer_is_live(ctx: &CommandContext, viewer: &Viewer) -> Result<bool, CommandError> {
1942 let Some(client_id) = viewer.client_id.clone() else {
1943 return Ok(false);
1944 };
1945 Ok(ctx.exec_report(ClientStatus { client_id })?.online)
1946}
1947
1948fn reconcile_control(ctx: &CommandContext, stream_id: &StreamId) -> Result<(), CommandError> {
1949 let stored: Vec<Arc<Viewer>> = ctx.exec_query(GetViewersByQuery(ViewerQuery {
1950 stream_id: Some(IdFilter::Eq(stream_id.clone())),
1951 ..Default::default()
1952 }))?;
1953 let mut viewers = Vec::with_capacity(stored.len());
1958 for viewer in stored {
1959 if viewer_is_live(ctx, &viewer)? {
1960 viewers.push(viewer);
1961 }
1962 }
1963 let id = ControlLock::row_id(stream_id);
1964
1965 if let Some(lock) = ctx.exec_report(GetControlLockById { id: id.clone() })? {
1967 let holder_present = viewers.iter().any(|v| v.viewer_id == lock.viewer_id);
1968 if !holder_present {
1969 ctx.emit_del(&*lock)?;
1970 }
1971 }
1972
1973 let people = people_present(&viewers);
1975 if let [sole_vid] = people.as_slice() {
1976 let held_by_sole = ctx
1977 .exec_report(GetControlLockById { id: id.clone() })?
1978 .as_deref()
1979 .is_some_and(|l| l.viewer_id.as_str() == *sole_vid);
1980 if !held_by_sole {
1981 let conn = viewers
1983 .iter()
1984 .find(|v| v.viewer_id.as_str() == *sole_vid)
1985 .expect("present");
1986 ctx.emit_set(&ControlLock {
1987 id,
1988 stream_id: stream_id.clone(),
1989 viewer_id: (*sole_vid).to_string(),
1990 client_id: conn.client_id.clone(),
1991 })?;
1992 }
1993 }
1994 Ok(())
1995}
1996
1997#[myko_command]
2000pub struct AutoAssignControl {
2001 pub stream_id: StreamId,
2002}
2003
2004impl CommandHandler for AutoAssignControl {
2005 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
2006 reconcile_control(&ctx, &self.stream_id)
2007 }
2008}
2009
2010#[myko_command(FrameCaptureRequestId)]
2018pub struct SubmitFrameCapture {
2019 pub streamer_id: String,
2020 pub capture_id: String,
2021 pub command_id: String,
2022 #[ts(type = "unknown")]
2023 pub capture_context: crate::CaptureContext,
2024 pub target: crate::FrameCaptureTarget,
2025 #[serde(default)]
2027 pub force: bool,
2028 #[serde(default)]
2029 pub requested_at_ms: u64,
2030}
2031
2032impl CommandHandler for SubmitFrameCapture {
2033 fn execute(self, ctx: CommandContext) -> Result<FrameCaptureRequestId, CommandError> {
2034 if self.streamer_id.trim().is_empty() {
2035 return Err(command_error(&ctx, "Frame capture requires a stream"));
2036 }
2037 if self.capture_id.trim().is_empty() {
2038 return Err(command_error(&ctx, "Frame capture requires a capture id"));
2039 }
2040 if self.target.cluster_name.trim().is_empty() {
2041 return Err(command_error(
2042 &ctx,
2043 "Frame capture requires an explicit target cluster",
2044 ));
2045 }
2046 let id: FrameCaptureRequestId = self.streamer_id.clone().into();
2049 ctx.emit_set(&crate::FrameCaptureRequest {
2050 id: id.clone(),
2051 streamer_id: self.streamer_id,
2052 capture_id: self.capture_id,
2053 command_id: self.command_id,
2054 capture_context: self.capture_context.into(),
2055 target: self.target,
2056 force: self.force,
2057 requested_at_ms: self.requested_at_ms,
2058 })?;
2059 Ok(id)
2060 }
2061}
2062
2063#[myko_command(FrameCaptureStatusId)]
2065pub struct SetFrameCaptureStatus {
2066 pub streamer_id: String,
2067 #[serde(default)]
2068 pub capture_id: String,
2069 pub phase: crate::FrameCapturePhase,
2070 #[serde(default, skip_serializing_if = "Option::is_none")]
2071 #[ts(type = "unknown")]
2072 pub capture_context: Option<crate::CaptureContext>,
2073 #[serde(default)]
2074 pub target: crate::FrameCaptureTarget,
2075 #[serde(default)]
2076 pub observed_generation: String,
2077 #[serde(default)]
2078 pub receipts: Vec<crate::FrameCaptureReceipt>,
2079 #[serde(default)]
2080 pub error: String,
2081 #[serde(default)]
2082 pub forceable: bool,
2083 #[serde(default)]
2084 pub updated_at_ms: u64,
2085}
2086
2087impl CommandHandler for SetFrameCaptureStatus {
2088 fn execute(self, ctx: CommandContext) -> Result<FrameCaptureStatusId, CommandError> {
2089 let id: FrameCaptureStatusId = self.streamer_id.clone().into();
2090 ctx.emit_set(&crate::FrameCaptureStatus {
2091 id: id.clone(),
2092 streamer_id: self.streamer_id,
2093 capture_id: self.capture_id,
2094 phase: self.phase,
2095 capture_context: self.capture_context.map(StoredCaptureContext::from),
2096 target: self.target,
2097 observed_generation: self.observed_generation,
2098 receipts: self.receipts,
2099 error: self.error,
2100 forceable: self.forceable,
2101 updated_at_ms: self.updated_at_ms,
2102 })?;
2103 Ok(id)
2104 }
2105}
2106
2107#[myko_command(FrameCaptureTargetSummaryId)]
2111pub struct SetFrameCaptureTargetSummary {
2112 pub cluster_name: String,
2113 #[serde(default)]
2114 pub generation: String,
2115 #[serde(default)]
2116 pub capturable: bool,
2117 #[serde(default)]
2118 pub status: String,
2119 #[serde(default)]
2120 pub updated_at_ms: u64,
2121}
2122
2123impl CommandHandler for SetFrameCaptureTargetSummary {
2124 fn execute(self, ctx: CommandContext) -> Result<FrameCaptureTargetSummaryId, CommandError> {
2125 if self.cluster_name.trim().is_empty() {
2126 return Err(command_error(
2127 &ctx,
2128 "Target summary requires a cluster name",
2129 ));
2130 }
2131 let id: FrameCaptureTargetSummaryId = self.cluster_name.clone().into();
2132 let summary = crate::FrameCaptureTargetSummary {
2133 id: id.clone(),
2134 cluster_name: self.cluster_name,
2135 generation: self.generation,
2136 capturable: self.capturable,
2137 status: self.status,
2138 updated_at_ms: self.updated_at_ms,
2139 };
2140 let unchanged = ctx
2145 .exec_query_first(GetFrameCaptureTargetSummarysByIds {
2146 ids: vec![id.clone()],
2147 })?
2148 .is_some_and(|existing| target_summary_is_heartbeat_only(&summary, &existing));
2149 if unchanged {
2150 return Ok(id);
2151 }
2152 ctx.emit_set(&summary)?;
2153 Ok(id)
2154 }
2155}
2156
2157#[cfg(test)]
2158mod discovery_tests {
2159 use super::*;
2160 use crate::shot::DEFAULT_SHOT_LIBRARY_ID;
2161
2162 fn tuned_legacy_shot() -> Shot {
2163 Shot {
2164 id: ShotId::from("render-11:moving:Floor Dolly"),
2165 library_id: String::new(),
2166 streamer_id: "render-11".to_owned(),
2167 name: "Floor Dolly".to_owned(),
2168 kind: ShotKind::Moving,
2169 target_name: "Floor Dolly".to_owned(),
2170 translation_speed_cm_s: 17.0,
2171 rotation_speed_deg_s: 3.5,
2172 hold_duration_ms: 8_000,
2173 travel_duration_ms: 41_000,
2174 default_entry_mode: ShotEntryMode::Both,
2175 shot_index: 9,
2176 }
2177 }
2178
2179 fn legacy_timeline(id: &str, streamer_id: &str, name: &str, shot_id: &str) -> Timeline {
2180 Timeline {
2181 id: TimelineId::from(id),
2182 library_id: String::new(),
2183 streamer_id: streamer_id.to_owned(),
2184 name: name.to_owned(),
2185 revision: 0,
2186 entries: if shot_id.is_empty() {
2187 Vec::new()
2188 } else {
2189 vec![ShotEntry {
2190 shot_id: shot_id.to_owned(),
2191 direction: ShotEntryMode::Forward,
2192 ..Default::default()
2193 }]
2194 },
2195 sort_order: 0,
2196 }
2197 }
2198
2199 #[test]
2200 fn discovery_creates_only_missing_targets_and_preserves_tuned_legacy_rows() {
2201 let existing = vec![tuned_legacy_shot()];
2202 let discovered = vec![
2203 ShotDiscovery {
2204 name: "Floor Dolly".to_owned(),
2205 kind: ShotKind::Moving,
2206 target_name: "Floor Dolly".to_owned(),
2207 },
2208 ShotDiscovery {
2209 name: "Hero Push Forward".to_owned(),
2210 kind: ShotKind::Moving,
2211 target_name: "Hero Push Forward".to_owned(),
2212 },
2213 ShotDiscovery {
2214 name: "Hero Push Forward".to_owned(),
2215 kind: ShotKind::Moving,
2216 target_name: "Hero Push Forward".to_owned(),
2217 },
2218 ];
2219
2220 let created = discovered_shots_to_create(DEFAULT_SHOT_LIBRARY_ID, discovered, &existing);
2221 assert_eq!(created.len(), 1);
2222 assert_eq!(created[0].name, "Hero Push Forward");
2223 assert_eq!(created[0].shot_index, 10);
2224 assert_eq!(
2225 created[0].translation_speed_cm_s,
2226 DEFAULT_SHOT_TRANSLATION_SPEED_CM_S
2227 );
2228 assert_eq!(
2229 created[0].rotation_speed_deg_s,
2230 DEFAULT_SHOT_ROTATION_SPEED_DEG_S
2231 );
2232 assert_eq!(created[0].default_entry_mode, ShotEntryMode::Forward);
2233 assert_eq!(existing[0].translation_speed_cm_s, 17.0);
2234 assert_eq!(existing[0].rotation_speed_deg_s, 3.5);
2235 }
2236
2237 #[test]
2238 fn discovery_reconciles_live_legacy_timeline_duplicates_without_losing_clips() {
2239 let render_shot = tuned_legacy_shot();
2240 let mut studio_shot = tuned_legacy_shot();
2241 studio_shot.id = ShotId::from("Studio A:moving:Floor Dolly");
2242 studio_shot.streamer_id = "Studio A".to_owned();
2243 let timelines = vec![
2244 legacy_timeline(
2245 "render-11:timeline:default",
2246 "render-11",
2247 "Default shot list",
2248 render_shot.id.as_ref(),
2249 ),
2250 legacy_timeline(
2251 "Studio A:timeline:default",
2252 "Studio A",
2253 "Default timeline",
2254 studio_shot.id.as_ref(),
2255 ),
2256 legacy_timeline(
2257 "timeline-old",
2258 "render-11",
2259 "Supercut",
2260 render_shot.id.as_ref(),
2261 ),
2262 Timeline {
2263 id: TimelineId::from("timeline-shared"),
2264 library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
2265 streamer_id: "Studio A".to_owned(),
2266 name: " superCUT ".to_owned(),
2267 revision: 2,
2268 entries: vec![ShotEntry {
2269 shot_id: studio_shot.id.to_string(),
2270 direction: ShotEntryMode::Reverse,
2271 ..Default::default()
2272 }],
2273 sort_order: 1,
2274 },
2275 ];
2276
2277 let result = reconcile_timelines(
2278 DEFAULT_SHOT_LIBRARY_ID,
2279 &[render_shot, studio_shot],
2280 timelines,
2281 );
2282
2283 assert_eq!(result.upserts.len(), 2);
2284 assert_eq!(result.deletes.len(), 3);
2285 let default = result
2286 .upserts
2287 .iter()
2288 .find(|timeline| timeline.id.to_string() == Timeline::shared_legacy_default_id())
2289 .expect("canonical shared default");
2290 assert_eq!(default.library_id, DEFAULT_SHOT_LIBRARY_ID);
2291 assert!(default.streamer_id.is_empty());
2292 assert_eq!(default.name, "Migrated timeline");
2293 assert_eq!(default.entries.len(), 1);
2294 assert_eq!(default.entries[0].shot_id, "Studio A:moving:Floor Dolly");
2295 assert_eq!(result.id_migrations.len(), 3);
2296 assert_eq!(
2297 result.id_migrations.get("render-11:timeline:default"),
2298 Some(&Timeline::shared_legacy_default_id())
2299 );
2300
2301 let supercut = result
2302 .upserts
2303 .iter()
2304 .find(|timeline| timeline.id.as_ref() == "timeline-shared")
2305 .expect("explicit shared timeline wins");
2306 assert_eq!(supercut.name, "superCUT");
2307 assert_eq!(supercut.revision, 2);
2308 assert_eq!(supercut.entries.len(), 2);
2309 assert_eq!(supercut.entries[0].direction, ShotEntryMode::Reverse);
2310 assert_eq!(supercut.entries[1].direction, ShotEntryMode::Forward);
2311 assert!(supercut
2312 .entries
2313 .iter()
2314 .all(|entry| !entry.entry_id.is_empty()));
2315 }
2316
2317 #[test]
2318 fn reconciliation_is_idempotent_for_canonical_timelines() {
2319 let shot = tuned_legacy_shot();
2320 let mut timeline = Timeline {
2321 id: TimelineId::from("shared:timeline:supercut"),
2322 library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
2323 streamer_id: String::new(),
2324 name: "Supercut".to_owned(),
2325 revision: 1,
2326 entries: vec![ShotEntry {
2327 shot_id: shot.id.to_string(),
2328 direction: ShotEntryMode::Forward,
2329 ..Default::default()
2330 }],
2331 sort_order: 0,
2332 };
2333 timeline.normalize_entries();
2334 assert!(timeline.backfill_entry_parameters(std::slice::from_ref(&shot)));
2335 assert!(!timeline.backfill_entry_parameters(std::slice::from_ref(&shot)));
2336 let result = reconcile_timelines(DEFAULT_SHOT_LIBRARY_ID, &[shot], vec![timeline]);
2337 assert!(result.upserts.is_empty());
2338 assert!(result.deletes.is_empty());
2339 }
2340
2341 #[test]
2342 fn reconciliation_snapshots_only_missing_legacy_clip_parameters() {
2343 let shot = tuned_legacy_shot();
2344 let mut timeline =
2345 legacy_timeline("shared:timeline:supercut", "", "Supercut", shot.id.as_ref());
2346 timeline.library_id = DEFAULT_SHOT_LIBRARY_ID.to_owned();
2347 timeline.entries[0].translation_speed_cm_s = Some(4.5);
2348
2349 let result = reconcile_timelines(
2350 DEFAULT_SHOT_LIBRARY_ID,
2351 std::slice::from_ref(&shot),
2352 vec![timeline],
2353 );
2354 assert_eq!(result.upserts.len(), 1);
2355 let entry = &result.upserts[0].entries[0];
2356 assert_eq!(entry.translation_speed_cm_s, Some(4.5));
2357 assert_eq!(entry.rotation_speed_deg_s, Some(shot.rotation_speed_deg_s));
2358 assert_eq!(entry.hold_duration_ms, Some(shot.hold_duration_ms));
2359 assert_eq!(
2360 entry.travel_duration_ms,
2361 Some(shot.effective_travel_duration_ms())
2362 );
2363 assert_eq!(entry.shot_index, Some(shot.shot_index));
2364 }
2365}
2366
2367#[cfg(test)]
2368mod heartbeat_suppression_tests {
2369 use super::*;
2370 use crate::recording_job_status::RecordingJobStatus;
2371
2372 fn status() -> RecordingJobStatus {
2373 RecordingJobStatus {
2374 id: "render-13".into(),
2375 streamer_id: "render-13".to_owned(),
2376 job_id: "job-1".to_owned(),
2377 capture_context: None,
2378 timeline_id: "supercut".to_owned(),
2379 timeline_name: "Supercut".to_owned(),
2380 timeline_revision: 1,
2381 collection_id: String::new(),
2382 collection_path: Vec::new(),
2383 phase: RecordingJobPhase::Traveling,
2384 pause_requested: false,
2385 entries: Vec::new(),
2386 index: 0,
2387 completed: 0,
2388 error: String::new(),
2389 updated_at_ms: 1_000,
2390 elapsed_ms: 5_000,
2391 estimated_total_ms: 10_000,
2392 estimated_remaining_ms: 5_000,
2393 takes: Vec::new(),
2394 started_at_ms: 500,
2395 }
2396 }
2397
2398 fn summary() -> crate::FrameCaptureTargetSummary {
2399 crate::FrameCaptureTargetSummary {
2400 id: "0of12_rx11".into(),
2401 cluster_name: "0of12_rx11".to_owned(),
2402 generation: "3350".to_owned(),
2403 capturable: false,
2404 status: "stopped".to_owned(),
2405 updated_at_ms: 1_000,
2406 }
2407 }
2408
2409 #[test]
2410 fn a_newer_clock_alone_is_not_a_change() {
2411 let stored = status();
2412 let mut republished = stored.clone();
2413 republished.updated_at_ms = 9_999;
2414 assert!(recording_job_status_is_heartbeat_only(
2415 &republished,
2416 &stored
2417 ));
2418 }
2419
2420 #[test]
2421 fn real_progress_still_writes() {
2422 let stored = status();
2423 for mutate in [
2424 (|s: &mut RecordingJobStatus| s.phase = RecordingJobPhase::Complete)
2425 as fn(&mut RecordingJobStatus),
2426 |s: &mut RecordingJobStatus| s.elapsed_ms = 6_000,
2427 |s: &mut RecordingJobStatus| s.completed = 1,
2428 |s: &mut RecordingJobStatus| s.error = "disk full".to_owned(),
2429 |s: &mut RecordingJobStatus| s.pause_requested = true,
2430 |s: &mut RecordingJobStatus| s.estimated_remaining_ms = 4_000,
2431 ] {
2432 let mut candidate = stored.clone();
2433 candidate.updated_at_ms = 9_999;
2434 mutate(&mut candidate);
2435 assert!(
2436 !recording_job_status_is_heartbeat_only(&candidate, &stored),
2437 "a changed field must still be written"
2438 );
2439 }
2440 }
2441
2442 #[test]
2443 fn target_summaries_follow_the_same_rule() {
2444 let stored = summary();
2445 let mut polled = stored.clone();
2446 polled.updated_at_ms = 9_999;
2447 assert!(target_summary_is_heartbeat_only(&polled, &stored));
2448
2449 for mutate in [
2450 (|s: &mut crate::FrameCaptureTargetSummary| s.capturable = true)
2451 as fn(&mut crate::FrameCaptureTargetSummary),
2452 |s: &mut crate::FrameCaptureTargetSummary| s.generation = "3351".to_owned(),
2453 |s: &mut crate::FrameCaptureTargetSummary| s.status = "running".to_owned(),
2454 ] {
2455 let mut candidate = stored.clone();
2456 candidate.updated_at_ms = 9_999;
2457 mutate(&mut candidate);
2458 assert!(!target_summary_is_heartbeat_only(&candidate, &stored));
2459 }
2460 }
2461}
2462
2463#[cfg(test)]
2464mod presence_tests {
2465 use std::sync::Arc;
2466
2467 use super::people_present;
2468 use crate::viewer::Viewer;
2469
2470 fn viewer(viewer_id: &str, client: &str) -> Arc<Viewer> {
2471 Arc::new(Viewer {
2472 id: format!("s1:{client}").into(),
2473 stream_id: "s1".into(),
2474 viewer_id: viewer_id.to_owned(),
2475 name: "Anonymous Cheetah".to_owned(),
2476 color: "#F87171".to_owned(),
2477 identity_issuer: None,
2478 identity_subject: None,
2479 avatar_url: None,
2480 cursor: None,
2481 client_id: Some(client.to_owned().into()),
2482 })
2483 }
2484
2485 #[test]
2486 fn tabs_of_one_person_are_one_person() {
2487 let viewers = vec![viewer("max", "conn-a"), viewer("max", "conn-b")];
2489 assert_eq!(people_present(&viewers), vec!["max"]);
2490 }
2491
2492 #[test]
2493 fn distinct_people_are_counted_separately_and_sorted() {
2494 let viewers = vec![
2495 viewer("zoe", "conn-c"),
2496 viewer("max", "conn-a"),
2497 viewer("max", "conn-b"),
2498 ];
2499 assert_eq!(people_present(&viewers), vec!["max", "zoe"]);
2500 }
2501
2502 #[test]
2503 fn a_stream_whose_connections_all_died_has_nobody_present() {
2504 assert!(people_present(&[]).is_empty());
2509 }
2510}