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