Skip to main content

pulse_pixelstream_types/
commands.rs

1use myko::prelude::*;
2use std::{
3    collections::{HashMap, HashSet},
4    sync::Arc,
5};
6
7use myko::command::{CommandContext, CommandError, CommandHandler};
8use myko::entities::client::ClientStatus;
9use myko_macros::myko_command;
10
11use crate::cam_pref::{CamPref, CamPrefId};
12use crate::camera_home::{CameraHome, CameraHomeId};
13use crate::collection::{
14    resolve_collection_path, Collection, CollectionId, CollectionQuery, GetCollectionsByIds,
15    GetCollectionsByQuery,
16};
17use crate::collection_membership::{
18    CollectionMembership, CollectionMembershipId, CollectionMembershipQuery,
19    GetCollectionMembershipsByIds, GetCollectionMembershipsByQuery,
20};
21use crate::control_lock::{ControlLock, ControlLockId, GetControlLockById};
22use crate::frame_capture::FrameCaptureRequestId;
23use crate::frame_capture_status::FrameCaptureStatusId;
24use crate::frame_capture_target::{
25    FrameCaptureTargetSummaryId, GetFrameCaptureTargetSummarysByIds,
26};
27use crate::legacy_capture_run::{
28    CaptureRunQuery as LegacyCaptureRunQuery, GetCaptureRunsByQuery as GetLegacyCaptureRunsByQuery,
29};
30use crate::legacy_shot_list::{
31    GetShotListsByQuery as GetLegacyShotListsByQuery, ShotListQuery as LegacyShotListQuery,
32};
33use crate::recording_job::{
34    CreativeStatus, DeliveryStatus, GetRecordingJobsByIds, GetRecordingJobsByQuery, RecordingJob,
35    RecordingJobId, RecordingJobQuery, Take, TakeState,
36};
37use crate::recording_job_request::{RecordingJobRequest, RecordingJobRequestId};
38use crate::recording_job_status::{
39    GetRecordingJobStatussByIds, RecordingJobStatus, RecordingJobStatusId,
40};
41use crate::recording_plan::{RecordingJobAction, RecordingJobPhase, ShotEntryPlan};
42use crate::recording_request::{RecordingRequest, RecordingRequestId};
43use crate::recording_status::{RecordingState, RecordingStatus, RecordingStatusId};
44use crate::shot::{
45    effective_library_id, GetShotsByQuery, Shot, ShotDiscovery, ShotId, ShotKind, ShotQuery,
46    DEFAULT_SHOT_HOLD_DURATION_MS, DEFAULT_SHOT_LIBRARY_ID, DEFAULT_SHOT_ROTATION_SPEED_DEG_S,
47    DEFAULT_SHOT_TRANSLATION_SPEED_CM_S,
48};
49use crate::stream::{Stream, StreamId};
50use crate::timeline::{
51    GetTimelinesByIds, GetTimelinesByQuery, ShotDirection, ShotEntry, ShotEntryMode, Timeline,
52    TimelineId, TimelineQuery,
53};
54use crate::viewer::{GetViewerById, GetViewersByQuery, Viewer, ViewerId, ViewerQuery};
55use crate::StoredCaptureContext;
56
57const MAX_OTIO_IMPORT_BYTES: usize = 10 * 1024 * 1024;
58const MAX_DISCOVERED_SHOTS: usize = 10_000;
59const MAX_COLLECTION_DEPTH: usize = 16;
60const MAX_COLLECTION_NAME_CHARS: usize = 128;
61
62fn require_uuid_v7_collection_ids() -> bool {
63    std::env::var("PULSE_PIXELSTREAM_REQUIRE_UUID_V7_COLLECTION_IDS").is_ok_and(|value| {
64        matches!(
65            value.trim().to_ascii_lowercase().as_str(),
66            "1" | "true" | "yes"
67        )
68    })
69}
70
71fn normalized_timeline_name(name: &str) -> String {
72    name.trim().to_lowercase()
73}
74
75fn normalized_collection_name(name: &str) -> String {
76    name.trim().to_lowercase()
77}
78
79/// Project historic `ShotList` rows into OTIO-native Timeline entities.
80///
81/// Legacy rows remain readable during the compatibility window, but all new
82/// commands and queries operate exclusively on Timeline. The migration keeps
83/// collection edges and capture history intact while active code uses canonical
84/// Timeline ids.
85fn 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            // Several old per-stream defaults project to the one shared default.
109            // Merge missing source+direction pairs while preserving an already
110            // canonical authored sequence and its independently-tuned entries.
111            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    // Canonicalize every organizational edge id, including rows whose Timeline
144    // id did not change. Old stable ids embedded the retired entity name.
145    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/// Idempotent persisted-data migration invoked by OTIO-native clients when
193/// they connect. This is deliberately explicit and observable rather than a
194/// hidden database rewrite during server startup.
195#[myko_command(TimelineId)]
196pub struct MigrateOtioTimelines {}
197
198impl CommandHandler for MigrateOtioTimelines {
199    fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
200        let shots = ctx
201            .exec_query(GetShotsByQuery(ShotQuery::default()))?
202            .into_iter()
203            .map(|shot| shot.as_ref().clone())
204            .collect::<Vec<_>>();
205        let timelines = migrate_legacy_timelines(&ctx, &shots)?;
206        apply_timeline_reconciliation(
207            &ctx,
208            reconcile_timelines(DEFAULT_SHOT_LIBRARY_ID, &shots, timelines),
209        )?;
210        migrate_legacy_recording_jobs(&ctx)?;
211        Ok(TimelineId::from("otio-timeline-migration"))
212    }
213}
214fn timeline_candidate_is_better(candidate: &Timeline, current: &Timeline) -> bool {
215    let candidate_rank = (
216        u8::from(!candidate.library_id.trim().is_empty()),
217        u8::from(candidate.streamer_id.trim().is_empty()),
218    );
219    let current_rank = (
220        u8::from(!current.library_id.trim().is_empty()),
221        u8::from(current.streamer_id.trim().is_empty()),
222    );
223    candidate_rank > current_rank
224        || (candidate_rank == current_rank && candidate.id.to_string() < current.id.to_string())
225}
226
227fn preferred_shot_ids(library_id: &str, shots: &[Shot]) -> HashMap<String, String> {
228    let mut preferred = HashMap::<(u8, String), &Shot>::new();
229    for candidate in shots
230        .iter()
231        .filter(|shot| shot.effective_library_id() == library_id)
232    {
233        let key = (
234            shot_kind_key(&candidate.kind),
235            candidate.target_name.clone(),
236        );
237        let replace = preferred.get(&key).is_none_or(|current| {
238            let candidate_rank = u8::from(!candidate.library_id.trim().is_empty());
239            let current_rank = u8::from(!current.library_id.trim().is_empty());
240            candidate_rank > current_rank
241                || (candidate_rank == current_rank
242                    && candidate.id.to_string() < current.id.to_string())
243        });
244        if replace {
245            preferred.insert(key, candidate);
246        }
247    }
248
249    shots
250        .iter()
251        .filter(|shot| shot.effective_library_id() == library_id)
252        .filter_map(|shot| {
253            preferred
254                .get(&(shot_kind_key(&shot.kind), shot.target_name.clone()))
255                .map(|winner| (shot.id.to_string(), winner.id.to_string()))
256        })
257        .collect()
258}
259
260#[derive(Default)]
261struct TimelineReconciliation {
262    upserts: Vec<Timeline>,
263    deletes: Vec<Timeline>,
264    id_migrations: HashMap<String, String>,
265}
266
267fn apply_timeline_reconciliation(
268    ctx: &CommandContext,
269    reconciliation: TimelineReconciliation,
270) -> Result<(), CommandError> {
271    for timeline in &reconciliation.upserts {
272        ctx.emit_set(timeline)?;
273    }
274
275    if !reconciliation.id_migrations.is_empty() {
276        let memberships = ctx
277            .exec_query(GetCollectionMembershipsByQuery(
278                CollectionMembershipQuery::default(),
279            ))?
280            .into_iter()
281            .map(|membership| membership.as_ref().clone())
282            .collect::<Vec<_>>();
283        for membership in &memberships {
284            let Some(timeline_id) = reconciliation.id_migrations.get(&membership.timeline_id)
285            else {
286                continue;
287            };
288            let replacement_id = CollectionMembershipId::from(CollectionMembership::stable_id(
289                &membership.collection_id,
290                timeline_id,
291            ));
292            let sort_order = memberships
293                .iter()
294                .filter(|candidate| candidate.id == replacement_id)
295                .map(|candidate| candidate.sort_order)
296                .chain(std::iter::once(membership.sort_order))
297                .min()
298                .unwrap_or(membership.sort_order);
299            let replaces_membership = replacement_id != membership.id;
300            ctx.emit_set(&CollectionMembership {
301                id: replacement_id,
302                collection_id: membership.collection_id.clone(),
303                timeline_id: timeline_id.clone(),
304                sort_order,
305            })?;
306            if replaces_membership {
307                ctx.emit_del(membership)?;
308            }
309        }
310    }
311
312    for timeline in &reconciliation.deletes {
313        ctx.emit_del(timeline)?;
314    }
315    Ok(())
316}
317
318/// Collapse legacy per-stream timelines into one persistent shared definition per
319/// case-insensitive name. Membership is merged rather than discarded, and entry
320/// references follow the same preferred Shot projection used by the UI.
321fn reconcile_timelines(
322    library_id: &str,
323    shots: &[Shot],
324    timelines: Vec<Timeline>,
325) -> TimelineReconciliation {
326    let library_id = effective_library_id(library_id);
327    let preferred_shots = preferred_shot_ids(library_id, shots);
328    let mut groups = HashMap::<String, Vec<Timeline>>::new();
329    for timeline in timelines
330        .into_iter()
331        .filter(|timeline| timeline.effective_library_id() == library_id)
332    {
333        let name = if timeline.has_legacy_default_identity() && timeline.has_legacy_default_name() {
334            "\0legacy-default-timeline".to_owned()
335        } else {
336            normalized_timeline_name(&timeline.name)
337        };
338        if !name.is_empty() {
339            groups.entry(name).or_default().push(timeline);
340        }
341    }
342
343    let mut reconciliation = TimelineReconciliation::default();
344    for (_, mut group) in groups {
345        group.sort_by_key(|timeline| timeline.id.to_string());
346        let preferred = group
347            .iter()
348            .reduce(|current, candidate| {
349                if timeline_candidate_is_better(candidate, current) {
350                    candidate
351                } else {
352                    current
353                }
354            })
355            .expect("timeline groups are non-empty");
356        let is_legacy_default = group.iter().any(|timeline| {
357            timeline.has_legacy_default_identity() && timeline.has_legacy_default_name()
358        });
359        let canonical_id = if is_legacy_default {
360            TimelineId::from(Timeline::legacy_default_id(library_id))
361        } else {
362            preferred.id.clone()
363        };
364
365        // The preferred timeline is the authoritative OTIO-like sequence and may
366        // intentionally contain the same Shot more than once. Secondary legacy
367        // rows contribute only missing shot+direction pairs, preventing old
368        // per-stream copies from multiplying an already-authored sequence.
369        let mut entries = preferred
370            .entries
371            .iter()
372            .cloned()
373            .map(|mut entry| {
374                entry.shot_id = preferred_shots
375                    .get(&entry.shot_id)
376                    .cloned()
377                    .unwrap_or(entry.shot_id);
378                entry
379            })
380            .collect::<Vec<_>>();
381        for timeline in group.iter().filter(|timeline| timeline.id != preferred.id) {
382            for source in &timeline.entries {
383                let shot_id = preferred_shots
384                    .get(&source.shot_id)
385                    .cloned()
386                    .unwrap_or_else(|| source.shot_id.clone());
387                for direction in source.directions() {
388                    let already_present = entries.iter().any(|entry| {
389                        entry.shot_id == shot_id && entry.directions().contains(direction)
390                    });
391                    if !already_present {
392                        let mut entry = source.clone();
393                        entry.entry_id.clear();
394                        entry.shot_id = shot_id.clone();
395                        entry.direction = ShotEntryMode::from_direction(*direction);
396                        entries.push(entry);
397                    }
398                }
399            }
400        }
401        let revision = group
402            .iter()
403            .map(|timeline| timeline.revision)
404            .max()
405            .unwrap_or(0);
406        let mut canonical = Timeline {
407            id: canonical_id.clone(),
408            library_id: library_id.to_owned(),
409            streamer_id: String::new(),
410            name: if is_legacy_default && preferred.has_legacy_default_name() {
411                "Migrated timeline".to_owned()
412            } else {
413                preferred.name.trim().to_owned()
414            },
415            revision,
416            entries,
417            sort_order: group
418                .iter()
419                .map(|timeline| timeline.sort_order)
420                .min()
421                .unwrap_or(preferred.sort_order),
422        };
423        canonical.normalize_entries();
424        canonical.backfill_entry_parameters(shots);
425
426        for timeline in &group {
427            if timeline.id != canonical_id {
428                reconciliation
429                    .id_migrations
430                    .insert(timeline.id.to_string(), canonical_id.to_string());
431            }
432        }
433
434        if group.iter().find(|timeline| timeline.id == canonical_id) != Some(&canonical) {
435            reconciliation.upserts.push(canonical);
436        }
437        reconciliation.deletes.extend(
438            group
439                .into_iter()
440                .filter(|timeline| timeline.id != canonical_id),
441        );
442    }
443    reconciliation
444}
445
446fn ensure_unique_timeline_name(
447    ctx: &CommandContext,
448    library_id: &str,
449    id: &TimelineId,
450    name: &str,
451) -> Result<(), CommandError> {
452    let normalized_name = normalized_timeline_name(name);
453    if normalized_name.is_empty() {
454        return Err(command_error(ctx, "Timeline name cannot be empty"));
455    }
456    let duplicate = ctx
457        .exec_query(GetTimelinesByQuery(TimelineQuery::default()))?
458        .into_iter()
459        .any(|timeline| {
460            timeline.id != *id
461                && timeline.effective_library_id() == effective_library_id(library_id)
462                && normalized_timeline_name(&timeline.name) == normalized_name
463        });
464    if duplicate {
465        return Err(command_error(
466            ctx,
467            format!("A timeline named ‘{}’ already exists", name.trim()),
468        ));
469    }
470    Ok(())
471}
472
473fn shot_kind_key(kind: &ShotKind) -> u8 {
474    match kind {
475        ShotKind::Moving => 0,
476        ShotKind::Static => 1,
477    }
478}
479
480fn discovered_shots_to_create(
481    library_id: &str,
482    discoveries: Vec<ShotDiscovery>,
483    existing: &[Shot],
484) -> Vec<Shot> {
485    let library_id = effective_library_id(library_id);
486    let mut known = existing
487        .iter()
488        .filter(|shot| shot.effective_library_id() == library_id)
489        .map(|shot| (shot_kind_key(&shot.kind), shot.target_name.clone()))
490        .collect::<HashSet<_>>();
491    let mut next_index = existing
492        .iter()
493        .filter(|shot| shot.effective_library_id() == library_id)
494        .map(|shot| shot.shot_index)
495        .max()
496        .map_or(0, |index| index.saturating_add(1));
497
498    discoveries
499        .into_iter()
500        .filter_map(|discovery| {
501            let name = discovery.name.trim();
502            let target_name = discovery.target_name.trim();
503            if name.is_empty() || target_name.is_empty() {
504                return None;
505            }
506            let key = (shot_kind_key(&discovery.kind), target_name.to_owned());
507            if !known.insert(key) {
508                return None;
509            }
510            let shot = Shot {
511                id: ShotId::from(Shot::stable_id(library_id, &discovery.kind, target_name)),
512                library_id: library_id.to_owned(),
513                streamer_id: String::new(),
514                name: name.to_owned(),
515                kind: discovery.kind,
516                target_name: target_name.to_owned(),
517                translation_speed_cm_s: DEFAULT_SHOT_TRANSLATION_SPEED_CM_S,
518                rotation_speed_deg_s: DEFAULT_SHOT_ROTATION_SPEED_DEG_S,
519                hold_duration_ms: DEFAULT_SHOT_HOLD_DURATION_MS,
520                travel_duration_ms: crate::DEFAULT_SHOT_TRAVEL_DURATION_MS,
521                default_entry_mode: ShotEntryMode::Forward,
522                shot_index: next_index,
523            };
524            next_index = next_index.saturating_add(1);
525            Some(shot)
526        })
527        .collect()
528}
529
530/// Reconcile live camera targets into a persistent shot library. This command
531/// is create-only by `(library, kind, target_name)`: discovery can add a new
532/// rail or preset, but can never overwrite an operator's tuned Shot values.
533#[myko_command]
534pub struct DiscoverShots {
535    #[serde(default)]
536    pub library_id: String,
537    pub shots: Vec<ShotDiscovery>,
538}
539
540impl CommandHandler for DiscoverShots {
541    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
542        if self.shots.len() > MAX_DISCOVERED_SHOTS {
543            return Err(command_error(
544                &ctx,
545                format!("shot discovery exceeds the {MAX_DISCOVERED_SHOTS} target limit"),
546            ));
547        }
548        let mut existing = ctx
549            .exec_query(GetShotsByQuery(ShotQuery::default()))?
550            .into_iter()
551            .map(|shot| shot.as_ref().clone())
552            .collect::<Vec<_>>();
553        let created = discovered_shots_to_create(&self.library_id, self.shots, &existing);
554        for shot in &created {
555            ctx.emit_set(shot)?;
556        }
557        existing.extend(created);
558
559        let timelines = ctx
560            .exec_query(GetTimelinesByQuery(TimelineQuery::default()))?
561            .into_iter()
562            .map(|timeline| timeline.as_ref().clone())
563            .collect();
564        let reconciliation = reconcile_timelines(&self.library_id, &existing, timelines);
565        apply_timeline_reconciliation(&ctx, reconciliation)
566    }
567}
568
569/// Upsert one persistent shot definition. The stable shot ID is supplied by the
570/// client so reconnects cannot create duplicate definitions for the same target.
571#[myko_command(ShotId)]
572pub struct SetShot {
573    pub shot_id: ShotId,
574    #[serde(default)]
575    pub library_id: String,
576    #[serde(default)]
577    pub streamer_id: String,
578    pub name: String,
579    #[serde(alias = "target_kind")]
580    pub kind: ShotKind,
581    pub target_name: String,
582    pub translation_speed_cm_s: f32,
583    pub rotation_speed_deg_s: f32,
584    pub hold_duration_ms: u64,
585    #[serde(default = "crate::default_shot_travel_duration_ms")]
586    pub travel_duration_ms: u64,
587    #[serde(
588        default,
589        alias = "defaultClipMode",
590        alias = "defaultListMode",
591        alias = "enabled",
592        alias = "batchMode"
593    )]
594    pub default_entry_mode: ShotEntryMode,
595    /// Persistent shot label. Keep the established wire name so currently
596    /// deployed clients remain compatible while Rust uses the correct taxonomy.
597    #[serde(rename = "sortOrder", alias = "shotIndex")]
598    pub shot_index: u32,
599}
600
601impl CommandHandler for SetShot {
602    fn execute(self, ctx: CommandContext) -> Result<ShotId, CommandError> {
603        let id = self.shot_id;
604        ctx.emit_set(&Shot {
605            id: id.clone(),
606            library_id: effective_library_id(&self.library_id).to_owned(),
607            streamer_id: self.streamer_id,
608            name: self.name,
609            kind: self.kind,
610            target_name: self.target_name,
611            translation_speed_cm_s: self.translation_speed_cm_s,
612            rotation_speed_deg_s: self.rotation_speed_deg_s,
613            hold_duration_ms: self.hold_duration_ms,
614            travel_duration_ms: self.travel_duration_ms,
615            default_entry_mode: self.default_entry_mode,
616            shot_index: self.shot_index,
617        })?;
618        Ok(id)
619    }
620}
621
622/// Create or update one named reusable timeline. Every entry persists a complete
623/// parameter snapshot; Shot values are defaults used only when adding or
624/// migrating an entry.
625#[myko_command(TimelineId)]
626pub struct SetTimeline {
627    #[serde(alias = "shotListId")]
628    pub timeline_id: TimelineId,
629    #[serde(default)]
630    pub library_id: String,
631    #[serde(default)]
632    pub streamer_id: String,
633    pub name: String,
634    #[serde(alias = "clips", alias = "cues")]
635    pub entries: Vec<ShotEntry>,
636    pub sort_order: u32,
637}
638
639impl CommandHandler for SetTimeline {
640    fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
641        let id = self.timeline_id;
642        ensure_unique_timeline_name(&ctx, &self.library_id, &id, &self.name)?;
643        let current = ctx.exec_query_first(GetTimelinesByIds {
644            ids: vec![id.clone()],
645        })?;
646        let revision = current.map(|current| current.next_revision()).unwrap_or(1);
647        let mut timeline = Timeline {
648            id: id.clone(),
649            library_id: effective_library_id(&self.library_id).to_owned(),
650            streamer_id: self.streamer_id,
651            name: self.name.trim().to_owned(),
652            revision,
653            entries: self.entries,
654            sort_order: self.sort_order,
655        };
656        timeline.normalize_entries();
657        let shots = ctx
658            .exec_query(GetShotsByQuery(ShotQuery::default()))?
659            .into_iter()
660            .map(|shot| shot.as_ref().clone())
661            .collect::<Vec<_>>();
662        timeline.backfill_entry_parameters(&shots);
663        ctx.emit_set(&timeline)?;
664        Ok(id)
665    }
666}
667
668/// Delete one timeline and all of its organizational placements without
669/// deleting any reusable shot definitions.
670#[myko_command(TimelineId)]
671pub struct RemoveTimeline {
672    #[serde(alias = "shotListId")]
673    pub timeline_id: TimelineId,
674}
675
676impl CommandHandler for RemoveTimeline {
677    fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
678        let id = self.timeline_id;
679        let current = ctx
680            .exec_query_first(GetTimelinesByIds {
681                ids: vec![id.clone()],
682            })?
683            .ok_or_else(|| command_error(&ctx, format!("Timeline {id} does not exist")))?;
684        for membership in ctx
685            .exec_query(GetCollectionMembershipsByQuery(
686                CollectionMembershipQuery::default(),
687            ))?
688            .into_iter()
689            .filter(|membership| membership.timeline_id == id.to_string())
690        {
691            ctx.emit_del(membership.as_ref())?;
692        }
693        ctx.emit_del(current.as_ref())?;
694        Ok(id)
695    }
696}
697
698/// Create or update a generic organizational bin. Collection names and depth
699/// are user-defined; the server enforces only tree integrity and sibling-name
700/// uniqueness.
701#[myko_command(CollectionId)]
702pub struct SetCollection {
703    pub collection_id: CollectionId,
704    #[serde(default)]
705    pub library_id: String,
706    #[serde(default)]
707    pub parent_id: String,
708    pub name: String,
709    #[serde(default)]
710    pub sort_order: u32,
711    #[serde(default)]
712    pub metadata: HashMap<String, String>,
713}
714
715impl CommandHandler for SetCollection {
716    fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
717        let id = self.collection_id;
718        let library_id = effective_library_id(&self.library_id).to_owned();
719        let name = self.name.trim().to_owned();
720        if name.is_empty() {
721            return Err(command_error(&ctx, "Collection name cannot be empty"));
722        }
723        if name.chars().count() > MAX_COLLECTION_NAME_CHARS {
724            return Err(command_error(
725                &ctx,
726                format!("Collection names are limited to {MAX_COLLECTION_NAME_CHARS} characters"),
727            ));
728        }
729        let mut collections = ctx
730            .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
731            .into_iter()
732            .map(|collection| collection.as_ref().clone())
733            .collect::<Vec<_>>();
734        if !collections.iter().any(|collection| collection.id == id)
735            && !crate::collection::is_uuid_v7(id.as_ref())
736        {
737            if require_uuid_v7_collection_ids() {
738                return Err(command_error(
739                    &ctx,
740                    "New collection IDs must be UUIDv7; existing legacy collections remain editable",
741                ));
742            }
743            eprintln!(
744                "accepted legacy native collection id during UUIDv7 rollout: {}",
745                id.as_ref()
746            );
747        }
748        if collections.iter().any(|collection| {
749            collection.id != id
750                && collection.library_id == library_id
751                && collection.parent_id == self.parent_id
752                && normalized_collection_name(&collection.name) == normalized_collection_name(&name)
753        }) {
754            return Err(command_error(
755                &ctx,
756                format!("A collection named ‘{name}’ already exists here"),
757            ));
758        }
759        if !self.parent_id.trim().is_empty() {
760            let parent = collections
761                .iter()
762                .find(|collection| collection.id.to_string() == self.parent_id)
763                .ok_or_else(|| command_error(&ctx, "Parent collection does not exist"))?;
764            if parent.library_id != library_id {
765                return Err(command_error(
766                    &ctx,
767                    "A collection cannot be moved between libraries",
768                ));
769            }
770            if parent.id == id {
771                return Err(command_error(&ctx, "A collection cannot contain itself"));
772            }
773            let parent_path = resolve_collection_path(&self.parent_id, &collections)
774                .map_err(|error| command_error(&ctx, error))?;
775            if parent_path.len() >= MAX_COLLECTION_DEPTH {
776                return Err(command_error(
777                    &ctx,
778                    format!("Collections are limited to {MAX_COLLECTION_DEPTH} levels"),
779                ));
780            }
781            if parent_path
782                .iter()
783                .any(|segment| segment.collection_id == id.to_string())
784            {
785                return Err(command_error(
786                    &ctx,
787                    "A collection cannot be moved inside one of its descendants",
788                ));
789            }
790        }
791        let collection = Collection {
792            id: id.clone(),
793            library_id,
794            parent_id: self.parent_id,
795            name,
796            sort_order: self.sort_order,
797            metadata: self.metadata,
798        };
799        if let Some(current) = collections.iter_mut().find(|row| row.id == id) {
800            *current = collection.clone();
801        } else {
802            collections.push(collection.clone());
803        }
804        // Validate the resulting row too, including pre-existing corrupt paths.
805        resolve_collection_path(id.as_ref(), &collections)
806            .map_err(|error| command_error(&ctx, error))?;
807        ctx.emit_set(&collection)?;
808        Ok(id)
809    }
810}
811
812/// Remove an empty collection. Memberships directly inside it are removed,
813/// while child collections must be deliberately handled first so a broad tree
814/// cannot disappear from one accidental click.
815#[myko_command(CollectionId)]
816pub struct RemoveCollection {
817    pub collection_id: CollectionId,
818}
819
820impl CommandHandler for RemoveCollection {
821    fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
822        let id = self.collection_id;
823        let current = ctx
824            .exec_query_first(GetCollectionsByIds {
825                ids: vec![id.clone()],
826            })?
827            .ok_or_else(|| command_error(&ctx, format!("Collection {id} does not exist")))?;
828        let has_children = ctx
829            .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
830            .into_iter()
831            .any(|collection| collection.parent_id == id.to_string());
832        if has_children {
833            return Err(command_error(
834                &ctx,
835                "Move or remove child collections before deleting this collection",
836            ));
837        }
838        for membership in ctx
839            .exec_query(GetCollectionMembershipsByQuery(
840                CollectionMembershipQuery::default(),
841            ))?
842            .into_iter()
843            .filter(|membership| membership.collection_id == id.to_string())
844        {
845            ctx.emit_del(membership.as_ref())?;
846        }
847        ctx.emit_del(current.as_ref())?;
848        Ok(id)
849    }
850}
851
852/// Add or remove one many-to-many timeline placement.
853#[myko_command(CollectionMembershipId)]
854pub struct SetCollectionMembership {
855    pub collection_id: String,
856    #[serde(alias = "shotListId")]
857    pub timeline_id: String,
858    pub included: bool,
859    #[serde(default)]
860    pub sort_order: u32,
861}
862
863impl CommandHandler for SetCollectionMembership {
864    fn execute(self, ctx: CommandContext) -> Result<CollectionMembershipId, CommandError> {
865        let id = CollectionMembershipId::from(CollectionMembership::stable_id(
866            &self.collection_id,
867            &self.timeline_id,
868        ));
869        let existing = ctx.exec_query_first(GetCollectionMembershipsByIds {
870            ids: vec![id.clone()],
871        })?;
872        if !self.included {
873            if let Some(existing) = existing {
874                ctx.emit_del(existing.as_ref())?;
875            }
876            return Ok(id);
877        }
878        let collection = ctx
879            .exec_query_first(GetCollectionsByIds {
880                ids: vec![CollectionId::from(self.collection_id.clone())],
881            })?
882            .ok_or_else(|| command_error(&ctx, "Collection does not exist"))?;
883        let timeline = ctx
884            .exec_query_first(GetTimelinesByIds {
885                ids: vec![TimelineId::from(self.timeline_id.clone())],
886            })?
887            .ok_or_else(|| command_error(&ctx, "Timeline does not exist"))?;
888        if collection.library_id != timeline.effective_library_id() {
889            return Err(command_error(
890                &ctx,
891                "Collection and timeline belong to different libraries",
892            ));
893        }
894        ctx.emit_set(&CollectionMembership {
895            id: id.clone(),
896            collection_id: self.collection_id,
897            timeline_id: self.timeline_id,
898            sort_order: self.sort_order,
899        })?;
900        Ok(id)
901    }
902}
903
904/// Atomically import a Pulse-profile OTIO document into the shared definition
905/// library. Camera execution remains untouched; this command only persists Shot
906/// and Timeline definitions.
907#[myko_command(TimelineId)]
908pub struct ImportTimelineOtio {
909    pub otio_json: String,
910    pub sort_order: u32,
911}
912
913impl CommandHandler for ImportTimelineOtio {
914    fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
915        if self.otio_json.len() > MAX_OTIO_IMPORT_BYTES {
916            return Err(command_error(
917                &ctx,
918                format!(
919                    "OTIO import exceeds the {} byte limit",
920                    MAX_OTIO_IMPORT_BYTES
921                ),
922            ));
923        }
924        let imported = crate::import_timeline_otio_json(&self.otio_json)
925            .map_err(|error| command_error(&ctx, error.to_string()))?;
926        if imported.shots.len() > 10_000 {
927            return Err(command_error(&ctx, "OTIO import contains too many shots"));
928        }
929        ensure_unique_timeline_name(
930            &ctx,
931            imported.timeline.effective_library_id(),
932            &imported.timeline.id,
933            &imported.timeline.name,
934        )?;
935
936        let mut timeline = imported.timeline;
937        timeline.backfill_entry_parameters(&imported.shots);
938        for shot in imported.shots {
939            ctx.emit_set(&shot)?;
940        }
941
942        if let Some(current) = ctx.exec_query_first(GetTimelinesByIds {
943            ids: vec![timeline.id.clone()],
944        })? {
945            timeline.revision = timeline.revision.max(current.next_revision());
946        }
947        timeline.sort_order = self.sort_order;
948        timeline.normalize_entries();
949        let id = timeline.id.clone();
950        ctx.emit_set(&timeline)?;
951        Ok(id)
952    }
953}
954
955/// Atomically import a generic OTIO `SerializableCollection` subtree, including
956/// nested bins, reusable timeline placements, timelines, and shot definitions.
957#[myko_command(CollectionId)]
958pub struct ImportCollectionOtio {
959    pub otio_json: String,
960}
961
962impl CommandHandler for ImportCollectionOtio {
963    fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
964        if self.otio_json.len() > MAX_OTIO_IMPORT_BYTES {
965            return Err(command_error(
966                &ctx,
967                format!(
968                    "OTIO import exceeds the {} byte limit",
969                    MAX_OTIO_IMPORT_BYTES
970                ),
971            ));
972        }
973        let mut imported = crate::import_collection_otio_json(&self.otio_json)
974            .map_err(|error| command_error(&ctx, error.to_string()))?;
975        if imported.collections.is_empty() {
976            return Err(command_error(&ctx, "OTIO collection is empty"));
977        }
978        if imported.collections.len() > 10_000
979            || imported.memberships.len() > 100_000
980            || imported.timelines.len() > 10_000
981            || imported.shots.len() > 10_000
982        {
983            return Err(command_error(&ctx, "OTIO collection exceeds import limits"));
984        }
985        let root_id = imported.collections[0].id.clone();
986        let imported_collection_ids = imported
987            .collections
988            .iter()
989            .map(|collection| collection.id.to_string())
990            .collect::<HashSet<_>>();
991        let existing_collections = ctx
992            .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
993            .into_iter()
994            .map(|collection| collection.as_ref().clone())
995            .collect::<Vec<_>>();
996        for collection in &imported.collections {
997            let duplicate = imported
998                .collections
999                .iter()
1000                .chain(
1001                    existing_collections
1002                        .iter()
1003                        .filter(|existing| !imported_collection_ids.contains(existing.id.as_ref())),
1004                )
1005                .any(|candidate| {
1006                    candidate.id != collection.id
1007                        && candidate.library_id == collection.library_id
1008                        && candidate.parent_id == collection.parent_id
1009                        && normalized_collection_name(&candidate.name)
1010                            == normalized_collection_name(&collection.name)
1011                });
1012            if duplicate {
1013                return Err(command_error(
1014                    &ctx,
1015                    format!(
1016                        "A collection named ‘{}’ already exists at the imported location",
1017                        collection.name
1018                    ),
1019                ));
1020            }
1021        }
1022
1023        let imported_timeline_ids = imported
1024            .timelines
1025            .iter()
1026            .map(|timeline| timeline.id.to_string())
1027            .collect::<HashSet<_>>();
1028        let existing_timelines = ctx
1029            .exec_query(GetTimelinesByQuery(TimelineQuery::default()))?
1030            .into_iter()
1031            .map(|timeline| timeline.as_ref().clone())
1032            .collect::<Vec<_>>();
1033        for timeline in &imported.timelines {
1034            let duplicate = imported
1035                .timelines
1036                .iter()
1037                .chain(
1038                    existing_timelines
1039                        .iter()
1040                        .filter(|existing| !imported_timeline_ids.contains(existing.id.as_ref())),
1041                )
1042                .any(|candidate| {
1043                    candidate.id != timeline.id
1044                        && candidate.effective_library_id() == timeline.effective_library_id()
1045                        && normalized_timeline_name(&candidate.name)
1046                            == normalized_timeline_name(&timeline.name)
1047                });
1048            if duplicate {
1049                return Err(command_error(
1050                    &ctx,
1051                    format!("A timeline named ‘{}’ already exists", timeline.name),
1052                ));
1053            }
1054        }
1055
1056        for shot in imported.shots {
1057            ctx.emit_set(&shot)?;
1058        }
1059        for timeline in &mut imported.timelines {
1060            timeline.normalize_entries();
1061            if let Some(current) = existing_timelines
1062                .iter()
1063                .find(|current| current.id == timeline.id)
1064            {
1065                timeline.revision = timeline.revision.max(current.next_revision());
1066            }
1067            ctx.emit_set(timeline)?;
1068        }
1069        for collection in imported.collections {
1070            ctx.emit_set(&collection)?;
1071        }
1072        for membership in imported.memberships {
1073            ctx.emit_set(&membership)?;
1074        }
1075        Ok(root_id)
1076    }
1077}
1078
1079fn command_error(ctx: &CommandContext, message: impl Into<String>) -> CommandError {
1080    CommandError {
1081        tx: ctx.tx().to_string(),
1082        command_id: ctx.command_id.to_string(),
1083        message: message.into(),
1084    }
1085}
1086
1087/// Create, resume, or cancel a durable recorder-controller-owned RecordingJob.
1088#[myko_command(RecordingJobRequestId)]
1089pub struct ControlRecordingJob {
1090    pub streamer_id: String,
1091    #[serde(alias = "runId")]
1092    pub job_id: String,
1093    pub command_id: String,
1094    pub action: RecordingJobAction,
1095    /// Required for Start and frozen for the lifetime of the job.
1096    #[serde(default, skip_serializing_if = "Option::is_none")]
1097    #[ts(type = "unknown")]
1098    pub capture_context: Option<crate::CaptureContext>,
1099    /// Required for Start. The server resolves the authoritative Timeline and
1100    /// snapshots its current revision; clients never author execution provenance.
1101    #[serde(default, alias = "shotListId")]
1102    pub timeline_id: String,
1103    /// Optional organizational placement selected by the client. Start
1104    /// validates the membership and snapshots its root-to-leaf path.
1105    #[serde(default)]
1106    pub collection_id: String,
1107    /// Legacy client-resolved plan. Start ignores this and resolves the
1108    /// authoritative persisted Timeline; resume/cancel do not need a plan.
1109    #[serde(default, alias = "shots", alias = "items")]
1110    pub entries: Vec<ShotEntryPlan>,
1111    /// Start only. When non-empty, record just these timeline entry ids (a
1112    /// single shot, or a subset) instead of the whole timeline. Empty = the
1113    /// whole timeline. The entries still come from the authoritative persisted
1114    /// Timeline — this only narrows which of its entries run — so take
1115    /// numbering, delivery placement, and integrity are unchanged.
1116    #[serde(default)]
1117    pub entry_ids: Vec<String>,
1118    #[serde(default)]
1119    pub preset_duration_ms: u64,
1120    #[serde(default)]
1121    pub translation_speed_cm_s: f32,
1122    #[serde(default)]
1123    pub rotation_speed_deg_s: f32,
1124    #[serde(default)]
1125    pub requested_at_ms: u64,
1126}
1127
1128impl CommandHandler for ControlRecordingJob {
1129    fn execute(self, ctx: CommandContext) -> Result<RecordingJobRequestId, CommandError> {
1130        let (
1131            timeline_id,
1132            timeline_name,
1133            timeline_revision,
1134            collection_id,
1135            collection_path,
1136            entries,
1137        ) = if self.action == RecordingJobAction::StartView {
1138            // A free-camera view capture is a first-class 1-take job: one
1139            // synthetic open-ended entry, no timeline, no mount. The client
1140            // supplies only a display label (entries[0].name = the active
1141            // rig/preset or "freefly"); everything else is authored here so a
1142            // stale client cannot smuggle plan fields into execution.
1143            if self.job_id.trim().is_empty() {
1144                return Err(command_error(
1145                    &ctx,
1146                    "StartView requires a stable RecordingJob id",
1147                ));
1148            }
1149            let label = self
1150                .entries
1151                .first()
1152                .map(|entry| entry.name.trim().to_owned())
1153                .filter(|name| !name.is_empty())
1154                .unwrap_or_else(|| "freefly".to_owned());
1155            let entry = ShotEntryPlan {
1156                entry_id: "view".to_owned(),
1157                shot_id: String::new(),
1158                name: label,
1159                shot_index: None,
1160                kind: crate::ShotKind::Static,
1161                target_name: String::new(),
1162                translation_speed_cm_s: 0.0,
1163                rotation_speed_deg_s: 0.0,
1164                hold_duration_ms: 0,
1165                travel_duration_ms: 0,
1166                direction: crate::ShotDirection::default(),
1167                next_take_number: 1,
1168                open_ended: true,
1169            };
1170            (
1171                String::new(),
1172                String::new(),
1173                0,
1174                String::new(),
1175                Vec::new(),
1176                vec![entry],
1177            )
1178        } else if self.action == RecordingJobAction::Start {
1179            if self.capture_context.is_none() {
1180                return Err(command_error(
1181                    &ctx,
1182                    "Start requires editorial capture context",
1183                ));
1184            }
1185            if self.timeline_id.trim().is_empty() {
1186                return Err(CommandError {
1187                    tx: ctx.tx().to_string(),
1188                    command_id: ctx.command_id.to_string(),
1189                    message: "Start requires a persistent Timeline id".to_owned(),
1190                });
1191            }
1192            if self.job_id.trim().is_empty() {
1193                return Err(CommandError {
1194                    tx: ctx.tx().to_string(),
1195                    command_id: ctx.command_id.to_string(),
1196                    message: "Start requires a stable RecordingJob id".to_owned(),
1197                });
1198            }
1199            let timeline_id = TimelineId::from(self.timeline_id);
1200            let current = ctx
1201                .exec_query_first(GetTimelinesByIds {
1202                    ids: vec![timeline_id.clone()],
1203                })?
1204                .ok_or_else(|| CommandError {
1205                    tx: ctx.tx().to_string(),
1206                    command_id: ctx.command_id.to_string(),
1207                    message: format!("Timeline {timeline_id} does not exist"),
1208                })?;
1209            let mut timeline = (*current).clone();
1210            let (collection_id, collection_path) = if self.collection_id.trim().is_empty() {
1211                (String::new(), Vec::new())
1212            } else {
1213                let membership_id = CollectionMembershipId::from(CollectionMembership::stable_id(
1214                    &self.collection_id,
1215                    timeline_id.as_ref(),
1216                ));
1217                if ctx
1218                    .exec_query_first(GetCollectionMembershipsByIds {
1219                        ids: vec![membership_id],
1220                    })?
1221                    .is_none()
1222                {
1223                    return Err(command_error(
1224                        &ctx,
1225                        "Timeline is not assigned to the selected collection",
1226                    ));
1227                }
1228                let collections = ctx
1229                    .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
1230                    .into_iter()
1231                    .map(|collection| collection.as_ref().clone())
1232                    .collect::<Vec<_>>();
1233                let path = resolve_collection_path(&self.collection_id, &collections)
1234                    .map_err(|error| command_error(&ctx, error))?;
1235                (self.collection_id.clone(), path)
1236            };
1237            let revision = timeline.revision;
1238            let name = timeline.name.clone();
1239            let shot_rows = ctx
1240                .exec_query(GetShotsByQuery(ShotQuery::default()))?
1241                .into_iter()
1242                .map(|shot| shot.as_ref().clone())
1243                .collect::<Vec<_>>();
1244            let timeline_changed = timeline.backfill_entry_parameters(&shot_rows);
1245            let mut entries = crate::resolve_timeline_plans(&timeline, &shot_rows)
1246                .map_err(|error| command_error(&ctx, error))?;
1247            if entries.is_empty() {
1248                return Err(command_error(&ctx, "Timeline has no shot entries"));
1249            }
1250            // Optional single-shot / subset scope: keep only the requested
1251            // entry ids, preserving timeline order. Unknown ids are an error so
1252            // a stale UI can't silently record the wrong thing.
1253            if !self.entry_ids.is_empty() {
1254                let wanted: std::collections::HashSet<&str> =
1255                    self.entry_ids.iter().map(String::as_str).collect();
1256                entries.retain(|entry| wanted.contains(entry.entry_id.as_str()));
1257                if entries.len() != self.entry_ids.len() {
1258                    return Err(command_error(
1259                        &ctx,
1260                        "one or more requested shot entries are not in this timeline",
1261                    ));
1262                }
1263            }
1264            let prior_jobs = ctx
1265                .exec_query(GetRecordingJobsByQuery(RecordingJobQuery::default()))?
1266                .into_iter()
1267                .map(|job| job.as_ref().clone())
1268                .collect::<Vec<_>>();
1269            for entry in &mut entries {
1270                entry.next_take_number = crate::next_take_number_for_entry(
1271                    &prior_jobs,
1272                    &collection_id,
1273                    timeline_id.as_ref(),
1274                    entry,
1275                );
1276            }
1277            if timeline_changed {
1278                ctx.emit_set(&timeline)?;
1279            }
1280            (
1281                timeline_id.to_string(),
1282                name,
1283                revision,
1284                collection_id,
1285                collection_path,
1286                entries,
1287            )
1288        } else {
1289            (
1290                String::new(),
1291                String::new(),
1292                0,
1293                String::new(),
1294                Vec::new(),
1295                self.entries,
1296            )
1297        };
1298        let id: RecordingJobRequestId = self.streamer_id.clone().into();
1299        let capture_context = self.capture_context.map(StoredCaptureContext::from);
1300        ctx.emit_set(&RecordingJobRequest {
1301            id: id.clone(),
1302            streamer_id: self.streamer_id.clone(),
1303            job_id: self.job_id.clone(),
1304            command_id: self.command_id,
1305            action: self.action.clone(),
1306            capture_context: capture_context.clone(),
1307            timeline_id: timeline_id.clone(),
1308            timeline_name: timeline_name.clone(),
1309            timeline_revision,
1310            collection_id: collection_id.clone(),
1311            collection_path: collection_path.clone(),
1312            entries: entries.clone(),
1313            preset_duration_ms: self.preset_duration_ms,
1314            translation_speed_cm_s: self.translation_speed_cm_s,
1315            rotation_speed_deg_s: self.rotation_speed_deg_s,
1316            requested_at_ms: self.requested_at_ms,
1317        })?;
1318        if self.action == RecordingJobAction::Start {
1319            ctx.emit_set(&RecordingJob {
1320                id: RecordingJobId::from(self.job_id.clone()),
1321                job_id: self.job_id,
1322                streamer_id: self.streamer_id,
1323                capture_context,
1324                timeline_id,
1325                timeline_name,
1326                timeline_revision,
1327                collection_id,
1328                collection_path,
1329                phase: RecordingJobPhase::Idle,
1330                pause_requested: false,
1331                entries,
1332                takes: Vec::new(),
1333                error: String::new(),
1334                started_at_ms: 0,
1335                updated_at_ms: self.requested_at_ms,
1336                elapsed_ms: 0,
1337                estimated_total_ms: 0,
1338                estimated_remaining_ms: 0,
1339            })?;
1340        }
1341        Ok(id)
1342    }
1343}
1344
1345/// Recorder-side authoritative RecordingJob progress report.
1346#[myko_command(RecordingJobStatusId)]
1347pub struct SetRecordingJobStatus {
1348    pub streamer_id: String,
1349    #[serde(alias = "runId")]
1350    pub job_id: String,
1351    #[serde(default, skip_serializing_if = "Option::is_none")]
1352    #[ts(type = "unknown")]
1353    pub capture_context: Option<crate::CaptureContext>,
1354    #[serde(default, alias = "shotListId")]
1355    pub timeline_id: String,
1356    #[serde(default, alias = "shotListName")]
1357    pub timeline_name: String,
1358    #[serde(default, alias = "timelineVersion", alias = "shotListVersion")]
1359    pub timeline_revision: u32,
1360    #[serde(default)]
1361    pub collection_id: String,
1362    #[serde(default)]
1363    pub collection_path: Vec<crate::CollectionPathSegment>,
1364    pub phase: RecordingJobPhase,
1365    #[serde(default)]
1366    pub pause_requested: bool,
1367    #[serde(default, alias = "shots", alias = "items")]
1368    pub entries: Vec<ShotEntryPlan>,
1369    #[serde(default)]
1370    pub index: u32,
1371    #[serde(default)]
1372    pub completed: u32,
1373    #[serde(default)]
1374    pub error: String,
1375    #[serde(default)]
1376    pub updated_at_ms: u64,
1377    #[serde(default)]
1378    pub elapsed_ms: u64,
1379    #[serde(default)]
1380    pub estimated_total_ms: u64,
1381    #[serde(default)]
1382    pub estimated_remaining_ms: u64,
1383    #[serde(default)]
1384    pub takes: Vec<Take>,
1385    #[serde(default)]
1386    pub started_at_ms: u64,
1387}
1388
1389/// True when `candidate` says nothing `stored` did not already say, apart from
1390/// the clock.
1391///
1392/// The event store is append-only, so a publisher that republishes unchanged
1393/// state on a timer grows it forever. Comparing with the stored
1394/// `updated_at_ms` substituted in isolates exactly that case: every real
1395/// field still participates, so any actual change writes normally.
1396fn recording_job_status_is_heartbeat_only(
1397    candidate: &RecordingJobStatus,
1398    stored: &RecordingJobStatus,
1399) -> bool {
1400    let mut probe = candidate.clone();
1401    probe.updated_at_ms = stored.updated_at_ms;
1402    probe == *stored
1403}
1404
1405/// Same test for the target summaries the capture bridge mirrors on a poll.
1406fn target_summary_is_heartbeat_only(
1407    candidate: &crate::FrameCaptureTargetSummary,
1408    stored: &crate::FrameCaptureTargetSummary,
1409) -> bool {
1410    let mut probe = candidate.clone();
1411    probe.updated_at_ms = stored.updated_at_ms;
1412    probe == *stored
1413}
1414
1415impl CommandHandler for SetRecordingJobStatus {
1416    fn execute(self, ctx: CommandContext) -> Result<RecordingJobStatusId, CommandError> {
1417        let id: RecordingJobStatusId = self.streamer_id.clone().into();
1418        let mut takes = self.takes;
1419        let job_id = self.job_id.clone();
1420        let existing_job = if !job_id.trim().is_empty() {
1421            ctx.exec_query_first(GetRecordingJobsByIds {
1422                ids: vec![RecordingJobId::from(job_id.clone())],
1423            })?
1424        } else {
1425            None
1426        };
1427        if let Some(existing) = &existing_job {
1428            for take in &mut takes {
1429                let accepted = existing.takes.iter().any(|saved| {
1430                    saved.take_id == take.take_id
1431                        && saved.capture.as_ref().is_some_and(|capture| {
1432                            capture.creative_status == CreativeStatus::Accepted
1433                        })
1434                });
1435                if accepted {
1436                    if let Some(capture) = &mut take.capture {
1437                        capture.creative_status = CreativeStatus::Accepted;
1438                    }
1439                }
1440            }
1441        }
1442        let mirror_present = existing_job.is_some();
1443        let capture_context = self
1444            .capture_context
1445            .map(StoredCaptureContext::from)
1446            .or_else(|| existing_job.and_then(|job| job.capture_context.clone()));
1447        let status = RecordingJobStatus {
1448            id: id.clone(),
1449            streamer_id: self.streamer_id,
1450            job_id: job_id.clone(),
1451            capture_context,
1452            timeline_id: self.timeline_id,
1453            timeline_name: self.timeline_name,
1454            timeline_revision: self.timeline_revision,
1455            collection_id: self.collection_id,
1456            collection_path: self.collection_path,
1457            phase: self.phase,
1458            pause_requested: self.pause_requested,
1459            entries: self.entries,
1460            index: self.index,
1461            completed: self.completed,
1462            error: self.error,
1463            updated_at_ms: self.updated_at_ms,
1464            elapsed_ms: self.elapsed_ms,
1465            estimated_total_ms: self.estimated_total_ms,
1466            estimated_remaining_ms: self.estimated_remaining_ms,
1467            takes,
1468            started_at_ms: self.started_at_ms,
1469        };
1470        // A recorder heartbeat carrying no new state must not become an event.
1471        // The store is append-only, so republishing an unchanged job every few
1472        // seconds per streamer is pure growth: it was 99.9% of all events
1473        // written and ~190 MB/day, with nothing but `updated_at_ms` differing
1474        // between consecutive rows. Suppressing it also makes the field mean
1475        // what every reader already assumes — when this job last *changed* —
1476        // rather than when the recorder last spoke.
1477        //
1478        // This lives here rather than in the recorder because the cell owns
1479        // the durable store: any publisher, now or later, gets the same floor.
1480        let unchanged = ctx
1481            .exec_query_first(GetRecordingJobStatussByIds {
1482                ids: vec![id.clone()],
1483            })?
1484            .is_some_and(|existing| recording_job_status_is_heartbeat_only(&status, &existing));
1485        // An unchanged status still writes when the paired RecordingJob mirror
1486        // is missing, so a half-written pair always converges.
1487        if unchanged && (job_id.trim().is_empty() || mirror_present) {
1488            return Ok(id);
1489        }
1490        ctx.emit_set(&status)?;
1491        if !job_id.trim().is_empty() {
1492            ctx.emit_set(&RecordingJob {
1493                id: RecordingJobId::from(job_id.clone()),
1494                job_id,
1495                streamer_id: status.streamer_id.clone(),
1496                capture_context: status.capture_context.clone(),
1497                timeline_id: status.timeline_id.clone(),
1498                timeline_name: status.timeline_name.clone(),
1499                timeline_revision: status.timeline_revision,
1500                collection_id: status.collection_id.clone(),
1501                collection_path: status.collection_path.clone(),
1502                phase: status.phase.clone(),
1503                pause_requested: status.pause_requested,
1504                entries: status.entries.clone(),
1505                takes: status.takes.clone(),
1506                error: status.error.clone(),
1507                started_at_ms: status.started_at_ms,
1508                updated_at_ms: status.updated_at_ms,
1509                elapsed_ms: status.elapsed_ms,
1510                estimated_total_ms: status.estimated_total_ms,
1511                estimated_remaining_ms: status.estimated_remaining_ms,
1512            })?;
1513        }
1514        Ok(id)
1515    }
1516}
1517
1518/// Mark a delivered Take's Capture as operator-accepted without mutating its
1519/// integrity or QC evidence. Later recorder status republishes preserve review.
1520#[myko_command(RecordingJobId)]
1521pub struct AcceptTake {
1522    pub job_id: String,
1523    pub take_id: String,
1524}
1525
1526impl CommandHandler for AcceptTake {
1527    fn execute(self, ctx: CommandContext) -> Result<RecordingJobId, CommandError> {
1528        let id = RecordingJobId::from(self.job_id);
1529        let current = ctx
1530            .exec_query_first(GetRecordingJobsByIds {
1531                ids: vec![id.clone()],
1532            })?
1533            .ok_or_else(|| command_error(&ctx, format!("Recording job {id} does not exist")))?;
1534        let mut job = current.as_ref().clone();
1535        let take = job
1536            .takes
1537            .iter_mut()
1538            .find(|take| take.take_id == self.take_id)
1539            .ok_or_else(|| command_error(&ctx, "Take does not exist"))?;
1540        let Some(capture) = &mut take.capture else {
1541            return Err(command_error(&ctx, "Take has no Capture to accept"));
1542        };
1543        if take.state != TakeState::Completed
1544            || capture.delivery_status != DeliveryStatus::Delivered
1545        {
1546            return Err(command_error(
1547                &ctx,
1548                "Only delivered Captures can be accepted",
1549            ));
1550        }
1551        capture.creative_status = CreativeStatus::Accepted;
1552        ctx.emit_set(&job)?;
1553        Ok(id)
1554    }
1555}
1556
1557/// Upsert the durable global camera settings. The client sends this whenever the
1558/// operator changes a control, and reads them back (`GetCamPrefsByQuery`) on connect
1559/// to restore the panel + re-apply to the freshly-launched pawn. Full struct each
1560/// time (last-write-wins) — simplest and the payload is tiny.
1561#[myko_command(CamPrefId)]
1562pub struct SetCamPref {
1563    pub focal: f32,
1564    pub aperture: f32,
1565    pub focus_method: String,
1566    pub focus_dist: f32,
1567    pub base_speed: f32,
1568    pub look_scale: f32,
1569    pub invert: bool,
1570    pub glide: bool,
1571    pub glide_secs: f32,
1572    pub motion_blur: f32,
1573    pub rail_speed: f32,
1574}
1575
1576impl CommandHandler for SetCamPref {
1577    fn execute(self, ctx: CommandContext) -> Result<CamPrefId, CommandError> {
1578        let id = CamPref::row_id();
1579        let pref = CamPref {
1580            id: id.clone(),
1581            focal: self.focal,
1582            aperture: self.aperture,
1583            focus_method: self.focus_method,
1584            focus_dist: self.focus_dist,
1585            base_speed: self.base_speed,
1586            look_scale: self.look_scale,
1587            invert: self.invert,
1588            glide: self.glide,
1589            glide_secs: self.glide_secs,
1590            motion_blur: self.motion_blur,
1591            rail_speed: self.rail_speed,
1592        };
1593        ctx.emit_set(&pref)?;
1594        Ok(id)
1595    }
1596}
1597
1598/// Persist one stream's authored Home camera pose and lens. The command only
1599/// updates server state; moving the live camera remains an explicit client action.
1600#[myko_command(CameraHomeId)]
1601pub struct SetCameraHome {
1602    pub stream_id: String,
1603    pub location_x: f32,
1604    pub location_y: f32,
1605    pub location_z: f32,
1606    pub rotation_pitch: f32,
1607    pub rotation_yaw: f32,
1608    pub rotation_roll: f32,
1609    pub focal_length: f32,
1610}
1611
1612impl CommandHandler for SetCameraHome {
1613    fn execute(self, ctx: CommandContext) -> Result<CameraHomeId, CommandError> {
1614        if self.stream_id.trim().is_empty() {
1615            return Err(command_error(&ctx, "Camera Home requires a stream id"));
1616        }
1617        let values = [
1618            self.location_x,
1619            self.location_y,
1620            self.location_z,
1621            self.rotation_pitch,
1622            self.rotation_yaw,
1623            self.rotation_roll,
1624            self.focal_length,
1625        ];
1626        if values.iter().any(|value| !value.is_finite()) {
1627            return Err(command_error(&ctx, "Camera Home values must be finite"));
1628        }
1629        if !(1.0..=1000.0).contains(&self.focal_length) {
1630            return Err(command_error(
1631                &ctx,
1632                "Camera Home focal length must be between 1 and 1000 mm",
1633            ));
1634        }
1635
1636        let id = CameraHome::row_id(&self.stream_id);
1637        ctx.emit_set(&CameraHome {
1638            id: id.clone(),
1639            stream_id: self.stream_id,
1640            location_x: self.location_x,
1641            location_y: self.location_y,
1642            location_z: self.location_z,
1643            rotation_pitch: self.rotation_pitch,
1644            rotation_yaw: self.rotation_yaw,
1645            rotation_roll: self.rotation_roll,
1646            focal_length: self.focal_length,
1647        })?;
1648        Ok(id)
1649    }
1650}
1651
1652/// Set a stream's friendly DISPLAY name (from the cluster def's `previs.stream_name`,
1653/// written on previs launch, keyed by the StreamerId). Upsert / last-write-wins. The
1654/// StreamerId is unchanged, so duplicate names never collide.
1655#[myko_command(StreamId)]
1656pub struct SetStreamName {
1657    pub stream_id: StreamId,
1658    pub name: String,
1659}
1660
1661impl CommandHandler for SetStreamName {
1662    fn execute(self, ctx: CommandContext) -> Result<StreamId, CommandError> {
1663        let id = self.stream_id.clone();
1664        let stream = Stream {
1665            id: id.clone(),
1666            name: self.name,
1667        };
1668        ctx.emit_set(&stream)?;
1669        Ok(id)
1670    }
1671}
1672
1673/// Client-side record trigger: set the intent to record `streamer_id` (active on/off) +
1674/// camera metadata. The recorder service watches `RecordingRequest`, chooses the active
1675/// rig (rail/crane) or preset/shot as its human artifact label, and starts/stops the
1676/// server-side no-transcode capture. Keyed by streamer_id (one active recording per stream).
1677#[myko_command(RecordingRequestId)]
1678pub struct SetRecording {
1679    pub streamer_id: String,
1680    pub active: bool,
1681    #[serde(default)]
1682    pub capture_kind: crate::RecordingKind,
1683    #[serde(default)]
1684    pub rig: String,
1685    #[serde(default)]
1686    pub preset: String,
1687    #[serde(default)]
1688    pub stream_name: String,
1689    #[serde(default, skip_serializing_if = "Option::is_none")]
1690    pub travel_direction: Option<ShotDirection>,
1691    #[serde(default, skip_serializing_if = "Option::is_none")]
1692    pub shot_index: Option<u32>,
1693    #[serde(default)]
1694    pub take_number: u32,
1695    #[serde(default, alias = "clipId", alias = "cueId")]
1696    pub entry_id: String,
1697    #[serde(default, alias = "shotListId")]
1698    pub timeline_id: String,
1699    #[serde(default, alias = "shotListName")]
1700    pub timeline_name: String,
1701    #[serde(default, alias = "shotListVersion")]
1702    pub timeline_revision: u32,
1703    #[serde(default)]
1704    pub collection_path: Vec<crate::CollectionPathSegment>,
1705    #[serde(default)]
1706    pub requested_at_ms: u64,
1707}
1708impl CommandHandler for SetRecording {
1709    fn execute(self, ctx: CommandContext) -> Result<RecordingRequestId, CommandError> {
1710        let id: RecordingRequestId = self.streamer_id.clone().into();
1711        let req = RecordingRequest {
1712            id: id.clone(),
1713            streamer_id: self.streamer_id,
1714            active: self.active,
1715            capture_kind: self.capture_kind,
1716            rig: self.rig,
1717            preset: self.preset,
1718            stream_name: self.stream_name,
1719            travel_direction: self.travel_direction,
1720            shot_index: self.shot_index,
1721            take_number: self.take_number,
1722            entry_id: self.entry_id,
1723            timeline_id: self.timeline_id,
1724            timeline_name: self.timeline_name,
1725            timeline_revision: self.timeline_revision,
1726            collection_path: self.collection_path,
1727            requested_at_ms: self.requested_at_ms,
1728        };
1729        ctx.emit_set(&req)?;
1730        Ok(id)
1731    }
1732}
1733
1734/// Recorder-side status report for capture, finalization, and NAS delivery readiness.
1735#[myko_command(RecordingStatusId)]
1736pub struct SetRecordingStatus {
1737    pub streamer_id: String,
1738    pub state: RecordingState,
1739    #[serde(default)]
1740    pub file_name: String,
1741    #[serde(default)]
1742    pub error: String,
1743    #[serde(default)]
1744    pub started_at_ms: u64,
1745}
1746impl CommandHandler for SetRecordingStatus {
1747    fn execute(self, ctx: CommandContext) -> Result<RecordingStatusId, CommandError> {
1748        let id: RecordingStatusId = self.streamer_id.clone().into();
1749        let st = RecordingStatus {
1750            id: id.clone(),
1751            streamer_id: self.streamer_id,
1752            state: self.state,
1753            file_name: self.file_name,
1754            error: self.error,
1755            started_at_ms: self.started_at_ms,
1756        };
1757        ctx.emit_set(&st)?;
1758        Ok(id)
1759    }
1760}
1761
1762/// Register (or refresh) my presence on a stream.
1763#[myko_command(ViewerId)]
1764pub struct JoinStream {
1765    pub stream_id: StreamId,
1766    pub viewer_id: String,
1767    pub name: String,
1768    pub color: String,
1769    #[serde(default, skip_serializing_if = "Option::is_none")]
1770    pub identity_issuer: Option<String>,
1771    #[serde(default, skip_serializing_if = "Option::is_none")]
1772    pub identity_subject: Option<String>,
1773    #[serde(default, skip_serializing_if = "Option::is_none")]
1774    pub avatar_url: Option<String>,
1775}
1776
1777impl CommandHandler for JoinStream {
1778    fn execute(self, ctx: CommandContext) -> Result<ViewerId, CommandError> {
1779        let client_id = ctx
1780            .client_id()
1781            .map(|id| myko::entities::client::ClientId::from(id.to_owned()));
1782        // One presence row per CONNECTION: a browser's tabs share the persisted
1783        // viewer_id but must each be their own presence + drive unit. Fall back to
1784        // viewer_id only if there's somehow no client id (not a live WS connection).
1785        let conn = client_id
1786            .as_ref()
1787            .map(|c| c.to_string())
1788            .unwrap_or_else(|| self.viewer_id.clone());
1789        let id = Viewer::row_id(&self.stream_id, &conn);
1790        let stream_id = self.stream_id.clone();
1791        let viewer = Viewer {
1792            id: id.clone(),
1793            stream_id: self.stream_id,
1794            viewer_id: self.viewer_id,
1795            name: self.name,
1796            color: self.color,
1797            identity_issuer: self.identity_issuer,
1798            identity_subject: self.identity_subject,
1799            avatar_url: self.avatar_url,
1800            cursor: None,
1801            // Explicit: a command's emit_set is not auto-stamped with the client id.
1802            client_id,
1803        };
1804        ctx.emit_set(&viewer)?;
1805        // Reconcile the lock against live presence: clear a departed holder's
1806        // stale lock, and if this leaves exactly one viewer, they drive.
1807        reconcile_control(&ctx, &stream_id)?;
1808        Ok(id)
1809    }
1810}
1811
1812/// Remove this connection's presence from a stream during in-page navigation.
1813///
1814/// A full websocket disconnect is already cascade-cleaned by myko, but moving
1815/// between streams keeps that socket alive. Addressing the same connection-keyed
1816/// row as [`JoinStream`] prevents ghost viewers and immediately reconciles the old
1817/// room's wheel.
1818#[myko_command]
1819pub struct LeaveStream {
1820    pub stream_id: StreamId,
1821    pub viewer_id: String,
1822}
1823
1824impl CommandHandler for LeaveStream {
1825    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
1826        let conn = ctx
1827            .client_id()
1828            .map(|client_id| client_id.to_string())
1829            .unwrap_or(self.viewer_id);
1830        let id = Viewer::row_id(&self.stream_id, &conn);
1831        if let Some(viewer) = ctx.exec_report(GetViewerById { id })? {
1832            ctx.emit_del(&*viewer)?;
1833        }
1834        reconcile_control(&ctx, &self.stream_id)
1835    }
1836}
1837
1838/// Move my cursor (frequent, cheap).
1839#[myko_command]
1840pub struct UpdateCursor {
1841    pub stream_id: StreamId,
1842    pub viewer_id: String,
1843    pub cursor: Option<(f32, f32)>,
1844}
1845
1846impl CommandHandler for UpdateCursor {
1847    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
1848        // Address my own per-connection presence row (see JoinStream).
1849        let conn = ctx
1850            .client_id()
1851            .map(|c| c.to_string())
1852            .unwrap_or_else(|| self.viewer_id.clone());
1853        let id = Viewer::row_id(&self.stream_id, &conn);
1854        if let Some(viewer) = ctx.exec_report(GetViewerById { id })? {
1855            let updated = Viewer {
1856                cursor: self.cursor,
1857                ..(*viewer).clone()
1858            };
1859            ctx.emit_set(&updated)?;
1860        }
1861        Ok(())
1862    }
1863}
1864
1865/// Take the wheel (SET overwrites any current holder — takeover).
1866#[myko_command(ControlLockId)]
1867pub struct AcquireControl {
1868    pub stream_id: StreamId,
1869    pub viewer_id: String,
1870}
1871
1872impl CommandHandler for AcquireControl {
1873    fn execute(self, ctx: CommandContext) -> Result<ControlLockId, CommandError> {
1874        let id = ControlLock::row_id(&self.stream_id);
1875        let lock = ControlLock {
1876            id: id.clone(),
1877            stream_id: self.stream_id,
1878            viewer_id: self.viewer_id,
1879            client_id: ctx
1880                .client_id()
1881                .map(|id| myko::entities::client::ClientId::from(id.to_owned())),
1882        };
1883        ctx.emit_set(&lock)?;
1884        Ok(id)
1885    }
1886}
1887
1888/// Release the wheel — held by a PERSON (viewer_id), so any of their tabs may
1889/// release it (the request carries the caller's viewer_id).
1890#[myko_command]
1891pub struct ReleaseControl {
1892    pub stream_id: StreamId,
1893    pub viewer_id: String,
1894}
1895
1896impl CommandHandler for ReleaseControl {
1897    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
1898        let id = ControlLock::row_id(&self.stream_id);
1899        if let Some(lock) = ctx.exec_report(GetControlLockById { id })? {
1900            if lock.viewer_id == self.viewer_id {
1901                ctx.emit_del(&*lock)?;
1902            }
1903        }
1904        Ok(())
1905    }
1906}
1907
1908/// Server-internal: reconcile a stream's control lock against live presence.
1909/// Idempotent and cheap, safe to run on every join/leave.
1910///
1911/// The wheel belongs to a PERSON (viewer_id) — a person's tabs share it (browser
1912/// focus ensures only the active tab actually sends input), so a lock is kept alive
1913/// as long as ANY connection of the holder is present.
1914///
1915/// 1. **Clear a departed holder's lock.** `ControlLock` is not reliably
1916///    cascade-deleted on disconnect, so a holder who has fully left (no connection
1917///    of theirs remains) can linger; delete it. App-level backstop for the cascade.
1918/// 2. **Sole person auto-holds.** If exactly one person is present (any number of
1919///    their tabs) and doesn't already hold a valid lock, hand them the wheel.
1920/// The people behind a stream's live connections, deduplicated, sorted.
1921///
1922/// Multiple tabs of one person are one person: the wheel belongs to a human,
1923/// not to a socket.
1924fn people_present(viewers: &[Arc<Viewer>]) -> Vec<&str> {
1925    let mut people: Vec<&str> = viewers.iter().map(|v| v.viewer_id.as_str()).collect();
1926    people.sort_unstable();
1927    people.dedup();
1928    people
1929}
1930
1931/// Whether this presence row still has a browser behind it.
1932///
1933/// Liveness is a property of the connection, never of the stored row. A row
1934/// carries no proof of life: a tab that dies without a disconnect leaves it
1935/// behind, replay restores it at boot, and it then looks exactly like presence.
1936/// myko's `ClientStatus` answers from the live connection registry instead —
1937/// the same source its `ConnectedClients` view uses.
1938///
1939/// A row with no client id cannot be tied to a connection at all, so it cannot
1940/// be shown to be alive.
1941fn viewer_is_live(ctx: &CommandContext, viewer: &Viewer) -> Result<bool, CommandError> {
1942    let Some(client_id) = viewer.client_id.clone() else {
1943        return Ok(false);
1944    };
1945    Ok(ctx.exec_report(ClientStatus { client_id })?.online)
1946}
1947
1948fn reconcile_control(ctx: &CommandContext, stream_id: &StreamId) -> Result<(), CommandError> {
1949    let stored: Vec<Arc<Viewer>> = ctx.exec_query(GetViewersByQuery(ViewerQuery {
1950        stream_id: Some(IdFilter::Eq(stream_id.clone())),
1951        ..Default::default()
1952    }))?;
1953    // Every decision below is about who is *here*, so ask the connections, not
1954    // the store. Without this a ghost keeps a departed holder's lock alive and
1955    // can even be handed the wheel as the "sole" person — the recurring
1956    // "Anonymous ... has the wheel".
1957    let mut viewers = Vec::with_capacity(stored.len());
1958    for viewer in stored {
1959        if viewer_is_live(ctx, &viewer)? {
1960            viewers.push(viewer);
1961        }
1962    }
1963    let id = ControlLock::row_id(stream_id);
1964
1965    // (1) Drop a lock whose holding PERSON has left (no connection of theirs remains).
1966    if let Some(lock) = ctx.exec_report(GetControlLockById { id: id.clone() })? {
1967        let holder_present = viewers.iter().any(|v| v.viewer_id == lock.viewer_id);
1968        if !holder_present {
1969            ctx.emit_del(&*lock)?;
1970        }
1971    }
1972
1973    // (2) Sole PERSON auto-holds (multiple tabs of one person count as one).
1974    let people = people_present(&viewers);
1975    if let [sole_vid] = people.as_slice() {
1976        let held_by_sole = ctx
1977            .exec_report(GetControlLockById { id: id.clone() })?
1978            .as_deref()
1979            .is_some_and(|l| l.viewer_id.as_str() == *sole_vid);
1980        if !held_by_sole {
1981            // Carry the lock's client_id on any one of that person's connections.
1982            let conn = viewers
1983                .iter()
1984                .find(|v| v.viewer_id.as_str() == *sole_vid)
1985                .expect("present");
1986            ctx.emit_set(&ControlLock {
1987                id,
1988                stream_id: stream_id.clone(),
1989                viewer_id: (*sole_vid).to_string(),
1990                client_id: conn.client_id.clone(),
1991            })?;
1992        }
1993    }
1994    Ok(())
1995}
1996
1997/// Server-internal command emitted by the leave-reassign saga (server crate):
1998/// reconcile a stream's control lock against live presence. Exposed so the saga can emit it.
1999#[myko_command]
2000pub struct AutoAssignControl {
2001    pub stream_id: StreamId,
2002}
2003
2004impl CommandHandler for AutoAssignControl {
2005    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
2006        reconcile_control(&ctx, &self.stream_id)
2007    }
2008}
2009
2010/// Submit a synchronized nDisplay frame capture.
2011///
2012/// The client supplies ONLY typed editorial context and a typed target
2013/// selection; it never talks to Pulse Cluster and never constructs artifact
2014/// paths. The off-browser bridge picks this request up, submits it with the
2015/// cluster credential, and mirrors authoritative status + typed receipts back
2016/// as `FrameCaptureStatus`.
2017#[myko_command(FrameCaptureRequestId)]
2018pub struct SubmitFrameCapture {
2019    pub streamer_id: String,
2020    pub capture_id: String,
2021    pub command_id: String,
2022    #[ts(type = "unknown")]
2023    pub capture_context: crate::CaptureContext,
2024    pub target: crate::FrameCaptureTarget,
2025    /// Explicit operator override; see FrameCaptureRequest::force.
2026    #[serde(default)]
2027    pub force: bool,
2028    #[serde(default)]
2029    pub requested_at_ms: u64,
2030}
2031
2032impl CommandHandler for SubmitFrameCapture {
2033    fn execute(self, ctx: CommandContext) -> Result<FrameCaptureRequestId, CommandError> {
2034        if self.streamer_id.trim().is_empty() {
2035            return Err(command_error(&ctx, "Frame capture requires a stream"));
2036        }
2037        if self.capture_id.trim().is_empty() {
2038            return Err(command_error(&ctx, "Frame capture requires a capture id"));
2039        }
2040        if self.target.cluster_name.trim().is_empty() {
2041            return Err(command_error(
2042                &ctx,
2043                "Frame capture requires an explicit target cluster",
2044            ));
2045        }
2046        // Editorial identity must be explicit — never inferred from whatever
2047        // collection happens to be browsed. The typed context carries it.
2048        let id: FrameCaptureRequestId = self.streamer_id.clone().into();
2049        ctx.emit_set(&crate::FrameCaptureRequest {
2050            id: id.clone(),
2051            streamer_id: self.streamer_id,
2052            capture_id: self.capture_id,
2053            command_id: self.command_id,
2054            capture_context: self.capture_context.into(),
2055            target: self.target,
2056            force: self.force,
2057            requested_at_ms: self.requested_at_ms,
2058        })?;
2059        Ok(id)
2060    }
2061}
2062
2063/// Bridge-owned authoritative status for one stream's frame capture.
2064#[myko_command(FrameCaptureStatusId)]
2065pub struct SetFrameCaptureStatus {
2066    pub streamer_id: String,
2067    #[serde(default)]
2068    pub capture_id: String,
2069    pub phase: crate::FrameCapturePhase,
2070    #[serde(default, skip_serializing_if = "Option::is_none")]
2071    #[ts(type = "unknown")]
2072    pub capture_context: Option<crate::CaptureContext>,
2073    #[serde(default)]
2074    pub target: crate::FrameCaptureTarget,
2075    #[serde(default)]
2076    pub observed_generation: String,
2077    #[serde(default)]
2078    pub receipts: Vec<crate::FrameCaptureReceipt>,
2079    #[serde(default)]
2080    pub error: String,
2081    #[serde(default)]
2082    pub forceable: bool,
2083    #[serde(default)]
2084    pub updated_at_ms: u64,
2085}
2086
2087impl CommandHandler for SetFrameCaptureStatus {
2088    fn execute(self, ctx: CommandContext) -> Result<FrameCaptureStatusId, CommandError> {
2089        let id: FrameCaptureStatusId = self.streamer_id.clone().into();
2090        ctx.emit_set(&crate::FrameCaptureStatus {
2091            id: id.clone(),
2092            streamer_id: self.streamer_id,
2093            capture_id: self.capture_id,
2094            phase: self.phase,
2095            capture_context: self.capture_context.map(StoredCaptureContext::from),
2096            target: self.target,
2097            observed_generation: self.observed_generation,
2098            receipts: self.receipts,
2099            error: self.error,
2100            forceable: self.forceable,
2101            updated_at_ms: self.updated_at_ms,
2102        })?;
2103        Ok(id)
2104    }
2105}
2106
2107/// Bridge-mirrored summary of a cluster the operator may capture on. The
2108/// browser never queries Pulse Cluster, so these summaries are how the UI
2109/// learns valid targets at all.
2110#[myko_command(FrameCaptureTargetSummaryId)]
2111pub struct SetFrameCaptureTargetSummary {
2112    pub cluster_name: String,
2113    #[serde(default)]
2114    pub generation: String,
2115    #[serde(default)]
2116    pub capturable: bool,
2117    #[serde(default)]
2118    pub status: String,
2119    #[serde(default)]
2120    pub updated_at_ms: u64,
2121}
2122
2123impl CommandHandler for SetFrameCaptureTargetSummary {
2124    fn execute(self, ctx: CommandContext) -> Result<FrameCaptureTargetSummaryId, CommandError> {
2125        if self.cluster_name.trim().is_empty() {
2126            return Err(command_error(
2127                &ctx,
2128                "Target summary requires a cluster name",
2129            ));
2130        }
2131        let id: FrameCaptureTargetSummaryId = self.cluster_name.clone().into();
2132        let summary = crate::FrameCaptureTargetSummary {
2133            id: id.clone(),
2134            cluster_name: self.cluster_name,
2135            generation: self.generation,
2136            capturable: self.capturable,
2137            status: self.status,
2138            updated_at_ms: self.updated_at_ms,
2139        };
2140        // The bridge mirrors every target on a poll loop, so most of these
2141        // carry the same generation, status and capturability as the row
2142        // already stored — only the clock moved. An append-only store must not
2143        // record that: it was ~60k events/day for ~18 real changes.
2144        let unchanged = ctx
2145            .exec_query_first(GetFrameCaptureTargetSummarysByIds {
2146                ids: vec![id.clone()],
2147            })?
2148            .is_some_and(|existing| target_summary_is_heartbeat_only(&summary, &existing));
2149        if unchanged {
2150            return Ok(id);
2151        }
2152        ctx.emit_set(&summary)?;
2153        Ok(id)
2154    }
2155}
2156
2157#[cfg(test)]
2158mod discovery_tests {
2159    use super::*;
2160    use crate::shot::DEFAULT_SHOT_LIBRARY_ID;
2161
2162    fn tuned_legacy_shot() -> Shot {
2163        Shot {
2164            id: ShotId::from("render-11:moving:Floor Dolly"),
2165            library_id: String::new(),
2166            streamer_id: "render-11".to_owned(),
2167            name: "Floor Dolly".to_owned(),
2168            kind: ShotKind::Moving,
2169            target_name: "Floor Dolly".to_owned(),
2170            translation_speed_cm_s: 17.0,
2171            rotation_speed_deg_s: 3.5,
2172            hold_duration_ms: 8_000,
2173            travel_duration_ms: 41_000,
2174            default_entry_mode: ShotEntryMode::Both,
2175            shot_index: 9,
2176        }
2177    }
2178
2179    fn legacy_timeline(id: &str, streamer_id: &str, name: &str, shot_id: &str) -> Timeline {
2180        Timeline {
2181            id: TimelineId::from(id),
2182            library_id: String::new(),
2183            streamer_id: streamer_id.to_owned(),
2184            name: name.to_owned(),
2185            revision: 0,
2186            entries: if shot_id.is_empty() {
2187                Vec::new()
2188            } else {
2189                vec![ShotEntry {
2190                    shot_id: shot_id.to_owned(),
2191                    direction: ShotEntryMode::Forward,
2192                    ..Default::default()
2193                }]
2194            },
2195            sort_order: 0,
2196        }
2197    }
2198
2199    #[test]
2200    fn discovery_creates_only_missing_targets_and_preserves_tuned_legacy_rows() {
2201        let existing = vec![tuned_legacy_shot()];
2202        let discovered = vec![
2203            ShotDiscovery {
2204                name: "Floor Dolly".to_owned(),
2205                kind: ShotKind::Moving,
2206                target_name: "Floor Dolly".to_owned(),
2207            },
2208            ShotDiscovery {
2209                name: "Hero Push Forward".to_owned(),
2210                kind: ShotKind::Moving,
2211                target_name: "Hero Push Forward".to_owned(),
2212            },
2213            ShotDiscovery {
2214                name: "Hero Push Forward".to_owned(),
2215                kind: ShotKind::Moving,
2216                target_name: "Hero Push Forward".to_owned(),
2217            },
2218        ];
2219
2220        let created = discovered_shots_to_create(DEFAULT_SHOT_LIBRARY_ID, discovered, &existing);
2221        assert_eq!(created.len(), 1);
2222        assert_eq!(created[0].name, "Hero Push Forward");
2223        assert_eq!(created[0].shot_index, 10);
2224        assert_eq!(
2225            created[0].translation_speed_cm_s,
2226            DEFAULT_SHOT_TRANSLATION_SPEED_CM_S
2227        );
2228        assert_eq!(
2229            created[0].rotation_speed_deg_s,
2230            DEFAULT_SHOT_ROTATION_SPEED_DEG_S
2231        );
2232        assert_eq!(created[0].default_entry_mode, ShotEntryMode::Forward);
2233        assert_eq!(existing[0].translation_speed_cm_s, 17.0);
2234        assert_eq!(existing[0].rotation_speed_deg_s, 3.5);
2235    }
2236
2237    #[test]
2238    fn discovery_reconciles_live_legacy_timeline_duplicates_without_losing_clips() {
2239        let render_shot = tuned_legacy_shot();
2240        let mut studio_shot = tuned_legacy_shot();
2241        studio_shot.id = ShotId::from("Studio A:moving:Floor Dolly");
2242        studio_shot.streamer_id = "Studio A".to_owned();
2243        let timelines = vec![
2244            legacy_timeline(
2245                "render-11:timeline:default",
2246                "render-11",
2247                "Default shot list",
2248                render_shot.id.as_ref(),
2249            ),
2250            legacy_timeline(
2251                "Studio A:timeline:default",
2252                "Studio A",
2253                "Default timeline",
2254                studio_shot.id.as_ref(),
2255            ),
2256            legacy_timeline(
2257                "timeline-old",
2258                "render-11",
2259                "Supercut",
2260                render_shot.id.as_ref(),
2261            ),
2262            Timeline {
2263                id: TimelineId::from("timeline-shared"),
2264                library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
2265                streamer_id: "Studio A".to_owned(),
2266                name: " superCUT ".to_owned(),
2267                revision: 2,
2268                entries: vec![ShotEntry {
2269                    shot_id: studio_shot.id.to_string(),
2270                    direction: ShotEntryMode::Reverse,
2271                    ..Default::default()
2272                }],
2273                sort_order: 1,
2274            },
2275        ];
2276
2277        let result = reconcile_timelines(
2278            DEFAULT_SHOT_LIBRARY_ID,
2279            &[render_shot, studio_shot],
2280            timelines,
2281        );
2282
2283        assert_eq!(result.upserts.len(), 2);
2284        assert_eq!(result.deletes.len(), 3);
2285        let default = result
2286            .upserts
2287            .iter()
2288            .find(|timeline| timeline.id.to_string() == Timeline::shared_legacy_default_id())
2289            .expect("canonical shared default");
2290        assert_eq!(default.library_id, DEFAULT_SHOT_LIBRARY_ID);
2291        assert!(default.streamer_id.is_empty());
2292        assert_eq!(default.name, "Migrated timeline");
2293        assert_eq!(default.entries.len(), 1);
2294        assert_eq!(default.entries[0].shot_id, "Studio A:moving:Floor Dolly");
2295        assert_eq!(result.id_migrations.len(), 3);
2296        assert_eq!(
2297            result.id_migrations.get("render-11:timeline:default"),
2298            Some(&Timeline::shared_legacy_default_id())
2299        );
2300
2301        let supercut = result
2302            .upserts
2303            .iter()
2304            .find(|timeline| timeline.id.as_ref() == "timeline-shared")
2305            .expect("explicit shared timeline wins");
2306        assert_eq!(supercut.name, "superCUT");
2307        assert_eq!(supercut.revision, 2);
2308        assert_eq!(supercut.entries.len(), 2);
2309        assert_eq!(supercut.entries[0].direction, ShotEntryMode::Reverse);
2310        assert_eq!(supercut.entries[1].direction, ShotEntryMode::Forward);
2311        assert!(supercut
2312            .entries
2313            .iter()
2314            .all(|entry| !entry.entry_id.is_empty()));
2315    }
2316
2317    #[test]
2318    fn reconciliation_is_idempotent_for_canonical_timelines() {
2319        let shot = tuned_legacy_shot();
2320        let mut timeline = Timeline {
2321            id: TimelineId::from("shared:timeline:supercut"),
2322            library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
2323            streamer_id: String::new(),
2324            name: "Supercut".to_owned(),
2325            revision: 1,
2326            entries: vec![ShotEntry {
2327                shot_id: shot.id.to_string(),
2328                direction: ShotEntryMode::Forward,
2329                ..Default::default()
2330            }],
2331            sort_order: 0,
2332        };
2333        timeline.normalize_entries();
2334        assert!(timeline.backfill_entry_parameters(std::slice::from_ref(&shot)));
2335        assert!(!timeline.backfill_entry_parameters(std::slice::from_ref(&shot)));
2336        let result = reconcile_timelines(DEFAULT_SHOT_LIBRARY_ID, &[shot], vec![timeline]);
2337        assert!(result.upserts.is_empty());
2338        assert!(result.deletes.is_empty());
2339    }
2340
2341    #[test]
2342    fn reconciliation_snapshots_only_missing_legacy_clip_parameters() {
2343        let shot = tuned_legacy_shot();
2344        let mut timeline =
2345            legacy_timeline("shared:timeline:supercut", "", "Supercut", shot.id.as_ref());
2346        timeline.library_id = DEFAULT_SHOT_LIBRARY_ID.to_owned();
2347        timeline.entries[0].translation_speed_cm_s = Some(4.5);
2348
2349        let result = reconcile_timelines(
2350            DEFAULT_SHOT_LIBRARY_ID,
2351            std::slice::from_ref(&shot),
2352            vec![timeline],
2353        );
2354        assert_eq!(result.upserts.len(), 1);
2355        let entry = &result.upserts[0].entries[0];
2356        assert_eq!(entry.translation_speed_cm_s, Some(4.5));
2357        assert_eq!(entry.rotation_speed_deg_s, Some(shot.rotation_speed_deg_s));
2358        assert_eq!(entry.hold_duration_ms, Some(shot.hold_duration_ms));
2359        assert_eq!(
2360            entry.travel_duration_ms,
2361            Some(shot.effective_travel_duration_ms())
2362        );
2363        assert_eq!(entry.shot_index, Some(shot.shot_index));
2364    }
2365}
2366
2367#[cfg(test)]
2368mod heartbeat_suppression_tests {
2369    use super::*;
2370    use crate::recording_job_status::RecordingJobStatus;
2371
2372    fn status() -> RecordingJobStatus {
2373        RecordingJobStatus {
2374            id: "render-13".into(),
2375            streamer_id: "render-13".to_owned(),
2376            job_id: "job-1".to_owned(),
2377            capture_context: None,
2378            timeline_id: "supercut".to_owned(),
2379            timeline_name: "Supercut".to_owned(),
2380            timeline_revision: 1,
2381            collection_id: String::new(),
2382            collection_path: Vec::new(),
2383            phase: RecordingJobPhase::Traveling,
2384            pause_requested: false,
2385            entries: Vec::new(),
2386            index: 0,
2387            completed: 0,
2388            error: String::new(),
2389            updated_at_ms: 1_000,
2390            elapsed_ms: 5_000,
2391            estimated_total_ms: 10_000,
2392            estimated_remaining_ms: 5_000,
2393            takes: Vec::new(),
2394            started_at_ms: 500,
2395        }
2396    }
2397
2398    fn summary() -> crate::FrameCaptureTargetSummary {
2399        crate::FrameCaptureTargetSummary {
2400            id: "0of12_rx11".into(),
2401            cluster_name: "0of12_rx11".to_owned(),
2402            generation: "3350".to_owned(),
2403            capturable: false,
2404            status: "stopped".to_owned(),
2405            updated_at_ms: 1_000,
2406        }
2407    }
2408
2409    #[test]
2410    fn a_newer_clock_alone_is_not_a_change() {
2411        let stored = status();
2412        let mut republished = stored.clone();
2413        republished.updated_at_ms = 9_999;
2414        assert!(recording_job_status_is_heartbeat_only(
2415            &republished,
2416            &stored
2417        ));
2418    }
2419
2420    #[test]
2421    fn real_progress_still_writes() {
2422        let stored = status();
2423        for mutate in [
2424            (|s: &mut RecordingJobStatus| s.phase = RecordingJobPhase::Complete)
2425                as fn(&mut RecordingJobStatus),
2426            |s: &mut RecordingJobStatus| s.elapsed_ms = 6_000,
2427            |s: &mut RecordingJobStatus| s.completed = 1,
2428            |s: &mut RecordingJobStatus| s.error = "disk full".to_owned(),
2429            |s: &mut RecordingJobStatus| s.pause_requested = true,
2430            |s: &mut RecordingJobStatus| s.estimated_remaining_ms = 4_000,
2431        ] {
2432            let mut candidate = stored.clone();
2433            candidate.updated_at_ms = 9_999;
2434            mutate(&mut candidate);
2435            assert!(
2436                !recording_job_status_is_heartbeat_only(&candidate, &stored),
2437                "a changed field must still be written"
2438            );
2439        }
2440    }
2441
2442    #[test]
2443    fn target_summaries_follow_the_same_rule() {
2444        let stored = summary();
2445        let mut polled = stored.clone();
2446        polled.updated_at_ms = 9_999;
2447        assert!(target_summary_is_heartbeat_only(&polled, &stored));
2448
2449        for mutate in [
2450            (|s: &mut crate::FrameCaptureTargetSummary| s.capturable = true)
2451                as fn(&mut crate::FrameCaptureTargetSummary),
2452            |s: &mut crate::FrameCaptureTargetSummary| s.generation = "3351".to_owned(),
2453            |s: &mut crate::FrameCaptureTargetSummary| s.status = "running".to_owned(),
2454        ] {
2455            let mut candidate = stored.clone();
2456            candidate.updated_at_ms = 9_999;
2457            mutate(&mut candidate);
2458            assert!(!target_summary_is_heartbeat_only(&candidate, &stored));
2459        }
2460    }
2461}
2462
2463#[cfg(test)]
2464mod presence_tests {
2465    use std::sync::Arc;
2466
2467    use super::people_present;
2468    use crate::viewer::Viewer;
2469
2470    fn viewer(viewer_id: &str, client: &str) -> Arc<Viewer> {
2471        Arc::new(Viewer {
2472            id: format!("s1:{client}").into(),
2473            stream_id: "s1".into(),
2474            viewer_id: viewer_id.to_owned(),
2475            name: "Anonymous Cheetah".to_owned(),
2476            color: "#F87171".to_owned(),
2477            identity_issuer: None,
2478            identity_subject: None,
2479            avatar_url: None,
2480            cursor: None,
2481            client_id: Some(client.to_owned().into()),
2482        })
2483    }
2484
2485    #[test]
2486    fn tabs_of_one_person_are_one_person() {
2487        // Two connections, one human — the wheel belongs to the human.
2488        let viewers = vec![viewer("max", "conn-a"), viewer("max", "conn-b")];
2489        assert_eq!(people_present(&viewers), vec!["max"]);
2490    }
2491
2492    #[test]
2493    fn distinct_people_are_counted_separately_and_sorted() {
2494        let viewers = vec![
2495            viewer("zoe", "conn-c"),
2496            viewer("max", "conn-a"),
2497            viewer("max", "conn-b"),
2498        ];
2499        assert_eq!(people_present(&viewers), vec!["max", "zoe"]);
2500    }
2501
2502    #[test]
2503    fn a_stream_whose_connections_all_died_has_nobody_present() {
2504        // reconcile_control filters to live rows before asking this, so the
2505        // ghost case arrives here as an empty list: no sole holder, and the
2506        // departed holder's lock is dropped rather than kept alive by a row
2507        // nobody is behind.
2508        assert!(people_present(&[]).is_empty());
2509    }
2510}