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::previs_dlss::PrevisDlssRequestId;
34use crate::previs_dlss_status::PrevisDlssStatusId;
35use crate::recording_job::{
36 CreativeStatus, DeliveryStatus, GetRecordingJobsByIds, GetRecordingJobsByQuery, RecordingJob,
37 RecordingJobId, RecordingJobQuery, Take, TakeState,
38};
39use crate::recording_job_request::{RecordingJobRequest, RecordingJobRequestId};
40use crate::recording_job_status::{
41 GetRecordingJobStatussByIds, RecordingJobStatus, RecordingJobStatusId,
42};
43use crate::recording_plan::{RecordingJobAction, RecordingJobPhase, ShotEntryPlan};
44use crate::recording_request::{RecordingRequest, RecordingRequestId};
45use crate::recording_status::{RecordingState, RecordingStatus, RecordingStatusId};
46use crate::shot::{
47 effective_library_id, GetShotsByIds, GetShotsByQuery, Shot, ShotDiscovery, ShotId, ShotKind,
48 ShotQuery, DEFAULT_SHOT_HOLD_DURATION_MS, DEFAULT_SHOT_LIBRARY_ID,
49 DEFAULT_SHOT_ROTATION_SPEED_DEG_S, DEFAULT_SHOT_TRANSLATION_SPEED_CM_S,
50};
51use crate::stream::{Stream, StreamId};
52use crate::timeline::{
53 GetTimelinesByIds, GetTimelinesByQuery, ShotDirection, ShotEntry, ShotEntryMode, Timeline,
54 TimelineId, TimelineQuery,
55};
56use crate::viewer::{GetViewerById, GetViewersByQuery, Viewer, ViewerId, ViewerQuery};
57use crate::StoredCaptureContext;
58
59const MAX_OTIO_IMPORT_BYTES: usize = 10 * 1024 * 1024;
60const MAX_DISCOVERED_SHOTS: usize = 10_000;
61const MAX_COLLECTION_DEPTH: usize = 16;
62const MAX_COLLECTION_NAME_CHARS: usize = 128;
63
64fn require_uuid_v7_collection_ids() -> bool {
65 std::env::var("PULSE_PIXELSTREAM_REQUIRE_UUID_V7_COLLECTION_IDS").is_ok_and(|value| {
66 matches!(
67 value.trim().to_ascii_lowercase().as_str(),
68 "1" | "true" | "yes"
69 )
70 })
71}
72
73fn normalized_timeline_name(name: &str) -> String {
74 name.trim().to_lowercase()
75}
76
77fn normalized_collection_name(name: &str) -> String {
78 name.trim().to_lowercase()
79}
80
81fn 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 for legacy in ctx.exec_query(GetLegacyShotListsByQuery(LegacyShotListQuery::default()))? {
215 ctx.emit_del(legacy.as_ref())?;
216 }
217 Ok(TimelineId::from("otio-timeline-migration"))
218 }
219}
220fn timeline_candidate_is_better(candidate: &Timeline, current: &Timeline) -> bool {
221 let candidate_rank = (
222 u8::from(!candidate.library_id.trim().is_empty()),
223 u8::from(candidate.streamer_id.trim().is_empty()),
224 );
225 let current_rank = (
226 u8::from(!current.library_id.trim().is_empty()),
227 u8::from(current.streamer_id.trim().is_empty()),
228 );
229 candidate_rank > current_rank
230 || (candidate_rank == current_rank && candidate.id.to_string() < current.id.to_string())
231}
232
233fn preferred_shot_ids(library_id: &str, shots: &[Shot]) -> HashMap<String, String> {
234 let mut preferred = HashMap::<(u8, String), &Shot>::new();
235 for candidate in shots
236 .iter()
237 .filter(|shot| shot.effective_library_id() == library_id)
238 {
239 let key = (
240 shot_kind_key(&candidate.kind),
241 candidate.target_name.clone(),
242 );
243 let replace = preferred.get(&key).is_none_or(|current| {
244 let candidate_rank = u8::from(!candidate.library_id.trim().is_empty());
245 let current_rank = u8::from(!current.library_id.trim().is_empty());
246 candidate_rank > current_rank
247 || (candidate_rank == current_rank
248 && candidate.id.to_string() < current.id.to_string())
249 });
250 if replace {
251 preferred.insert(key, candidate);
252 }
253 }
254
255 shots
256 .iter()
257 .filter(|shot| shot.effective_library_id() == library_id)
258 .filter_map(|shot| {
259 preferred
260 .get(&(shot_kind_key(&shot.kind), shot.target_name.clone()))
261 .map(|winner| (shot.id.to_string(), winner.id.to_string()))
262 })
263 .collect()
264}
265
266#[derive(Default)]
267struct TimelineReconciliation {
268 upserts: Vec<Timeline>,
269 deletes: Vec<Timeline>,
270 id_migrations: HashMap<String, String>,
271}
272
273fn apply_timeline_reconciliation(
274 ctx: &CommandContext,
275 reconciliation: TimelineReconciliation,
276) -> Result<(), CommandError> {
277 for timeline in &reconciliation.upserts {
278 ctx.emit_set(timeline)?;
279 }
280
281 if !reconciliation.id_migrations.is_empty() {
282 let memberships = ctx
283 .exec_query(GetCollectionMembershipsByQuery(
284 CollectionMembershipQuery::default(),
285 ))?
286 .into_iter()
287 .map(|membership| membership.as_ref().clone())
288 .collect::<Vec<_>>();
289 for membership in &memberships {
290 let Some(timeline_id) = reconciliation.id_migrations.get(&membership.timeline_id)
291 else {
292 continue;
293 };
294 let replacement_id = CollectionMembershipId::from(CollectionMembership::stable_id(
295 &membership.collection_id,
296 timeline_id,
297 ));
298 let sort_order = memberships
299 .iter()
300 .filter(|candidate| candidate.id == replacement_id)
301 .map(|candidate| candidate.sort_order)
302 .chain(std::iter::once(membership.sort_order))
303 .min()
304 .unwrap_or(membership.sort_order);
305 let replaces_membership = replacement_id != membership.id;
306 ctx.emit_set(&CollectionMembership {
307 id: replacement_id,
308 collection_id: membership.collection_id.clone(),
309 timeline_id: timeline_id.clone(),
310 sort_order,
311 })?;
312 if replaces_membership {
313 ctx.emit_del(membership)?;
314 }
315 }
316 }
317
318 for timeline in &reconciliation.deletes {
319 ctx.emit_del(timeline)?;
320 }
321 Ok(())
322}
323
324fn reconcile_timelines(
328 library_id: &str,
329 shots: &[Shot],
330 timelines: Vec<Timeline>,
331) -> TimelineReconciliation {
332 let library_id = effective_library_id(library_id);
333 let preferred_shots = preferred_shot_ids(library_id, shots);
334 let mut groups = HashMap::<String, Vec<Timeline>>::new();
335 for timeline in timelines
336 .into_iter()
337 .filter(|timeline| timeline.effective_library_id() == library_id)
338 {
339 let name = if timeline.has_legacy_default_identity() && timeline.has_legacy_default_name() {
340 "\0legacy-default-timeline".to_owned()
341 } else {
342 normalized_timeline_name(&timeline.name)
343 };
344 if !name.is_empty() {
345 groups.entry(name).or_default().push(timeline);
346 }
347 }
348
349 let mut reconciliation = TimelineReconciliation::default();
350 for (_, mut group) in groups {
351 group.sort_by_key(|timeline| timeline.id.to_string());
352 let preferred = group
353 .iter()
354 .reduce(|current, candidate| {
355 if timeline_candidate_is_better(candidate, current) {
356 candidate
357 } else {
358 current
359 }
360 })
361 .expect("timeline groups are non-empty");
362 let is_legacy_default = group.iter().any(|timeline| {
363 timeline.has_legacy_default_identity() && timeline.has_legacy_default_name()
364 });
365 let canonical_id = if is_legacy_default {
366 TimelineId::from(Timeline::legacy_default_id(library_id))
367 } else {
368 preferred.id.clone()
369 };
370
371 let mut entries = preferred
376 .entries
377 .iter()
378 .cloned()
379 .map(|mut entry| {
380 entry.shot_id = preferred_shots
381 .get(&entry.shot_id)
382 .cloned()
383 .unwrap_or(entry.shot_id);
384 entry
385 })
386 .collect::<Vec<_>>();
387 for timeline in group.iter().filter(|timeline| timeline.id != preferred.id) {
388 for source in &timeline.entries {
389 let shot_id = preferred_shots
390 .get(&source.shot_id)
391 .cloned()
392 .unwrap_or_else(|| source.shot_id.clone());
393 for direction in source.directions() {
394 let already_present = entries.iter().any(|entry| {
395 entry.shot_id == shot_id && entry.directions().contains(direction)
396 });
397 if !already_present {
398 let mut entry = source.clone();
399 entry.entry_id.clear();
400 entry.shot_id = shot_id.clone();
401 entry.direction = ShotEntryMode::from_direction(*direction);
402 entries.push(entry);
403 }
404 }
405 }
406 }
407 let revision = group
408 .iter()
409 .map(|timeline| timeline.revision)
410 .max()
411 .unwrap_or(0);
412 let mut canonical = Timeline {
413 id: canonical_id.clone(),
414 library_id: library_id.to_owned(),
415 streamer_id: String::new(),
416 name: if is_legacy_default && preferred.has_legacy_default_name() {
417 "Migrated timeline".to_owned()
418 } else {
419 preferred.name.trim().to_owned()
420 },
421 revision,
422 entries,
423 sort_order: group
424 .iter()
425 .map(|timeline| timeline.sort_order)
426 .min()
427 .unwrap_or(preferred.sort_order),
428 };
429 canonical.normalize_entries();
430 canonical.backfill_entry_parameters(shots);
431
432 for timeline in &group {
433 if timeline.id != canonical_id {
434 reconciliation
435 .id_migrations
436 .insert(timeline.id.to_string(), canonical_id.to_string());
437 }
438 }
439
440 if group.iter().find(|timeline| timeline.id == canonical_id) != Some(&canonical) {
441 reconciliation.upserts.push(canonical);
442 }
443 reconciliation.deletes.extend(
444 group
445 .into_iter()
446 .filter(|timeline| timeline.id != canonical_id),
447 );
448 }
449 reconciliation
450}
451
452fn ensure_unique_timeline_name(
453 ctx: &CommandContext,
454 library_id: &str,
455 id: &TimelineId,
456 name: &str,
457) -> Result<(), CommandError> {
458 let normalized_name = normalized_timeline_name(name);
459 if normalized_name.is_empty() {
460 return Err(command_error(ctx, "Timeline name cannot be empty"));
461 }
462 let duplicate = ctx
463 .exec_query(GetTimelinesByQuery(TimelineQuery::default()))?
464 .into_iter()
465 .any(|timeline| {
466 timeline.id != *id
467 && timeline.effective_library_id() == effective_library_id(library_id)
468 && normalized_timeline_name(&timeline.name) == normalized_name
469 });
470 if duplicate {
471 return Err(command_error(
472 ctx,
473 format!("A timeline named ‘{}’ already exists", name.trim()),
474 ));
475 }
476 Ok(())
477}
478
479fn shot_kind_key(kind: &ShotKind) -> u8 {
480 match kind {
481 ShotKind::Moving => 0,
482 ShotKind::Static => 1,
483 }
484}
485
486fn discovered_shots_to_create(
487 library_id: &str,
488 discoveries: Vec<ShotDiscovery>,
489 existing: &[Shot],
490) -> Vec<Shot> {
491 let library_id = effective_library_id(library_id);
492 let mut known = existing
493 .iter()
494 .filter(|shot| shot.effective_library_id() == library_id)
495 .map(|shot| (shot_kind_key(&shot.kind), shot.target_name.clone()))
496 .collect::<HashSet<_>>();
497 let mut next_index = existing
498 .iter()
499 .filter(|shot| shot.effective_library_id() == library_id)
500 .map(|shot| shot.shot_index)
501 .max()
502 .map_or(0, |index| index.saturating_add(1));
503
504 discoveries
505 .into_iter()
506 .filter_map(|discovery| {
507 let name = discovery.name.trim();
508 let target_name = discovery.target_name.trim();
509 if name.is_empty() || target_name.is_empty() {
510 return None;
511 }
512 let key = (shot_kind_key(&discovery.kind), target_name.to_owned());
513 if !known.insert(key) {
514 return None;
515 }
516 let shot = Shot {
517 id: ShotId::from(Shot::stable_id(library_id, &discovery.kind, target_name)),
518 library_id: library_id.to_owned(),
519 streamer_id: String::new(),
520 name: name.to_owned(),
521 kind: discovery.kind,
522 target_name: target_name.to_owned(),
523 translation_speed_cm_s: DEFAULT_SHOT_TRANSLATION_SPEED_CM_S,
524 rotation_speed_deg_s: DEFAULT_SHOT_ROTATION_SPEED_DEG_S,
525 hold_duration_ms: DEFAULT_SHOT_HOLD_DURATION_MS,
526 travel_duration_ms: crate::DEFAULT_SHOT_TRAVEL_DURATION_MS,
527 default_entry_mode: ShotEntryMode::Forward,
528 shot_index: next_index,
529 };
530 next_index = next_index.saturating_add(1);
531 Some(shot)
532 })
533 .collect()
534}
535
536#[myko_command]
540pub struct DiscoverShots {
541 #[serde(default)]
542 pub library_id: String,
543 pub shots: Vec<ShotDiscovery>,
544}
545
546impl CommandHandler for DiscoverShots {
547 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
548 if self.shots.len() > MAX_DISCOVERED_SHOTS {
549 return Err(command_error(
550 &ctx,
551 format!("shot discovery exceeds the {MAX_DISCOVERED_SHOTS} target limit"),
552 ));
553 }
554 let mut existing = ctx
555 .exec_query(GetShotsByQuery(ShotQuery::default()))?
556 .into_iter()
557 .map(|shot| shot.as_ref().clone())
558 .collect::<Vec<_>>();
559 let created = discovered_shots_to_create(&self.library_id, self.shots, &existing);
560 for shot in &created {
561 ctx.emit_set(shot)?;
562 }
563 existing.extend(created);
564
565 let timelines = ctx
566 .exec_query(GetTimelinesByQuery(TimelineQuery::default()))?
567 .into_iter()
568 .map(|timeline| timeline.as_ref().clone())
569 .collect();
570 let reconciliation = reconcile_timelines(&self.library_id, &existing, timelines);
571 apply_timeline_reconciliation(&ctx, reconciliation)
572 }
573}
574
575#[myko_command(ShotId)]
578pub struct SetShot {
579 pub shot_id: ShotId,
580 #[serde(default)]
581 pub library_id: String,
582 #[serde(default)]
583 pub streamer_id: String,
584 pub name: String,
585 #[serde(alias = "target_kind")]
586 pub kind: ShotKind,
587 pub target_name: String,
588 pub translation_speed_cm_s: f32,
589 pub rotation_speed_deg_s: f32,
590 pub hold_duration_ms: u64,
591 #[serde(default = "crate::default_shot_travel_duration_ms")]
592 pub travel_duration_ms: u64,
593 #[serde(
594 default,
595 alias = "defaultClipMode",
596 alias = "defaultListMode",
597 alias = "enabled",
598 alias = "batchMode"
599 )]
600 pub default_entry_mode: ShotEntryMode,
601 #[serde(rename = "sortOrder", alias = "shotIndex")]
604 pub shot_index: u32,
605}
606
607impl CommandHandler for SetShot {
608 fn execute(self, ctx: CommandContext) -> Result<ShotId, CommandError> {
609 let id = self.shot_id;
610 ctx.emit_set(&Shot {
611 id: id.clone(),
612 library_id: effective_library_id(&self.library_id).to_owned(),
613 streamer_id: self.streamer_id,
614 name: self.name,
615 kind: self.kind,
616 target_name: self.target_name,
617 translation_speed_cm_s: self.translation_speed_cm_s,
618 rotation_speed_deg_s: self.rotation_speed_deg_s,
619 hold_duration_ms: self.hold_duration_ms,
620 travel_duration_ms: self.travel_duration_ms,
621 default_entry_mode: self.default_entry_mode,
622 shot_index: self.shot_index,
623 })?;
624 Ok(id)
625 }
626}
627
628#[myko_command(TimelineId)]
632pub struct SetTimeline {
633 #[serde(alias = "shotListId")]
634 pub timeline_id: TimelineId,
635 #[serde(default)]
636 pub library_id: String,
637 #[serde(default)]
638 pub streamer_id: String,
639 pub name: String,
640 #[serde(alias = "clips", alias = "cues")]
641 pub entries: Vec<ShotEntry>,
642 pub sort_order: u32,
643}
644
645impl CommandHandler for SetTimeline {
646 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
647 let id = self.timeline_id;
648 ensure_unique_timeline_name(&ctx, &self.library_id, &id, &self.name)?;
649 let current = ctx.exec_query_first(GetTimelinesByIds {
650 ids: vec![id.clone()],
651 })?;
652 let revision = current.map(|current| current.next_revision()).unwrap_or(1);
653 let mut timeline = Timeline {
654 id: id.clone(),
655 library_id: effective_library_id(&self.library_id).to_owned(),
656 streamer_id: self.streamer_id,
657 name: self.name.trim().to_owned(),
658 revision,
659 entries: self.entries,
660 sort_order: self.sort_order,
661 };
662 timeline.normalize_entries();
663 let shots = ctx
664 .exec_query(GetShotsByQuery(ShotQuery::default()))?
665 .into_iter()
666 .map(|shot| shot.as_ref().clone())
667 .collect::<Vec<_>>();
668 timeline.backfill_entry_parameters(&shots);
669 ctx.emit_set(&timeline)?;
670 Ok(id)
671 }
672}
673
674fn timeline_for_edit(
687 ctx: &CommandContext,
688 timeline_id: &TimelineId,
689) -> Result<Timeline, CommandError> {
690 ctx.exec_query_first(GetTimelinesByIds {
691 ids: vec![timeline_id.clone()],
692 })?
693 .map(|timeline| timeline.as_ref().clone())
694 .ok_or_else(|| command_error(ctx, "That timeline no longer exists"))
695}
696
697fn commit_timeline(ctx: &CommandContext, mut timeline: Timeline) -> Result<(), CommandError> {
699 timeline.revision = timeline.next_revision();
700 timeline.normalize_entries();
701 let shots = ctx
702 .exec_query(GetShotsByQuery(ShotQuery::default()))?
703 .into_iter()
704 .map(|shot| shot.as_ref().clone())
705 .collect::<Vec<_>>();
706 timeline.backfill_entry_parameters(&shots);
707 ctx.emit_set(&timeline)?;
708 Ok(())
709}
710
711#[myko_command(TimelineId)]
713pub struct AddShotEntry {
714 pub timeline_id: TimelineId,
715 pub shot_id: String,
716 #[serde(default)]
717 pub direction: ShotDirection,
718 pub entry_id: String,
720 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub position: Option<u32>,
723}
724
725impl CommandHandler for AddShotEntry {
726 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
727 let mut timeline = timeline_for_edit(&ctx, &self.timeline_id)?;
728 if timeline.entry_position(&self.entry_id).is_some() {
730 return Ok(self.timeline_id);
731 }
732 let shot = ctx
733 .exec_query_first(GetShotsByIds {
734 ids: vec![ShotId::from(self.shot_id.clone())],
735 })?
736 .ok_or_else(|| command_error(&ctx, "That shot no longer exists"))?;
737 timeline.insert_shot_entry(
738 shot.as_ref(),
739 self.direction,
740 self.entry_id,
741 self.position.map(|position| position as usize),
742 );
743 commit_timeline(&ctx, timeline)?;
744 Ok(self.timeline_id)
745 }
746}
747
748#[myko_command(TimelineId)]
751pub struct RemoveShotEntry {
752 pub timeline_id: TimelineId,
753 pub entry_id: String,
754}
755
756impl CommandHandler for RemoveShotEntry {
757 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
758 let mut timeline = timeline_for_edit(&ctx, &self.timeline_id)?;
759 if timeline.entry_position(&self.entry_id).is_none() {
760 return Ok(self.timeline_id);
762 }
763 timeline.remove_entry(&self.entry_id);
764 commit_timeline(&ctx, timeline)?;
765 Ok(self.timeline_id)
766 }
767}
768
769#[myko_command(TimelineId)]
771pub struct MoveShotEntry {
772 pub timeline_id: TimelineId,
773 pub entry_id: String,
774 pub position: u32,
776}
777
778impl CommandHandler for MoveShotEntry {
779 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
780 let mut timeline = timeline_for_edit(&ctx, &self.timeline_id)?;
781 let Some(current) = timeline.entry_position(&self.entry_id) else {
782 return Err(command_error(
783 &ctx,
784 "That shot is no longer in this timeline",
785 ));
786 };
787 let target = (self.position as usize).min(timeline.entries.len().saturating_sub(1));
788 if current == target {
789 return Ok(self.timeline_id);
790 }
791 timeline.move_entry(&self.entry_id, target);
792 commit_timeline(&ctx, timeline)?;
793 Ok(self.timeline_id)
794 }
795}
796
797#[myko_command(TimelineId)]
799pub struct SetShotEntryDirection {
800 pub timeline_id: TimelineId,
801 pub entry_id: String,
802 pub direction: ShotDirection,
803}
804
805impl CommandHandler for SetShotEntryDirection {
806 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
807 let mut timeline = timeline_for_edit(&ctx, &self.timeline_id)?;
808 if timeline.entry_position(&self.entry_id).is_none() {
809 return Err(command_error(
810 &ctx,
811 "That shot is no longer in this timeline",
812 ));
813 }
814 timeline.set_entry_direction(&self.entry_id, self.direction);
815 commit_timeline(&ctx, timeline)?;
816 Ok(self.timeline_id)
817 }
818}
819
820#[myko_command(TimelineId)]
823pub struct RemoveTimeline {
824 #[serde(alias = "shotListId")]
825 pub timeline_id: TimelineId,
826}
827
828impl CommandHandler for RemoveTimeline {
829 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
830 let id = self.timeline_id;
831 let current = ctx
832 .exec_query_first(GetTimelinesByIds {
833 ids: vec![id.clone()],
834 })?
835 .ok_or_else(|| command_error(&ctx, format!("Timeline {id} does not exist")))?;
836 for membership in ctx
837 .exec_query(GetCollectionMembershipsByQuery(
838 CollectionMembershipQuery::default(),
839 ))?
840 .into_iter()
841 .filter(|membership| membership.timeline_id == id.to_string())
842 {
843 ctx.emit_del(membership.as_ref())?;
844 }
845 ctx.emit_del(current.as_ref())?;
846 Ok(id)
847 }
848}
849
850#[myko_command(CollectionId)]
854pub struct SetCollection {
855 pub collection_id: CollectionId,
856 #[serde(default)]
857 pub library_id: String,
858 #[serde(default)]
859 pub parent_id: String,
860 pub name: String,
861 #[serde(default)]
862 pub sort_order: u32,
863 #[serde(default)]
864 pub metadata: HashMap<String, String>,
865}
866
867impl CommandHandler for SetCollection {
868 fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
869 let id = self.collection_id;
870 let library_id = effective_library_id(&self.library_id).to_owned();
871 let name = self.name.trim().to_owned();
872 if name.is_empty() {
873 return Err(command_error(&ctx, "Collection name cannot be empty"));
874 }
875 if name.chars().count() > MAX_COLLECTION_NAME_CHARS {
876 return Err(command_error(
877 &ctx,
878 format!("Collection names are limited to {MAX_COLLECTION_NAME_CHARS} characters"),
879 ));
880 }
881 let mut collections = ctx
882 .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
883 .into_iter()
884 .map(|collection| collection.as_ref().clone())
885 .collect::<Vec<_>>();
886 if !collections.iter().any(|collection| collection.id == id)
887 && !crate::collection::is_uuid_v7(id.as_ref())
888 {
889 if require_uuid_v7_collection_ids() {
890 return Err(command_error(
891 &ctx,
892 "New collection IDs must be UUIDv7; existing legacy collections remain editable",
893 ));
894 }
895 eprintln!(
896 "accepted legacy native collection id during UUIDv7 rollout: {}",
897 id.as_ref()
898 );
899 }
900 if collections.iter().any(|collection| {
901 collection.id != id
902 && collection.library_id == library_id
903 && collection.parent_id == self.parent_id
904 && normalized_collection_name(&collection.name) == normalized_collection_name(&name)
905 }) {
906 return Err(command_error(
907 &ctx,
908 format!("A collection named ‘{name}’ already exists here"),
909 ));
910 }
911 if !self.parent_id.trim().is_empty() {
912 let parent = collections
913 .iter()
914 .find(|collection| collection.id.to_string() == self.parent_id)
915 .ok_or_else(|| command_error(&ctx, "Parent collection does not exist"))?;
916 if parent.library_id != library_id {
917 return Err(command_error(
918 &ctx,
919 "A collection cannot be moved between libraries",
920 ));
921 }
922 if parent.id == id {
923 return Err(command_error(&ctx, "A collection cannot contain itself"));
924 }
925 let parent_path = resolve_collection_path(&self.parent_id, &collections)
926 .map_err(|error| command_error(&ctx, error))?;
927 if parent_path.len() >= MAX_COLLECTION_DEPTH {
928 return Err(command_error(
929 &ctx,
930 format!("Collections are limited to {MAX_COLLECTION_DEPTH} levels"),
931 ));
932 }
933 if parent_path
934 .iter()
935 .any(|segment| segment.collection_id == id.to_string())
936 {
937 return Err(command_error(
938 &ctx,
939 "A collection cannot be moved inside one of its descendants",
940 ));
941 }
942 }
943 let collection = Collection {
944 id: id.clone(),
945 library_id,
946 parent_id: self.parent_id,
947 name,
948 sort_order: self.sort_order,
949 metadata: self.metadata,
950 };
951 if let Some(current) = collections.iter_mut().find(|row| row.id == id) {
952 *current = collection.clone();
953 } else {
954 collections.push(collection.clone());
955 }
956 resolve_collection_path(id.as_ref(), &collections)
958 .map_err(|error| command_error(&ctx, error))?;
959 ctx.emit_set(&collection)?;
960 Ok(id)
961 }
962}
963
964#[myko_command(CollectionId)]
968pub struct RemoveCollection {
969 pub collection_id: CollectionId,
970}
971
972impl CommandHandler for RemoveCollection {
973 fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
974 let id = self.collection_id;
975 let current = ctx
976 .exec_query_first(GetCollectionsByIds {
977 ids: vec![id.clone()],
978 })?
979 .ok_or_else(|| command_error(&ctx, format!("Collection {id} does not exist")))?;
980 let has_children = ctx
981 .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
982 .into_iter()
983 .any(|collection| collection.parent_id == id.to_string());
984 if has_children {
985 return Err(command_error(
986 &ctx,
987 "Move or remove child collections before deleting this collection",
988 ));
989 }
990 for membership in ctx
991 .exec_query(GetCollectionMembershipsByQuery(
992 CollectionMembershipQuery::default(),
993 ))?
994 .into_iter()
995 .filter(|membership| membership.collection_id == id.to_string())
996 {
997 ctx.emit_del(membership.as_ref())?;
998 }
999 ctx.emit_del(current.as_ref())?;
1000 Ok(id)
1001 }
1002}
1003
1004#[myko_command(CollectionMembershipId)]
1006pub struct SetCollectionMembership {
1007 pub collection_id: String,
1008 #[serde(alias = "shotListId")]
1009 pub timeline_id: String,
1010 pub included: bool,
1011 #[serde(default)]
1012 pub sort_order: u32,
1013}
1014
1015impl CommandHandler for SetCollectionMembership {
1016 fn execute(self, ctx: CommandContext) -> Result<CollectionMembershipId, CommandError> {
1017 let id = CollectionMembershipId::from(CollectionMembership::stable_id(
1018 &self.collection_id,
1019 &self.timeline_id,
1020 ));
1021 let existing = ctx.exec_query_first(GetCollectionMembershipsByIds {
1022 ids: vec![id.clone()],
1023 })?;
1024 if !self.included {
1025 if let Some(existing) = existing {
1026 ctx.emit_del(existing.as_ref())?;
1027 }
1028 return Ok(id);
1029 }
1030 let collection = ctx
1031 .exec_query_first(GetCollectionsByIds {
1032 ids: vec![CollectionId::from(self.collection_id.clone())],
1033 })?
1034 .ok_or_else(|| command_error(&ctx, "Collection does not exist"))?;
1035 let timeline = ctx
1036 .exec_query_first(GetTimelinesByIds {
1037 ids: vec![TimelineId::from(self.timeline_id.clone())],
1038 })?
1039 .ok_or_else(|| command_error(&ctx, "Timeline does not exist"))?;
1040 if collection.library_id != timeline.effective_library_id() {
1041 return Err(command_error(
1042 &ctx,
1043 "Collection and timeline belong to different libraries",
1044 ));
1045 }
1046 ctx.emit_set(&CollectionMembership {
1047 id: id.clone(),
1048 collection_id: self.collection_id,
1049 timeline_id: self.timeline_id,
1050 sort_order: self.sort_order,
1051 })?;
1052 Ok(id)
1053 }
1054}
1055
1056#[myko_command(TimelineId)]
1060pub struct ImportTimelineOtio {
1061 pub otio_json: String,
1062 pub sort_order: u32,
1063}
1064
1065impl CommandHandler for ImportTimelineOtio {
1066 fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
1067 if self.otio_json.len() > MAX_OTIO_IMPORT_BYTES {
1068 return Err(command_error(
1069 &ctx,
1070 format!(
1071 "OTIO import exceeds the {} byte limit",
1072 MAX_OTIO_IMPORT_BYTES
1073 ),
1074 ));
1075 }
1076 let imported = crate::import_timeline_otio_json(&self.otio_json)
1077 .map_err(|error| command_error(&ctx, error.to_string()))?;
1078 if imported.shots.len() > 10_000 {
1079 return Err(command_error(&ctx, "OTIO import contains too many shots"));
1080 }
1081 ensure_unique_timeline_name(
1082 &ctx,
1083 imported.timeline.effective_library_id(),
1084 &imported.timeline.id,
1085 &imported.timeline.name,
1086 )?;
1087
1088 let mut timeline = imported.timeline;
1089 timeline.backfill_entry_parameters(&imported.shots);
1090 for shot in imported.shots {
1091 ctx.emit_set(&shot)?;
1092 }
1093
1094 if let Some(current) = ctx.exec_query_first(GetTimelinesByIds {
1095 ids: vec![timeline.id.clone()],
1096 })? {
1097 timeline.revision = timeline.revision.max(current.next_revision());
1098 }
1099 timeline.sort_order = self.sort_order;
1100 timeline.normalize_entries();
1101 let id = timeline.id.clone();
1102 ctx.emit_set(&timeline)?;
1103 Ok(id)
1104 }
1105}
1106
1107#[myko_command(CollectionId)]
1110pub struct ImportCollectionOtio {
1111 pub otio_json: String,
1112}
1113
1114impl CommandHandler for ImportCollectionOtio {
1115 fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
1116 if self.otio_json.len() > MAX_OTIO_IMPORT_BYTES {
1117 return Err(command_error(
1118 &ctx,
1119 format!(
1120 "OTIO import exceeds the {} byte limit",
1121 MAX_OTIO_IMPORT_BYTES
1122 ),
1123 ));
1124 }
1125 let mut imported = crate::import_collection_otio_json(&self.otio_json)
1126 .map_err(|error| command_error(&ctx, error.to_string()))?;
1127 if imported.collections.is_empty() {
1128 return Err(command_error(&ctx, "OTIO collection is empty"));
1129 }
1130 if imported.collections.len() > 10_000
1131 || imported.memberships.len() > 100_000
1132 || imported.timelines.len() > 10_000
1133 || imported.shots.len() > 10_000
1134 {
1135 return Err(command_error(&ctx, "OTIO collection exceeds import limits"));
1136 }
1137 let root_id = imported.collections[0].id.clone();
1138 let imported_collection_ids = imported
1139 .collections
1140 .iter()
1141 .map(|collection| collection.id.to_string())
1142 .collect::<HashSet<_>>();
1143 let existing_collections = ctx
1144 .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
1145 .into_iter()
1146 .map(|collection| collection.as_ref().clone())
1147 .collect::<Vec<_>>();
1148 for collection in &imported.collections {
1149 let duplicate = imported
1150 .collections
1151 .iter()
1152 .chain(
1153 existing_collections
1154 .iter()
1155 .filter(|existing| !imported_collection_ids.contains(existing.id.as_ref())),
1156 )
1157 .any(|candidate| {
1158 candidate.id != collection.id
1159 && candidate.library_id == collection.library_id
1160 && candidate.parent_id == collection.parent_id
1161 && normalized_collection_name(&candidate.name)
1162 == normalized_collection_name(&collection.name)
1163 });
1164 if duplicate {
1165 return Err(command_error(
1166 &ctx,
1167 format!(
1168 "A collection named ‘{}’ already exists at the imported location",
1169 collection.name
1170 ),
1171 ));
1172 }
1173 }
1174
1175 let imported_timeline_ids = imported
1176 .timelines
1177 .iter()
1178 .map(|timeline| timeline.id.to_string())
1179 .collect::<HashSet<_>>();
1180 let existing_timelines = ctx
1181 .exec_query(GetTimelinesByQuery(TimelineQuery::default()))?
1182 .into_iter()
1183 .map(|timeline| timeline.as_ref().clone())
1184 .collect::<Vec<_>>();
1185 for timeline in &imported.timelines {
1186 let duplicate = imported
1187 .timelines
1188 .iter()
1189 .chain(
1190 existing_timelines
1191 .iter()
1192 .filter(|existing| !imported_timeline_ids.contains(existing.id.as_ref())),
1193 )
1194 .any(|candidate| {
1195 candidate.id != timeline.id
1196 && candidate.effective_library_id() == timeline.effective_library_id()
1197 && normalized_timeline_name(&candidate.name)
1198 == normalized_timeline_name(&timeline.name)
1199 });
1200 if duplicate {
1201 return Err(command_error(
1202 &ctx,
1203 format!("A timeline named ‘{}’ already exists", timeline.name),
1204 ));
1205 }
1206 }
1207
1208 for shot in imported.shots {
1209 ctx.emit_set(&shot)?;
1210 }
1211 for timeline in &mut imported.timelines {
1212 timeline.normalize_entries();
1213 if let Some(current) = existing_timelines
1214 .iter()
1215 .find(|current| current.id == timeline.id)
1216 {
1217 timeline.revision = timeline.revision.max(current.next_revision());
1218 }
1219 ctx.emit_set(timeline)?;
1220 }
1221 for collection in imported.collections {
1222 ctx.emit_set(&collection)?;
1223 }
1224 for membership in imported.memberships {
1225 ctx.emit_set(&membership)?;
1226 }
1227 Ok(root_id)
1228 }
1229}
1230
1231fn command_error(ctx: &CommandContext, message: impl Into<String>) -> CommandError {
1232 CommandError {
1233 tx: ctx.tx().to_string(),
1234 command_id: ctx.command_id.to_string(),
1235 message: message.into(),
1236 }
1237}
1238
1239#[myko_command(RecordingJobRequestId)]
1241pub struct ControlRecordingJob {
1242 pub streamer_id: String,
1243 #[serde(alias = "runId")]
1244 pub job_id: String,
1245 pub command_id: String,
1246 pub action: RecordingJobAction,
1247 #[serde(default, skip_serializing_if = "Option::is_none")]
1249 #[ts(type = "unknown")]
1250 pub capture_context: Option<crate::CaptureContext>,
1251 #[serde(default, alias = "shotListId")]
1254 pub timeline_id: String,
1255 #[serde(default)]
1258 pub collection_id: String,
1259 #[serde(default, alias = "shots", alias = "items")]
1262 pub entries: Vec<ShotEntryPlan>,
1263 #[serde(default)]
1269 pub entry_ids: Vec<String>,
1270 #[serde(default)]
1271 pub preset_duration_ms: u64,
1272 #[serde(default)]
1273 pub translation_speed_cm_s: f32,
1274 #[serde(default)]
1275 pub rotation_speed_deg_s: f32,
1276 #[serde(default)]
1277 pub requested_at_ms: u64,
1278}
1279
1280impl CommandHandler for ControlRecordingJob {
1281 fn execute(self, ctx: CommandContext) -> Result<RecordingJobRequestId, CommandError> {
1282 let (
1283 timeline_id,
1284 timeline_name,
1285 timeline_revision,
1286 collection_id,
1287 collection_path,
1288 entries,
1289 ) = if self.action == RecordingJobAction::StartView
1290 || (self.action == RecordingJobAction::StartScreenshot
1291 && self.timeline_id.trim().is_empty())
1292 {
1293 if self.job_id.trim().is_empty() {
1299 return Err(command_error(
1300 &ctx,
1301 "StartView requires a stable RecordingJob id",
1302 ));
1303 }
1304 let requested_shot_id = self
1305 .entries
1306 .first()
1307 .map(|entry| entry.shot_id.trim())
1308 .filter(|id| !id.is_empty());
1309 let requested_direction = self
1310 .entries
1311 .first()
1312 .map(|entry| entry.direction)
1313 .unwrap_or_default();
1314 let mut entry = if let Some(shot_id) = requested_shot_id {
1315 let shot = ctx
1316 .exec_query(GetShotsByQuery(ShotQuery::default()))?
1317 .into_iter()
1318 .find(|shot| shot.id.as_ref() == shot_id)
1319 .ok_or_else(|| command_error(&ctx, "selected Shot no longer exists"))?;
1320 ShotEntryPlan {
1321 entry_id: format!("library:{}", shot.id.as_ref()),
1322 shot_id: shot.id.to_string(),
1323 name: shot.name.clone(),
1324 shot_index: Some(shot.shot_index),
1325 kind: shot.kind.clone(),
1326 target_name: shot.target_name.clone(),
1327 translation_speed_cm_s: shot.translation_speed_cm_s,
1328 rotation_speed_deg_s: shot.rotation_speed_deg_s,
1329 hold_duration_ms: shot.hold_duration_ms,
1330 travel_duration_ms: shot.travel_duration_ms,
1331 direction: requested_direction,
1332 next_take_number: 1,
1333 open_ended: false,
1334 }
1335 } else {
1336 let label = self
1337 .entries
1338 .first()
1339 .map(|entry| entry.name.trim().to_owned())
1340 .filter(|name| !name.is_empty())
1341 .unwrap_or_else(|| "freefly".to_owned());
1342 ShotEntryPlan {
1343 entry_id: "view".to_owned(),
1344 shot_id: String::new(),
1345 name: label,
1346 shot_index: None,
1347 kind: crate::ShotKind::Static,
1348 target_name: String::new(),
1349 translation_speed_cm_s: 0.0,
1350 rotation_speed_deg_s: 0.0,
1351 hold_duration_ms: 0,
1352 travel_duration_ms: 0,
1353 direction: crate::ShotDirection::default(),
1354 next_take_number: 1,
1355 open_ended: true,
1356 }
1357 };
1358 entry.open_ended = self.action == RecordingJobAction::StartView;
1359 (
1360 String::new(),
1361 String::new(),
1362 0,
1363 String::new(),
1364 Vec::new(),
1365 vec![entry],
1366 )
1367 } else if matches!(
1368 self.action,
1369 RecordingJobAction::Start | RecordingJobAction::StartScreenshot
1370 ) {
1371 if self.capture_context.is_none() {
1372 return Err(command_error(
1373 &ctx,
1374 "Start requires editorial capture context",
1375 ));
1376 }
1377 if self.timeline_id.trim().is_empty() {
1378 return Err(CommandError {
1379 tx: ctx.tx().to_string(),
1380 command_id: ctx.command_id.to_string(),
1381 message: "Start requires a persistent Timeline id".to_owned(),
1382 });
1383 }
1384 if self.job_id.trim().is_empty() {
1385 return Err(CommandError {
1386 tx: ctx.tx().to_string(),
1387 command_id: ctx.command_id.to_string(),
1388 message: "Start requires a stable RecordingJob id".to_owned(),
1389 });
1390 }
1391 let timeline_id = TimelineId::from(self.timeline_id);
1392 let current = ctx
1393 .exec_query_first(GetTimelinesByIds {
1394 ids: vec![timeline_id.clone()],
1395 })?
1396 .ok_or_else(|| CommandError {
1397 tx: ctx.tx().to_string(),
1398 command_id: ctx.command_id.to_string(),
1399 message: format!("Timeline {timeline_id} does not exist"),
1400 })?;
1401 let mut timeline = (*current).clone();
1402 let (collection_id, collection_path) = if self.collection_id.trim().is_empty() {
1403 (String::new(), Vec::new())
1404 } else {
1405 let membership_id = CollectionMembershipId::from(CollectionMembership::stable_id(
1406 &self.collection_id,
1407 timeline_id.as_ref(),
1408 ));
1409 if ctx
1410 .exec_query_first(GetCollectionMembershipsByIds {
1411 ids: vec![membership_id],
1412 })?
1413 .is_none()
1414 {
1415 return Err(command_error(
1416 &ctx,
1417 "Timeline is not assigned to the selected collection",
1418 ));
1419 }
1420 let collections = ctx
1421 .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
1422 .into_iter()
1423 .map(|collection| collection.as_ref().clone())
1424 .collect::<Vec<_>>();
1425 let path = resolve_collection_path(&self.collection_id, &collections)
1426 .map_err(|error| command_error(&ctx, error))?;
1427 (self.collection_id.clone(), path)
1428 };
1429 let revision = timeline.revision;
1430 let name = timeline.name.clone();
1431 let shot_rows = ctx
1432 .exec_query(GetShotsByQuery(ShotQuery::default()))?
1433 .into_iter()
1434 .map(|shot| shot.as_ref().clone())
1435 .collect::<Vec<_>>();
1436 let timeline_changed = timeline.backfill_entry_parameters(&shot_rows);
1437 let mut entries = crate::resolve_timeline_plans(&timeline, &shot_rows)
1438 .map_err(|error| command_error(&ctx, error))?;
1439 if entries.is_empty() {
1440 return Err(command_error(&ctx, "Timeline has no shot entries"));
1441 }
1442 if !self.entry_ids.is_empty() {
1446 let wanted: std::collections::HashSet<&str> =
1447 self.entry_ids.iter().map(String::as_str).collect();
1448 entries.retain(|entry| wanted.contains(entry.entry_id.as_str()));
1449 if entries.len() != self.entry_ids.len() {
1450 return Err(command_error(
1451 &ctx,
1452 "one or more requested shot entries are not in this timeline",
1453 ));
1454 }
1455 }
1456 if self.action == RecordingJobAction::StartScreenshot && entries.len() != 1 {
1457 return Err(command_error(
1458 &ctx,
1459 "StartScreenshot requires exactly one shot entry",
1460 ));
1461 }
1462 let prior_jobs = ctx
1463 .exec_query(GetRecordingJobsByQuery(RecordingJobQuery::default()))?
1464 .into_iter()
1465 .map(|job| job.as_ref().clone())
1466 .collect::<Vec<_>>();
1467 for entry in &mut entries {
1468 entry.next_take_number = crate::next_take_number_for_entry(
1469 &prior_jobs,
1470 &collection_id,
1471 timeline_id.as_ref(),
1472 entry,
1473 );
1474 }
1475 if timeline_changed {
1476 ctx.emit_set(&timeline)?;
1477 }
1478 (
1479 timeline_id.to_string(),
1480 name,
1481 revision,
1482 collection_id,
1483 collection_path,
1484 entries,
1485 )
1486 } else {
1487 (
1488 String::new(),
1489 String::new(),
1490 0,
1491 String::new(),
1492 Vec::new(),
1493 self.entries,
1494 )
1495 };
1496 let id: RecordingJobRequestId = self.streamer_id.clone().into();
1497 let capture_context = self.capture_context.map(StoredCaptureContext::from);
1498 ctx.emit_set(&RecordingJobRequest {
1499 id: id.clone(),
1500 streamer_id: self.streamer_id.clone(),
1501 job_id: self.job_id.clone(),
1502 command_id: self.command_id,
1503 action: self.action.clone(),
1504 capture_kind: if self.action == RecordingJobAction::StartScreenshot {
1505 crate::RecordingKind::Screenshot
1506 } else {
1507 crate::RecordingKind::Video
1508 },
1509 capture_context: capture_context.clone(),
1510 timeline_id: timeline_id.clone(),
1511 timeline_name: timeline_name.clone(),
1512 timeline_revision,
1513 collection_id: collection_id.clone(),
1514 collection_path: collection_path.clone(),
1515 entries: entries.clone(),
1516 preset_duration_ms: self.preset_duration_ms,
1517 translation_speed_cm_s: self.translation_speed_cm_s,
1518 rotation_speed_deg_s: self.rotation_speed_deg_s,
1519 requested_at_ms: self.requested_at_ms,
1520 })?;
1521 if matches!(
1522 self.action,
1523 RecordingJobAction::Start | RecordingJobAction::StartScreenshot
1524 ) {
1525 ctx.emit_set(&RecordingJob {
1526 id: RecordingJobId::from(self.job_id.clone()),
1527 job_id: self.job_id,
1528 capture_kind: if self.action == RecordingJobAction::StartScreenshot {
1529 crate::RecordingKind::Screenshot
1530 } else {
1531 crate::RecordingKind::Video
1532 },
1533 streamer_id: self.streamer_id,
1534 capture_context,
1535 timeline_id,
1536 timeline_name,
1537 timeline_revision,
1538 collection_id,
1539 collection_path,
1540 phase: RecordingJobPhase::Idle,
1541 pause_requested: false,
1542 entries,
1543 takes: Vec::new(),
1544 error: String::new(),
1545 started_at_ms: 0,
1546 updated_at_ms: self.requested_at_ms,
1547 elapsed_ms: 0,
1548 estimated_total_ms: 0,
1549 estimated_remaining_ms: 0,
1550 })?;
1551 }
1552 Ok(id)
1553 }
1554}
1555
1556#[myko_command(RecordingJobStatusId)]
1558pub struct SetRecordingJobStatus {
1559 pub streamer_id: String,
1560 #[serde(alias = "runId")]
1561 pub job_id: String,
1562 #[serde(default)]
1563 pub capture_kind: crate::RecordingKind,
1564 #[serde(default, skip_serializing_if = "Option::is_none")]
1565 #[ts(type = "unknown")]
1566 pub capture_context: Option<crate::CaptureContext>,
1567 #[serde(default, alias = "shotListId")]
1568 pub timeline_id: String,
1569 #[serde(default, alias = "shotListName")]
1570 pub timeline_name: String,
1571 #[serde(default, alias = "timelineVersion", alias = "shotListVersion")]
1572 pub timeline_revision: u32,
1573 #[serde(default)]
1574 pub collection_id: String,
1575 #[serde(default)]
1576 pub collection_path: Vec<crate::CollectionPathSegment>,
1577 pub phase: RecordingJobPhase,
1578 #[serde(default)]
1579 pub pause_requested: bool,
1580 #[serde(default, alias = "shots", alias = "items")]
1581 pub entries: Vec<ShotEntryPlan>,
1582 #[serde(default)]
1583 pub index: u32,
1584 #[serde(default)]
1585 pub completed: u32,
1586 #[serde(default)]
1587 pub error: String,
1588 #[serde(default)]
1589 pub updated_at_ms: u64,
1590 #[serde(default)]
1591 pub elapsed_ms: u64,
1592 #[serde(default)]
1593 pub estimated_total_ms: u64,
1594 #[serde(default)]
1595 pub estimated_remaining_ms: u64,
1596 #[serde(default)]
1597 pub takes: Vec<Take>,
1598 #[serde(default)]
1599 pub started_at_ms: u64,
1600}
1601
1602fn recording_job_status_is_heartbeat_only(
1610 candidate: &RecordingJobStatus,
1611 stored: &RecordingJobStatus,
1612) -> bool {
1613 let mut probe = candidate.clone();
1614 probe.updated_at_ms = stored.updated_at_ms;
1615 probe == *stored
1616}
1617
1618fn recording_job_status_is_historical(
1619 candidate: &RecordingJobStatus,
1620 current: &RecordingJobStatus,
1621) -> bool {
1622 candidate.job_id != current.job_id && candidate.started_at_ms <= current.started_at_ms
1623}
1624
1625fn target_summary_is_heartbeat_only(
1627 candidate: &crate::FrameCaptureTargetSummary,
1628 stored: &crate::FrameCaptureTargetSummary,
1629) -> bool {
1630 let mut probe = candidate.clone();
1631 probe.updated_at_ms = stored.updated_at_ms;
1632 probe == *stored
1633}
1634
1635fn previs_target_is_current(
1636 summary: &crate::FrameCaptureTargetSummary,
1637 streamer_id: &str,
1638 target: &crate::PrevisProcessTarget,
1639) -> bool {
1640 target.host == streamer_id && summary.previs_targets.contains(target)
1641}
1642
1643impl CommandHandler for SetRecordingJobStatus {
1644 fn execute(self, ctx: CommandContext) -> Result<RecordingJobStatusId, CommandError> {
1645 let id: RecordingJobStatusId = self.streamer_id.clone().into();
1646 let mut takes = self.takes;
1647 let job_id = self.job_id.clone();
1648 let existing_job = if !job_id.trim().is_empty() {
1649 ctx.exec_query_first(GetRecordingJobsByIds {
1650 ids: vec![RecordingJobId::from(job_id.clone())],
1651 })?
1652 } else {
1653 None
1654 };
1655 if let Some(existing) = &existing_job {
1656 for take in &mut takes {
1657 let accepted = existing.takes.iter().any(|saved| {
1658 saved.take_id == take.take_id
1659 && saved.capture.as_ref().is_some_and(|capture| {
1660 capture.creative_status == CreativeStatus::Accepted
1661 })
1662 });
1663 if accepted {
1664 if let Some(capture) = &mut take.capture {
1665 capture.creative_status = CreativeStatus::Accepted;
1666 }
1667 }
1668 }
1669 }
1670 let mirror_present = existing_job.is_some();
1671 let capture_context = self
1672 .capture_context
1673 .map(StoredCaptureContext::from)
1674 .or_else(|| {
1675 existing_job
1676 .as_ref()
1677 .and_then(|job| job.capture_context.clone())
1678 });
1679 let status = RecordingJobStatus {
1680 id: id.clone(),
1681 streamer_id: self.streamer_id,
1682 job_id: job_id.clone(),
1683 capture_kind: self.capture_kind,
1684 capture_context,
1685 timeline_id: self.timeline_id,
1686 timeline_name: self.timeline_name,
1687 timeline_revision: self.timeline_revision,
1688 collection_id: self.collection_id,
1689 collection_path: self.collection_path,
1690 phase: self.phase,
1691 pause_requested: self.pause_requested,
1692 entries: self.entries,
1693 index: self.index,
1694 completed: self.completed,
1695 error: self.error,
1696 updated_at_ms: self.updated_at_ms,
1697 elapsed_ms: self.elapsed_ms,
1698 estimated_total_ms: self.estimated_total_ms,
1699 estimated_remaining_ms: self.estimated_remaining_ms,
1700 takes,
1701 started_at_ms: self.started_at_ms,
1702 };
1703 let stored_status = ctx.exec_query_first(GetRecordingJobStatussByIds {
1704 ids: vec![id.clone()],
1705 })?;
1706 let historical_update = existing_job.is_some()
1707 && stored_status
1708 .as_ref()
1709 .is_some_and(|current| recording_job_status_is_historical(&status, current));
1710 if historical_update {
1711 ctx.emit_set(&RecordingJob {
1712 id: RecordingJobId::from(job_id.clone()),
1713 job_id,
1714 capture_kind: status.capture_kind,
1715 streamer_id: status.streamer_id.clone(),
1716 capture_context: status.capture_context.clone(),
1717 timeline_id: status.timeline_id.clone(),
1718 timeline_name: status.timeline_name.clone(),
1719 timeline_revision: status.timeline_revision,
1720 collection_id: status.collection_id.clone(),
1721 collection_path: status.collection_path.clone(),
1722 phase: status.phase.clone(),
1723 pause_requested: status.pause_requested,
1724 entries: status.entries.clone(),
1725 takes: status.takes.clone(),
1726 error: status.error.clone(),
1727 started_at_ms: status.started_at_ms,
1728 updated_at_ms: status.updated_at_ms,
1729 elapsed_ms: status.elapsed_ms,
1730 estimated_total_ms: status.estimated_total_ms,
1731 estimated_remaining_ms: status.estimated_remaining_ms,
1732 })?;
1733 return Ok(id);
1734 }
1735 let unchanged = stored_status
1746 .is_some_and(|existing| recording_job_status_is_heartbeat_only(&status, &existing));
1747 if unchanged && (job_id.trim().is_empty() || mirror_present) {
1750 return Ok(id);
1751 }
1752 ctx.emit_set(&status)?;
1753 if !job_id.trim().is_empty() {
1754 ctx.emit_set(&RecordingJob {
1755 id: RecordingJobId::from(job_id.clone()),
1756 job_id,
1757 capture_kind: status.capture_kind,
1758 streamer_id: status.streamer_id.clone(),
1759 capture_context: status.capture_context.clone(),
1760 timeline_id: status.timeline_id.clone(),
1761 timeline_name: status.timeline_name.clone(),
1762 timeline_revision: status.timeline_revision,
1763 collection_id: status.collection_id.clone(),
1764 collection_path: status.collection_path.clone(),
1765 phase: status.phase.clone(),
1766 pause_requested: status.pause_requested,
1767 entries: status.entries.clone(),
1768 takes: status.takes.clone(),
1769 error: status.error.clone(),
1770 started_at_ms: status.started_at_ms,
1771 updated_at_ms: status.updated_at_ms,
1772 elapsed_ms: status.elapsed_ms,
1773 estimated_total_ms: status.estimated_total_ms,
1774 estimated_remaining_ms: status.estimated_remaining_ms,
1775 })?;
1776 }
1777 Ok(id)
1778 }
1779}
1780
1781#[myko_command(RecordingJobId)]
1784pub struct AcceptTake {
1785 pub job_id: String,
1786 pub take_id: String,
1787}
1788
1789impl CommandHandler for AcceptTake {
1790 fn execute(self, ctx: CommandContext) -> Result<RecordingJobId, CommandError> {
1791 let id = RecordingJobId::from(self.job_id);
1792 let current = ctx
1793 .exec_query_first(GetRecordingJobsByIds {
1794 ids: vec![id.clone()],
1795 })?
1796 .ok_or_else(|| command_error(&ctx, format!("Recording job {id} does not exist")))?;
1797 let mut job = current.as_ref().clone();
1798 let take = job
1799 .takes
1800 .iter_mut()
1801 .find(|take| take.take_id == self.take_id)
1802 .ok_or_else(|| command_error(&ctx, "Take does not exist"))?;
1803 let Some(capture) = &mut take.capture else {
1804 return Err(command_error(&ctx, "Take has no Capture to accept"));
1805 };
1806 if take.state != TakeState::Completed
1807 || capture.delivery_status != DeliveryStatus::Delivered
1808 {
1809 return Err(command_error(
1810 &ctx,
1811 "Only delivered Captures can be accepted",
1812 ));
1813 }
1814 capture.creative_status = CreativeStatus::Accepted;
1815 ctx.emit_set(&job)?;
1816 Ok(id)
1817 }
1818}
1819
1820#[myko_command(CamPrefId)]
1825pub struct SetCamPref {
1826 pub focal: f32,
1827 pub aperture: f32,
1828 pub focus_method: String,
1829 pub focus_dist: f32,
1830 pub base_speed: f32,
1831 pub look_scale: f32,
1832 pub invert: bool,
1833 pub glide: bool,
1834 pub glide_secs: f32,
1835 pub motion_blur: f32,
1836 pub rail_speed: f32,
1837}
1838
1839impl CommandHandler for SetCamPref {
1840 fn execute(self, ctx: CommandContext) -> Result<CamPrefId, CommandError> {
1841 let id = CamPref::row_id();
1842 let pref = CamPref {
1843 id: id.clone(),
1844 focal: self.focal,
1845 aperture: self.aperture,
1846 focus_method: self.focus_method,
1847 focus_dist: self.focus_dist,
1848 base_speed: self.base_speed,
1849 look_scale: self.look_scale,
1850 invert: self.invert,
1851 glide: self.glide,
1852 glide_secs: self.glide_secs,
1853 motion_blur: self.motion_blur,
1854 rail_speed: self.rail_speed,
1855 };
1856 ctx.emit_set(&pref)?;
1857 Ok(id)
1858 }
1859}
1860
1861#[myko_command(CameraHomeId)]
1864pub struct SetCameraHome {
1865 pub stream_id: String,
1866 pub location_x: f32,
1867 pub location_y: f32,
1868 pub location_z: f32,
1869 pub rotation_pitch: f32,
1870 pub rotation_yaw: f32,
1871 pub rotation_roll: f32,
1872 pub focal_length: f32,
1873}
1874
1875impl CommandHandler for SetCameraHome {
1876 fn execute(self, ctx: CommandContext) -> Result<CameraHomeId, CommandError> {
1877 if self.stream_id.trim().is_empty() {
1878 return Err(command_error(&ctx, "Camera Home requires a stream id"));
1879 }
1880 let values = [
1881 self.location_x,
1882 self.location_y,
1883 self.location_z,
1884 self.rotation_pitch,
1885 self.rotation_yaw,
1886 self.rotation_roll,
1887 self.focal_length,
1888 ];
1889 if values.iter().any(|value| !value.is_finite()) {
1890 return Err(command_error(&ctx, "Camera Home values must be finite"));
1891 }
1892 if !(1.0..=1000.0).contains(&self.focal_length) {
1893 return Err(command_error(
1894 &ctx,
1895 "Camera Home focal length must be between 1 and 1000 mm",
1896 ));
1897 }
1898
1899 let id = CameraHome::row_id(&self.stream_id);
1900 ctx.emit_set(&CameraHome {
1901 id: id.clone(),
1902 stream_id: self.stream_id,
1903 location_x: self.location_x,
1904 location_y: self.location_y,
1905 location_z: self.location_z,
1906 rotation_pitch: self.rotation_pitch,
1907 rotation_yaw: self.rotation_yaw,
1908 rotation_roll: self.rotation_roll,
1909 focal_length: self.focal_length,
1910 })?;
1911 Ok(id)
1912 }
1913}
1914
1915#[myko_command(StreamId)]
1919pub struct SetStreamName {
1920 pub stream_id: StreamId,
1921 pub name: String,
1922}
1923
1924impl CommandHandler for SetStreamName {
1925 fn execute(self, ctx: CommandContext) -> Result<StreamId, CommandError> {
1926 let id = self.stream_id.clone();
1927 let stream = Stream {
1928 id: id.clone(),
1929 name: self.name,
1930 };
1931 ctx.emit_set(&stream)?;
1932 Ok(id)
1933 }
1934}
1935
1936#[myko_command(RecordingRequestId)]
1941pub struct SetRecording {
1942 pub streamer_id: String,
1943 pub active: bool,
1944 #[serde(default)]
1945 pub capture_kind: crate::RecordingKind,
1946 #[serde(default)]
1947 pub rig: String,
1948 #[serde(default)]
1949 pub preset: String,
1950 #[serde(default)]
1951 pub stream_name: String,
1952 #[serde(default, skip_serializing_if = "Option::is_none")]
1953 pub travel_direction: Option<ShotDirection>,
1954 #[serde(default, skip_serializing_if = "Option::is_none")]
1955 pub shot_index: Option<u32>,
1956 #[serde(default)]
1957 pub take_number: u32,
1958 #[serde(default, alias = "clipId", alias = "cueId")]
1959 pub entry_id: String,
1960 #[serde(default, alias = "shotListId")]
1961 pub timeline_id: String,
1962 #[serde(default, alias = "shotListName")]
1963 pub timeline_name: String,
1964 #[serde(default, alias = "shotListVersion")]
1965 pub timeline_revision: u32,
1966 #[serde(default)]
1967 pub collection_path: Vec<crate::CollectionPathSegment>,
1968 #[serde(default)]
1969 pub requested_at_ms: u64,
1970}
1971impl CommandHandler for SetRecording {
1972 fn execute(self, ctx: CommandContext) -> Result<RecordingRequestId, CommandError> {
1973 let id: RecordingRequestId = self.streamer_id.clone().into();
1974 let req = RecordingRequest {
1975 id: id.clone(),
1976 streamer_id: self.streamer_id,
1977 active: self.active,
1978 capture_kind: self.capture_kind,
1979 rig: self.rig,
1980 preset: self.preset,
1981 stream_name: self.stream_name,
1982 travel_direction: self.travel_direction,
1983 shot_index: self.shot_index,
1984 take_number: self.take_number,
1985 entry_id: self.entry_id,
1986 timeline_id: self.timeline_id,
1987 timeline_name: self.timeline_name,
1988 timeline_revision: self.timeline_revision,
1989 collection_path: self.collection_path,
1990 requested_at_ms: self.requested_at_ms,
1991 };
1992 ctx.emit_set(&req)?;
1993 Ok(id)
1994 }
1995}
1996
1997#[myko_command(RecordingStatusId)]
1999pub struct SetRecordingStatus {
2000 pub streamer_id: String,
2001 pub state: RecordingState,
2002 #[serde(default)]
2003 pub file_name: String,
2004 #[serde(default)]
2007 pub nas_path: String,
2008 #[serde(default)]
2010 pub dropbox_path: String,
2011 #[serde(default)]
2012 pub error: String,
2013 #[serde(default)]
2014 pub started_at_ms: u64,
2015}
2016impl CommandHandler for SetRecordingStatus {
2017 fn execute(self, ctx: CommandContext) -> Result<RecordingStatusId, CommandError> {
2018 let id: RecordingStatusId = self.streamer_id.clone().into();
2019 let st = RecordingStatus {
2020 id: id.clone(),
2021 streamer_id: self.streamer_id,
2022 state: self.state,
2023 file_name: self.file_name,
2024 nas_path: self.nas_path,
2025 dropbox_path: self.dropbox_path,
2026 error: self.error,
2027 started_at_ms: self.started_at_ms,
2028 };
2029 ctx.emit_set(&st)?;
2030 Ok(id)
2031 }
2032}
2033
2034#[myko_command(ViewerId)]
2036pub struct JoinStream {
2037 pub stream_id: StreamId,
2038 pub viewer_id: String,
2039 pub name: String,
2040 pub color: String,
2041 #[serde(default, skip_serializing_if = "Option::is_none")]
2042 pub identity_issuer: Option<String>,
2043 #[serde(default, skip_serializing_if = "Option::is_none")]
2044 pub identity_subject: Option<String>,
2045 #[serde(default, skip_serializing_if = "Option::is_none")]
2046 pub avatar_url: Option<String>,
2047}
2048
2049impl CommandHandler for JoinStream {
2050 fn execute(self, ctx: CommandContext) -> Result<ViewerId, CommandError> {
2051 let client_id = ctx
2052 .client_id()
2053 .map(|id| myko::entities::client::ClientId::from(id.to_owned()));
2054 let conn = client_id
2058 .as_ref()
2059 .map(|c| c.to_string())
2060 .unwrap_or_else(|| self.viewer_id.clone());
2061 let id = Viewer::row_id(&self.stream_id, &conn);
2062 let stream_id = self.stream_id.clone();
2063 let viewer = Viewer {
2064 id: id.clone(),
2065 stream_id: self.stream_id,
2066 viewer_id: self.viewer_id,
2067 name: self.name,
2068 color: self.color,
2069 identity_issuer: self.identity_issuer,
2070 identity_subject: self.identity_subject,
2071 avatar_url: self.avatar_url,
2072 cursor: None,
2073 client_id,
2075 };
2076 ctx.emit_set(&viewer)?;
2077 reconcile_control(&ctx, &stream_id)?;
2080 Ok(id)
2081 }
2082}
2083
2084#[myko_command]
2091pub struct LeaveStream {
2092 pub stream_id: StreamId,
2093 pub viewer_id: String,
2094}
2095
2096impl CommandHandler for LeaveStream {
2097 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
2098 let conn = ctx
2099 .client_id()
2100 .map(|client_id| client_id.to_string())
2101 .unwrap_or(self.viewer_id);
2102 let id = Viewer::row_id(&self.stream_id, &conn);
2103 if let Some(viewer) = ctx.exec_report(GetViewerById { id })? {
2104 ctx.emit_del(&*viewer)?;
2105 }
2106 reconcile_control(&ctx, &self.stream_id)
2107 }
2108}
2109
2110#[myko_command]
2112pub struct UpdateCursor {
2113 pub stream_id: StreamId,
2114 pub viewer_id: String,
2115 pub cursor: Option<(f32, f32)>,
2116}
2117
2118impl CommandHandler for UpdateCursor {
2119 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
2120 let conn = ctx
2122 .client_id()
2123 .map(|c| c.to_string())
2124 .unwrap_or_else(|| self.viewer_id.clone());
2125 let id = Viewer::row_id(&self.stream_id, &conn);
2126 if let Some(viewer) = ctx.exec_report(GetViewerById { id })? {
2127 let updated = Viewer {
2128 cursor: self.cursor,
2129 ..(*viewer).clone()
2130 };
2131 ctx.emit_set(&updated)?;
2132 }
2133 Ok(())
2134 }
2135}
2136
2137#[myko_command(ControlLockId)]
2139pub struct AcquireControl {
2140 pub stream_id: StreamId,
2141 pub viewer_id: String,
2142}
2143
2144impl CommandHandler for AcquireControl {
2145 fn execute(self, ctx: CommandContext) -> Result<ControlLockId, CommandError> {
2146 let id = ControlLock::row_id(&self.stream_id);
2147 let lock = ControlLock {
2148 id: id.clone(),
2149 stream_id: self.stream_id,
2150 viewer_id: self.viewer_id,
2151 client_id: ctx
2152 .client_id()
2153 .map(|id| myko::entities::client::ClientId::from(id.to_owned())),
2154 };
2155 ctx.emit_set(&lock)?;
2156 Ok(id)
2157 }
2158}
2159
2160#[myko_command]
2163pub struct ReleaseControl {
2164 pub stream_id: StreamId,
2165 pub viewer_id: String,
2166}
2167
2168impl CommandHandler for ReleaseControl {
2169 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
2170 let id = ControlLock::row_id(&self.stream_id);
2171 if let Some(lock) = ctx.exec_report(GetControlLockById { id })? {
2172 if lock.viewer_id == self.viewer_id {
2173 ctx.emit_del(&*lock)?;
2174 }
2175 }
2176 Ok(())
2177 }
2178}
2179
2180fn people_present(viewers: &[Arc<Viewer>]) -> Vec<&str> {
2185 let mut people: Vec<&str> = viewers.iter().map(|v| v.viewer_id.as_str()).collect();
2186 people.sort_unstable();
2187 people.dedup();
2188 people
2189}
2190
2191fn viewer_is_live(ctx: &CommandContext, viewer: &Viewer) -> Result<bool, CommandError> {
2202 let Some(client_id) = viewer.client_id.clone() else {
2203 return Ok(false);
2204 };
2205 Ok(ctx.exec_report(ClientStatus { client_id })?.online)
2206}
2207
2208fn reconcile_control(ctx: &CommandContext, stream_id: &StreamId) -> Result<(), CommandError> {
2221 let stored: Vec<Arc<Viewer>> = ctx.exec_query(GetViewersByQuery(ViewerQuery {
2222 stream_id: Some(IdFilter::Eq(stream_id.clone())),
2223 ..Default::default()
2224 }))?;
2225 let mut viewers = Vec::with_capacity(stored.len());
2230 for viewer in stored {
2231 if viewer_is_live(ctx, &viewer)? {
2232 viewers.push(viewer);
2233 }
2234 }
2235 let id = ControlLock::row_id(stream_id);
2236
2237 if let Some(lock) = ctx.exec_report(GetControlLockById { id: id.clone() })? {
2239 let holder_present = viewers.iter().any(|v| v.viewer_id == lock.viewer_id);
2240 if !holder_present {
2241 ctx.emit_del(&*lock)?;
2242 }
2243 }
2244
2245 let people = people_present(&viewers);
2247 if let [sole_vid] = people.as_slice() {
2248 let held_by_sole = ctx
2249 .exec_report(GetControlLockById { id: id.clone() })?
2250 .as_deref()
2251 .is_some_and(|l| l.viewer_id.as_str() == *sole_vid);
2252 if !held_by_sole {
2253 let conn = viewers
2255 .iter()
2256 .find(|v| v.viewer_id.as_str() == *sole_vid)
2257 .expect("present");
2258 ctx.emit_set(&ControlLock {
2259 id,
2260 stream_id: stream_id.clone(),
2261 viewer_id: (*sole_vid).to_string(),
2262 client_id: conn.client_id.clone(),
2263 })?;
2264 }
2265 }
2266 Ok(())
2267}
2268
2269#[myko_command]
2272pub struct AutoAssignControl {
2273 pub stream_id: StreamId,
2274}
2275
2276impl CommandHandler for AutoAssignControl {
2277 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
2278 reconcile_control(&ctx, &self.stream_id)
2279 }
2280}
2281
2282#[myko_command(FrameCaptureRequestId)]
2290pub struct SubmitFrameCapture {
2291 pub streamer_id: String,
2292 pub capture_id: String,
2293 pub command_id: String,
2294 #[ts(type = "unknown")]
2295 pub capture_context: crate::CaptureContext,
2296 pub target: crate::FrameCaptureTarget,
2297 #[serde(default)]
2299 pub force: bool,
2300 #[serde(default)]
2301 pub requested_at_ms: u64,
2302}
2303
2304impl CommandHandler for SubmitFrameCapture {
2305 fn execute(self, ctx: CommandContext) -> Result<FrameCaptureRequestId, CommandError> {
2306 if self.streamer_id.trim().is_empty() {
2307 return Err(command_error(&ctx, "Frame capture requires a stream"));
2308 }
2309 if self.capture_id.trim().is_empty() {
2310 return Err(command_error(&ctx, "Frame capture requires a capture id"));
2311 }
2312 if self.target.cluster_name.trim().is_empty() {
2313 return Err(command_error(
2314 &ctx,
2315 "Frame capture requires an explicit target cluster",
2316 ));
2317 }
2318 let id: FrameCaptureRequestId = self.streamer_id.clone().into();
2321 ctx.emit_set(&crate::FrameCaptureRequest {
2322 id: id.clone(),
2323 streamer_id: self.streamer_id,
2324 capture_id: self.capture_id,
2325 command_id: self.command_id,
2326 capture_context: self.capture_context.into(),
2327 target: self.target,
2328 force: self.force,
2329 requested_at_ms: self.requested_at_ms,
2330 })?;
2331 Ok(id)
2332 }
2333}
2334
2335#[myko_command(FrameCaptureStatusId)]
2337pub struct SetFrameCaptureStatus {
2338 pub streamer_id: String,
2339 #[serde(default)]
2340 pub capture_id: String,
2341 pub phase: crate::FrameCapturePhase,
2342 #[serde(default, skip_serializing_if = "Option::is_none")]
2343 #[ts(type = "unknown")]
2344 pub capture_context: Option<crate::CaptureContext>,
2345 #[serde(default)]
2346 pub target: crate::FrameCaptureTarget,
2347 #[serde(default)]
2348 pub observed_generation: String,
2349 #[serde(default)]
2350 pub receipts: Vec<crate::FrameCaptureReceipt>,
2351 #[serde(default)]
2352 pub error: String,
2353 #[serde(default)]
2354 pub forceable: bool,
2355 #[serde(default)]
2356 pub updated_at_ms: u64,
2357}
2358
2359impl CommandHandler for SetFrameCaptureStatus {
2360 fn execute(self, ctx: CommandContext) -> Result<FrameCaptureStatusId, CommandError> {
2361 let id: FrameCaptureStatusId = self.streamer_id.clone().into();
2362 ctx.emit_set(&crate::FrameCaptureStatus {
2363 id: id.clone(),
2364 streamer_id: self.streamer_id,
2365 capture_id: self.capture_id,
2366 phase: self.phase,
2367 capture_context: self.capture_context.map(StoredCaptureContext::from),
2368 target: self.target,
2369 observed_generation: self.observed_generation,
2370 receipts: self.receipts,
2371 error: self.error,
2372 forceable: self.forceable,
2373 updated_at_ms: self.updated_at_ms,
2374 })?;
2375 Ok(id)
2376 }
2377}
2378
2379#[myko_command(FrameCaptureTargetSummaryId)]
2383pub struct SetFrameCaptureTargetSummary {
2384 pub cluster_name: String,
2385 #[serde(default)]
2386 pub generation: String,
2387 #[serde(default)]
2388 pub capturable: bool,
2389 #[serde(default)]
2390 pub status: String,
2391 #[serde(default)]
2392 pub previs_targets: Vec<crate::PrevisProcessTarget>,
2393 #[serde(default)]
2394 pub updated_at_ms: u64,
2395}
2396
2397impl CommandHandler for SetFrameCaptureTargetSummary {
2398 fn execute(self, ctx: CommandContext) -> Result<FrameCaptureTargetSummaryId, CommandError> {
2399 if self.cluster_name.trim().is_empty() {
2400 return Err(command_error(
2401 &ctx,
2402 "Target summary requires a cluster name",
2403 ));
2404 }
2405 let id: FrameCaptureTargetSummaryId = self.cluster_name.clone().into();
2406 let summary = crate::FrameCaptureTargetSummary {
2407 id: id.clone(),
2408 cluster_name: self.cluster_name,
2409 generation: self.generation,
2410 capturable: self.capturable,
2411 status: self.status,
2412 previs_targets: self.previs_targets,
2413 updated_at_ms: self.updated_at_ms,
2414 };
2415 let unchanged = ctx
2420 .exec_query_first(GetFrameCaptureTargetSummarysByIds {
2421 ids: vec![id.clone()],
2422 })?
2423 .is_some_and(|existing| target_summary_is_heartbeat_only(&summary, &existing));
2424 if unchanged {
2425 return Ok(id);
2426 }
2427 ctx.emit_set(&summary)?;
2428 Ok(id)
2429 }
2430}
2431
2432#[myko_command(PrevisDlssRequestId)]
2433pub struct SetPrevisDlss {
2434 pub streamer_id: String,
2435 pub request_id: String,
2436 pub target: crate::PrevisProcessTarget,
2437 pub settings: crate::DlssSettings,
2438 #[serde(default)]
2439 pub requested_at_ms: u64,
2440}
2441
2442impl CommandHandler for SetPrevisDlss {
2443 fn execute(self, ctx: CommandContext) -> Result<PrevisDlssRequestId, CommandError> {
2444 if self.streamer_id.trim().is_empty() || self.request_id.trim().is_empty() {
2445 return Err(command_error(
2446 &ctx,
2447 "DLSS control requires a stream and request id",
2448 ));
2449 }
2450 let summary = ctx
2451 .exec_query_first(GetFrameCaptureTargetSummarysByIds {
2452 ids: vec![self.target.cluster_name.clone().into()],
2453 })?
2454 .ok_or_else(|| command_error(&ctx, "DLSS target is no longer available"))?;
2455 if !previs_target_is_current(&summary, &self.streamer_id, &self.target) {
2456 return Err(command_error(
2457 &ctx,
2458 "DLSS target process identity is stale; refresh before retrying",
2459 ));
2460 }
2461 let id: PrevisDlssRequestId = self.streamer_id.clone().into();
2462 ctx.emit_set(&crate::PrevisDlssRequest {
2463 id: id.clone(),
2464 streamer_id: self.streamer_id,
2465 request_id: self.request_id,
2466 target: self.target,
2467 settings: self.settings,
2468 requested_at_ms: self.requested_at_ms,
2469 })?;
2470 Ok(id)
2471 }
2472}
2473
2474#[myko_command(PrevisDlssStatusId)]
2475pub struct SetPrevisDlssStatus {
2476 pub streamer_id: String,
2477 #[serde(default)]
2478 pub request_id: String,
2479 #[serde(default, skip_serializing_if = "Option::is_none")]
2480 pub target: Option<crate::PrevisProcessTarget>,
2481 #[serde(default)]
2482 pub supported_qualities: Vec<crate::DlssQuality>,
2483 #[serde(default, skip_serializing_if = "Option::is_none")]
2484 pub quality_unavailable_reason: Option<String>,
2485 pub convergence: crate::DlssConvergence,
2486 #[serde(default)]
2487 pub updated_at_ms: u64,
2488}
2489
2490impl CommandHandler for SetPrevisDlssStatus {
2491 fn execute(self, ctx: CommandContext) -> Result<PrevisDlssStatusId, CommandError> {
2492 if self.streamer_id.trim().is_empty() {
2493 return Err(command_error(&ctx, "DLSS status requires a stream"));
2494 }
2495 let id: PrevisDlssStatusId = self.streamer_id.clone().into();
2496 ctx.emit_set(&crate::PrevisDlssStatus {
2497 id: id.clone(),
2498 streamer_id: self.streamer_id,
2499 request_id: self.request_id,
2500 target: self.target,
2501 supported_qualities: self.supported_qualities,
2502 quality_unavailable_reason: self.quality_unavailable_reason,
2503 convergence: self.convergence,
2504 updated_at_ms: self.updated_at_ms,
2505 })?;
2506 Ok(id)
2507 }
2508}
2509
2510#[cfg(test)]
2511mod discovery_tests {
2512 use super::*;
2513 use crate::shot::DEFAULT_SHOT_LIBRARY_ID;
2514
2515 fn tuned_legacy_shot() -> Shot {
2516 Shot {
2517 id: ShotId::from("render-11:moving:Floor Dolly"),
2518 library_id: String::new(),
2519 streamer_id: "render-11".to_owned(),
2520 name: "Floor Dolly".to_owned(),
2521 kind: ShotKind::Moving,
2522 target_name: "Floor Dolly".to_owned(),
2523 translation_speed_cm_s: 17.0,
2524 rotation_speed_deg_s: 3.5,
2525 hold_duration_ms: 8_000,
2526 travel_duration_ms: 41_000,
2527 default_entry_mode: ShotEntryMode::Both,
2528 shot_index: 9,
2529 }
2530 }
2531
2532 fn legacy_timeline(id: &str, streamer_id: &str, name: &str, shot_id: &str) -> Timeline {
2533 Timeline {
2534 id: TimelineId::from(id),
2535 library_id: String::new(),
2536 streamer_id: streamer_id.to_owned(),
2537 name: name.to_owned(),
2538 revision: 0,
2539 entries: if shot_id.is_empty() {
2540 Vec::new()
2541 } else {
2542 vec![ShotEntry {
2543 shot_id: shot_id.to_owned(),
2544 direction: ShotEntryMode::Forward,
2545 ..Default::default()
2546 }]
2547 },
2548 sort_order: 0,
2549 }
2550 }
2551
2552 #[test]
2553 fn discovery_creates_only_missing_targets_and_preserves_tuned_legacy_rows() {
2554 let existing = vec![tuned_legacy_shot()];
2555 let discovered = vec![
2556 ShotDiscovery {
2557 name: "Floor Dolly".to_owned(),
2558 kind: ShotKind::Moving,
2559 target_name: "Floor Dolly".to_owned(),
2560 },
2561 ShotDiscovery {
2562 name: "Hero Push Forward".to_owned(),
2563 kind: ShotKind::Moving,
2564 target_name: "Hero Push Forward".to_owned(),
2565 },
2566 ShotDiscovery {
2567 name: "Hero Push Forward".to_owned(),
2568 kind: ShotKind::Moving,
2569 target_name: "Hero Push Forward".to_owned(),
2570 },
2571 ];
2572
2573 let created = discovered_shots_to_create(DEFAULT_SHOT_LIBRARY_ID, discovered, &existing);
2574 assert_eq!(created.len(), 1);
2575 assert_eq!(created[0].name, "Hero Push Forward");
2576 assert_eq!(created[0].shot_index, 10);
2577 assert_eq!(
2578 created[0].translation_speed_cm_s,
2579 DEFAULT_SHOT_TRANSLATION_SPEED_CM_S
2580 );
2581 assert_eq!(
2582 created[0].rotation_speed_deg_s,
2583 DEFAULT_SHOT_ROTATION_SPEED_DEG_S
2584 );
2585 assert_eq!(created[0].default_entry_mode, ShotEntryMode::Forward);
2586 assert_eq!(existing[0].translation_speed_cm_s, 17.0);
2587 assert_eq!(existing[0].rotation_speed_deg_s, 3.5);
2588 }
2589
2590 #[test]
2591 fn discovery_reconciles_live_legacy_timeline_duplicates_without_losing_clips() {
2592 let render_shot = tuned_legacy_shot();
2593 let mut studio_shot = tuned_legacy_shot();
2594 studio_shot.id = ShotId::from("Studio A:moving:Floor Dolly");
2595 studio_shot.streamer_id = "Studio A".to_owned();
2596 let timelines = vec![
2597 legacy_timeline(
2598 "render-11:timeline:default",
2599 "render-11",
2600 "Default shot list",
2601 render_shot.id.as_ref(),
2602 ),
2603 legacy_timeline(
2604 "Studio A:timeline:default",
2605 "Studio A",
2606 "Default timeline",
2607 studio_shot.id.as_ref(),
2608 ),
2609 legacy_timeline(
2610 "timeline-old",
2611 "render-11",
2612 "Supercut",
2613 render_shot.id.as_ref(),
2614 ),
2615 Timeline {
2616 id: TimelineId::from("timeline-shared"),
2617 library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
2618 streamer_id: "Studio A".to_owned(),
2619 name: " superCUT ".to_owned(),
2620 revision: 2,
2621 entries: vec![ShotEntry {
2622 shot_id: studio_shot.id.to_string(),
2623 direction: ShotEntryMode::Reverse,
2624 ..Default::default()
2625 }],
2626 sort_order: 1,
2627 },
2628 ];
2629
2630 let result = reconcile_timelines(
2631 DEFAULT_SHOT_LIBRARY_ID,
2632 &[render_shot, studio_shot],
2633 timelines,
2634 );
2635
2636 assert_eq!(result.upserts.len(), 2);
2637 assert_eq!(result.deletes.len(), 3);
2638 let default = result
2639 .upserts
2640 .iter()
2641 .find(|timeline| timeline.id.to_string() == Timeline::shared_legacy_default_id())
2642 .expect("canonical shared default");
2643 assert_eq!(default.library_id, DEFAULT_SHOT_LIBRARY_ID);
2644 assert!(default.streamer_id.is_empty());
2645 assert_eq!(default.name, "Migrated timeline");
2646 assert_eq!(default.entries.len(), 1);
2647 assert_eq!(default.entries[0].shot_id, "Studio A:moving:Floor Dolly");
2648 assert_eq!(result.id_migrations.len(), 3);
2649 assert_eq!(
2650 result.id_migrations.get("render-11:timeline:default"),
2651 Some(&Timeline::shared_legacy_default_id())
2652 );
2653
2654 let supercut = result
2655 .upserts
2656 .iter()
2657 .find(|timeline| timeline.id.as_ref() == "timeline-shared")
2658 .expect("explicit shared timeline wins");
2659 assert_eq!(supercut.name, "superCUT");
2660 assert_eq!(supercut.revision, 2);
2661 assert_eq!(supercut.entries.len(), 2);
2662 assert_eq!(supercut.entries[0].direction, ShotEntryMode::Reverse);
2663 assert_eq!(supercut.entries[1].direction, ShotEntryMode::Forward);
2664 assert!(supercut
2665 .entries
2666 .iter()
2667 .all(|entry| !entry.entry_id.is_empty()));
2668 }
2669
2670 #[test]
2671 fn reconciliation_is_idempotent_for_canonical_timelines() {
2672 let shot = tuned_legacy_shot();
2673 let mut timeline = Timeline {
2674 id: TimelineId::from("shared:timeline:supercut"),
2675 library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
2676 streamer_id: String::new(),
2677 name: "Supercut".to_owned(),
2678 revision: 1,
2679 entries: vec![ShotEntry {
2680 shot_id: shot.id.to_string(),
2681 direction: ShotEntryMode::Forward,
2682 ..Default::default()
2683 }],
2684 sort_order: 0,
2685 };
2686 timeline.normalize_entries();
2687 assert!(timeline.backfill_entry_parameters(std::slice::from_ref(&shot)));
2688 assert!(!timeline.backfill_entry_parameters(std::slice::from_ref(&shot)));
2689 let result = reconcile_timelines(DEFAULT_SHOT_LIBRARY_ID, &[shot], vec![timeline]);
2690 assert!(result.upserts.is_empty());
2691 assert!(result.deletes.is_empty());
2692 }
2693
2694 #[test]
2695 fn reconciliation_snapshots_only_missing_legacy_clip_parameters() {
2696 let shot = tuned_legacy_shot();
2697 let mut timeline =
2698 legacy_timeline("shared:timeline:supercut", "", "Supercut", shot.id.as_ref());
2699 timeline.library_id = DEFAULT_SHOT_LIBRARY_ID.to_owned();
2700 timeline.entries[0].translation_speed_cm_s = Some(4.5);
2701
2702 let result = reconcile_timelines(
2703 DEFAULT_SHOT_LIBRARY_ID,
2704 std::slice::from_ref(&shot),
2705 vec![timeline],
2706 );
2707 assert_eq!(result.upserts.len(), 1);
2708 let entry = &result.upserts[0].entries[0];
2709 assert_eq!(entry.translation_speed_cm_s, Some(4.5));
2710 assert_eq!(entry.rotation_speed_deg_s, Some(shot.rotation_speed_deg_s));
2711 assert_eq!(entry.hold_duration_ms, Some(shot.hold_duration_ms));
2712 assert_eq!(
2713 entry.travel_duration_ms,
2714 Some(shot.effective_travel_duration_ms())
2715 );
2716 assert_eq!(entry.shot_index, Some(shot.shot_index));
2717 }
2718}
2719
2720#[cfg(test)]
2721mod heartbeat_suppression_tests {
2722 use super::*;
2723 use crate::recording_job_status::RecordingJobStatus;
2724
2725 fn status() -> RecordingJobStatus {
2726 RecordingJobStatus {
2727 id: "render-13".into(),
2728 streamer_id: "render-13".to_owned(),
2729 job_id: "job-1".to_owned(),
2730 capture_kind: crate::CaptureKind::Video,
2731 capture_context: None,
2732 timeline_id: "supercut".to_owned(),
2733 timeline_name: "Supercut".to_owned(),
2734 timeline_revision: 1,
2735 collection_id: String::new(),
2736 collection_path: Vec::new(),
2737 phase: RecordingJobPhase::Traveling,
2738 pause_requested: false,
2739 entries: Vec::new(),
2740 index: 0,
2741 completed: 0,
2742 error: String::new(),
2743 updated_at_ms: 1_000,
2744 elapsed_ms: 5_000,
2745 estimated_total_ms: 10_000,
2746 estimated_remaining_ms: 5_000,
2747 takes: Vec::new(),
2748 started_at_ms: 500,
2749 }
2750 }
2751
2752 fn summary() -> crate::FrameCaptureTargetSummary {
2753 crate::FrameCaptureTargetSummary {
2754 id: "0of12_rx11".into(),
2755 cluster_name: "0of12_rx11".to_owned(),
2756 generation: "3350".to_owned(),
2757 capturable: false,
2758 status: "stopped".to_owned(),
2759 previs_targets: Vec::new(),
2760 updated_at_ms: 1_000,
2761 }
2762 }
2763
2764 #[test]
2765 fn a_newer_clock_alone_is_not_a_change() {
2766 let stored = status();
2767 let mut republished = stored.clone();
2768 republished.updated_at_ms = 9_999;
2769 assert!(recording_job_status_is_heartbeat_only(
2770 &republished,
2771 &stored
2772 ));
2773 }
2774
2775 #[test]
2776 fn real_progress_still_writes() {
2777 let stored = status();
2778 for mutate in [
2779 (|s: &mut RecordingJobStatus| s.phase = RecordingJobPhase::Complete)
2780 as fn(&mut RecordingJobStatus),
2781 |s: &mut RecordingJobStatus| s.elapsed_ms = 6_000,
2782 |s: &mut RecordingJobStatus| s.completed = 1,
2783 |s: &mut RecordingJobStatus| s.error = "disk full".to_owned(),
2784 |s: &mut RecordingJobStatus| s.pause_requested = true,
2785 |s: &mut RecordingJobStatus| s.estimated_remaining_ms = 4_000,
2786 ] {
2787 let mut candidate = stored.clone();
2788 candidate.updated_at_ms = 9_999;
2789 mutate(&mut candidate);
2790 assert!(
2791 !recording_job_status_is_heartbeat_only(&candidate, &stored),
2792 "a changed field must still be written"
2793 );
2794 }
2795 }
2796
2797 #[test]
2798 fn a_newer_job_takes_over_the_stream_cursor() {
2799 let mut current = status();
2800 current.job_id = "job-old".to_owned();
2801 current.phase = RecordingJobPhase::Complete;
2802 current.started_at_ms = 1_000;
2803
2804 let mut next = status();
2805 next.job_id = "job-new".to_owned();
2806 next.started_at_ms = 2_000;
2807
2808 assert!(!recording_job_status_is_historical(&next, ¤t));
2809 assert!(recording_job_status_is_historical(¤t, &next));
2810 }
2811
2812 #[test]
2813 fn target_summaries_follow_the_same_rule() {
2814 let stored = summary();
2815 let mut polled = stored.clone();
2816 polled.updated_at_ms = 9_999;
2817 assert!(target_summary_is_heartbeat_only(&polled, &stored));
2818
2819 for mutate in [
2820 (|s: &mut crate::FrameCaptureTargetSummary| s.capturable = true)
2821 as fn(&mut crate::FrameCaptureTargetSummary),
2822 |s: &mut crate::FrameCaptureTargetSummary| s.generation = "3351".to_owned(),
2823 |s: &mut crate::FrameCaptureTargetSummary| s.status = "running".to_owned(),
2824 ] {
2825 let mut candidate = stored.clone();
2826 candidate.updated_at_ms = 9_999;
2827 mutate(&mut candidate);
2828 assert!(!target_summary_is_heartbeat_only(&candidate, &stored));
2829 }
2830 }
2831
2832 #[test]
2833 fn dlss_target_requires_the_exact_stream_process_identity() {
2834 let target = crate::PrevisProcessTarget {
2835 cluster_name: "12of12".to_owned(),
2836 deployment_generation: 42,
2837 host: "render-13".to_owned(),
2838 process_id: 4100,
2839 process_started_at: Some("2026-09-05T02:59:06+00:00".to_owned()),
2840 process_generation: 42,
2841 };
2842 let mut summary = summary();
2843 summary.cluster_name = target.cluster_name.clone();
2844 summary.previs_targets = vec![target.clone()];
2845 assert!(previs_target_is_current(&summary, "render-13", &target));
2846
2847 let mut replacement = target.clone();
2848 replacement.process_id += 1;
2849 assert!(!previs_target_is_current(
2850 &summary,
2851 "render-13",
2852 &replacement
2853 ));
2854 assert!(!previs_target_is_current(&summary, "render-12", &target));
2855 }
2856}
2857
2858#[cfg(test)]
2859mod presence_tests {
2860 use std::sync::Arc;
2861
2862 use super::people_present;
2863 use crate::viewer::Viewer;
2864
2865 fn viewer(viewer_id: &str, client: &str) -> Arc<Viewer> {
2866 Arc::new(Viewer {
2867 id: format!("s1:{client}").into(),
2868 stream_id: "s1".into(),
2869 viewer_id: viewer_id.to_owned(),
2870 name: "Anonymous Cheetah".to_owned(),
2871 color: "#F87171".to_owned(),
2872 identity_issuer: None,
2873 identity_subject: None,
2874 avatar_url: None,
2875 cursor: None,
2876 client_id: Some(client.to_owned().into()),
2877 })
2878 }
2879
2880 #[test]
2881 fn tabs_of_one_person_are_one_person() {
2882 let viewers = vec![viewer("max", "conn-a"), viewer("max", "conn-b")];
2884 assert_eq!(people_present(&viewers), vec!["max"]);
2885 }
2886
2887 #[test]
2888 fn distinct_people_are_counted_separately_and_sorted() {
2889 let viewers = vec![
2890 viewer("zoe", "conn-c"),
2891 viewer("max", "conn-a"),
2892 viewer("max", "conn-b"),
2893 ];
2894 assert_eq!(people_present(&viewers), vec!["max", "zoe"]);
2895 }
2896
2897 #[test]
2898 fn a_stream_whose_connections_all_died_has_nobody_present() {
2899 assert!(people_present(&[]).is_empty());
2904 }
2905}