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, GetShotsByIds, GetShotsByQuery, Shot, ShotDiscovery, ShotId, ShotKind,
46    ShotQuery, DEFAULT_SHOT_HOLD_DURATION_MS, DEFAULT_SHOT_LIBRARY_ID,
47    DEFAULT_SHOT_ROTATION_SPEED_DEG_S, DEFAULT_SHOT_TRANSLATION_SPEED_CM_S,
48};
49use crate::stream::{Stream, StreamId};
50use crate::timeline::{
51    GetTimelinesByIds, GetTimelinesByQuery, ShotDirection, ShotEntry, ShotEntryMode, Timeline,
52    TimelineId, TimelineQuery,
53};
54use crate::viewer::{GetViewerById, GetViewersByQuery, Viewer, ViewerId, ViewerQuery};
55use crate::StoredCaptureContext;
56
57const MAX_OTIO_IMPORT_BYTES: usize = 10 * 1024 * 1024;
58const MAX_DISCOVERED_SHOTS: usize = 10_000;
59const MAX_COLLECTION_DEPTH: usize = 16;
60const MAX_COLLECTION_NAME_CHARS: usize = 128;
61
62fn require_uuid_v7_collection_ids() -> bool {
63    std::env::var("PULSE_PIXELSTREAM_REQUIRE_UUID_V7_COLLECTION_IDS").is_ok_and(|value| {
64        matches!(
65            value.trim().to_ascii_lowercase().as_str(),
66            "1" | "true" | "yes"
67        )
68    })
69}
70
71fn normalized_timeline_name(name: &str) -> String {
72    name.trim().to_lowercase()
73}
74
75fn normalized_collection_name(name: &str) -> String {
76    name.trim().to_lowercase()
77}
78
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// ─────────────────────────────────────────────────────────────────────────
669// Entry-addressed timeline editing
670//
671// `SetTimeline` replaces the whole `entries` vector, so two operators editing
672// one timeline silently overwrite each other — and timeline editing is not
673// wheel-gated, so that is an ordinary Tuesday, not a race you have to try for.
674// Adding an expected-revision check would only turn the lost update into a
675// failed save. These commands name the entry they act on instead, so
676// concurrent edits to different entries compose and nobody's work disappears.
677// ─────────────────────────────────────────────────────────────────────────
678
679/// Load a timeline for editing, or explain why it cannot be edited.
680fn timeline_for_edit(
681    ctx: &CommandContext,
682    timeline_id: &TimelineId,
683) -> Result<Timeline, CommandError> {
684    ctx.exec_query_first(GetTimelinesByIds {
685        ids: vec![timeline_id.clone()],
686    })?
687    .map(|timeline| timeline.as_ref().clone())
688    .ok_or_else(|| command_error(ctx, "That timeline no longer exists"))
689}
690
691/// Persist an edited timeline, advancing the program revision.
692fn commit_timeline(ctx: &CommandContext, mut timeline: Timeline) -> Result<(), CommandError> {
693    timeline.revision = timeline.next_revision();
694    timeline.normalize_entries();
695    let shots = ctx
696        .exec_query(GetShotsByQuery(ShotQuery::default()))?
697        .into_iter()
698        .map(|shot| shot.as_ref().clone())
699        .collect::<Vec<_>>();
700    timeline.backfill_entry_parameters(&shots);
701    ctx.emit_set(&timeline)?;
702    Ok(())
703}
704
705/// Add one shot to a timeline, optionally at a position rather than the end.
706#[myko_command(TimelineId)]
707pub struct AddShotEntry {
708    pub timeline_id: TimelineId,
709    pub shot_id: String,
710    #[serde(default)]
711    pub direction: ShotDirection,
712    /// Client-minted so a retried submit cannot double-add.
713    pub entry_id: String,
714    /// Zero-based; `None` appends.
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub position: Option<u32>,
717}
718
719impl CommandHandler for AddShotEntry {
720    fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
721        let mut timeline = timeline_for_edit(&ctx, &self.timeline_id)?;
722        // Idempotent: the same entry id twice is one entry.
723        if timeline.entry_position(&self.entry_id).is_some() {
724            return Ok(self.timeline_id);
725        }
726        let shot = ctx
727            .exec_query_first(GetShotsByIds {
728                ids: vec![ShotId::from(self.shot_id.clone())],
729            })?
730            .ok_or_else(|| command_error(&ctx, "That shot no longer exists"))?;
731        timeline.insert_shot_entry(
732            shot.as_ref(),
733            self.direction,
734            self.entry_id,
735            self.position.map(|position| position as usize),
736        );
737        commit_timeline(&ctx, timeline)?;
738        Ok(self.timeline_id)
739    }
740}
741
742/// Remove one entry. Naming the entry means a concurrent add elsewhere in the
743/// timeline survives.
744#[myko_command(TimelineId)]
745pub struct RemoveShotEntry {
746    pub timeline_id: TimelineId,
747    pub entry_id: String,
748}
749
750impl CommandHandler for RemoveShotEntry {
751    fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
752        let mut timeline = timeline_for_edit(&ctx, &self.timeline_id)?;
753        if timeline.entry_position(&self.entry_id).is_none() {
754            // Already gone: someone else removed it, which is the outcome asked for.
755            return Ok(self.timeline_id);
756        }
757        timeline.remove_entry(&self.entry_id);
758        commit_timeline(&ctx, timeline)?;
759        Ok(self.timeline_id)
760    }
761}
762
763/// Move one entry to a position in the program.
764#[myko_command(TimelineId)]
765pub struct MoveShotEntry {
766    pub timeline_id: TimelineId,
767    pub entry_id: String,
768    /// Zero-based target position; clamped to the ends.
769    pub position: u32,
770}
771
772impl CommandHandler for MoveShotEntry {
773    fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
774        let mut timeline = timeline_for_edit(&ctx, &self.timeline_id)?;
775        let Some(current) = timeline.entry_position(&self.entry_id) else {
776            return Err(command_error(
777                &ctx,
778                "That shot is no longer in this timeline",
779            ));
780        };
781        let target = (self.position as usize).min(timeline.entries.len().saturating_sub(1));
782        if current == target {
783            return Ok(self.timeline_id);
784        }
785        timeline.move_entry(&self.entry_id, target);
786        commit_timeline(&ctx, timeline)?;
787        Ok(self.timeline_id)
788    }
789}
790
791/// Set one entry's capture direction.
792#[myko_command(TimelineId)]
793pub struct SetShotEntryDirection {
794    pub timeline_id: TimelineId,
795    pub entry_id: String,
796    pub direction: ShotDirection,
797}
798
799impl CommandHandler for SetShotEntryDirection {
800    fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
801        let mut timeline = timeline_for_edit(&ctx, &self.timeline_id)?;
802        if timeline.entry_position(&self.entry_id).is_none() {
803            return Err(command_error(
804                &ctx,
805                "That shot is no longer in this timeline",
806            ));
807        }
808        timeline.set_entry_direction(&self.entry_id, self.direction);
809        commit_timeline(&ctx, timeline)?;
810        Ok(self.timeline_id)
811    }
812}
813
814/// Delete one timeline and all of its organizational placements without
815/// deleting any reusable shot definitions.
816#[myko_command(TimelineId)]
817pub struct RemoveTimeline {
818    #[serde(alias = "shotListId")]
819    pub timeline_id: TimelineId,
820}
821
822impl CommandHandler for RemoveTimeline {
823    fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
824        let id = self.timeline_id;
825        let current = ctx
826            .exec_query_first(GetTimelinesByIds {
827                ids: vec![id.clone()],
828            })?
829            .ok_or_else(|| command_error(&ctx, format!("Timeline {id} does not exist")))?;
830        for membership in ctx
831            .exec_query(GetCollectionMembershipsByQuery(
832                CollectionMembershipQuery::default(),
833            ))?
834            .into_iter()
835            .filter(|membership| membership.timeline_id == id.to_string())
836        {
837            ctx.emit_del(membership.as_ref())?;
838        }
839        ctx.emit_del(current.as_ref())?;
840        Ok(id)
841    }
842}
843
844/// Create or update a generic organizational bin. Collection names and depth
845/// are user-defined; the server enforces only tree integrity and sibling-name
846/// uniqueness.
847#[myko_command(CollectionId)]
848pub struct SetCollection {
849    pub collection_id: CollectionId,
850    #[serde(default)]
851    pub library_id: String,
852    #[serde(default)]
853    pub parent_id: String,
854    pub name: String,
855    #[serde(default)]
856    pub sort_order: u32,
857    #[serde(default)]
858    pub metadata: HashMap<String, String>,
859}
860
861impl CommandHandler for SetCollection {
862    fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
863        let id = self.collection_id;
864        let library_id = effective_library_id(&self.library_id).to_owned();
865        let name = self.name.trim().to_owned();
866        if name.is_empty() {
867            return Err(command_error(&ctx, "Collection name cannot be empty"));
868        }
869        if name.chars().count() > MAX_COLLECTION_NAME_CHARS {
870            return Err(command_error(
871                &ctx,
872                format!("Collection names are limited to {MAX_COLLECTION_NAME_CHARS} characters"),
873            ));
874        }
875        let mut collections = ctx
876            .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
877            .into_iter()
878            .map(|collection| collection.as_ref().clone())
879            .collect::<Vec<_>>();
880        if !collections.iter().any(|collection| collection.id == id)
881            && !crate::collection::is_uuid_v7(id.as_ref())
882        {
883            if require_uuid_v7_collection_ids() {
884                return Err(command_error(
885                    &ctx,
886                    "New collection IDs must be UUIDv7; existing legacy collections remain editable",
887                ));
888            }
889            eprintln!(
890                "accepted legacy native collection id during UUIDv7 rollout: {}",
891                id.as_ref()
892            );
893        }
894        if collections.iter().any(|collection| {
895            collection.id != id
896                && collection.library_id == library_id
897                && collection.parent_id == self.parent_id
898                && normalized_collection_name(&collection.name) == normalized_collection_name(&name)
899        }) {
900            return Err(command_error(
901                &ctx,
902                format!("A collection named ‘{name}’ already exists here"),
903            ));
904        }
905        if !self.parent_id.trim().is_empty() {
906            let parent = collections
907                .iter()
908                .find(|collection| collection.id.to_string() == self.parent_id)
909                .ok_or_else(|| command_error(&ctx, "Parent collection does not exist"))?;
910            if parent.library_id != library_id {
911                return Err(command_error(
912                    &ctx,
913                    "A collection cannot be moved between libraries",
914                ));
915            }
916            if parent.id == id {
917                return Err(command_error(&ctx, "A collection cannot contain itself"));
918            }
919            let parent_path = resolve_collection_path(&self.parent_id, &collections)
920                .map_err(|error| command_error(&ctx, error))?;
921            if parent_path.len() >= MAX_COLLECTION_DEPTH {
922                return Err(command_error(
923                    &ctx,
924                    format!("Collections are limited to {MAX_COLLECTION_DEPTH} levels"),
925                ));
926            }
927            if parent_path
928                .iter()
929                .any(|segment| segment.collection_id == id.to_string())
930            {
931                return Err(command_error(
932                    &ctx,
933                    "A collection cannot be moved inside one of its descendants",
934                ));
935            }
936        }
937        let collection = Collection {
938            id: id.clone(),
939            library_id,
940            parent_id: self.parent_id,
941            name,
942            sort_order: self.sort_order,
943            metadata: self.metadata,
944        };
945        if let Some(current) = collections.iter_mut().find(|row| row.id == id) {
946            *current = collection.clone();
947        } else {
948            collections.push(collection.clone());
949        }
950        // Validate the resulting row too, including pre-existing corrupt paths.
951        resolve_collection_path(id.as_ref(), &collections)
952            .map_err(|error| command_error(&ctx, error))?;
953        ctx.emit_set(&collection)?;
954        Ok(id)
955    }
956}
957
958/// Remove an empty collection. Memberships directly inside it are removed,
959/// while child collections must be deliberately handled first so a broad tree
960/// cannot disappear from one accidental click.
961#[myko_command(CollectionId)]
962pub struct RemoveCollection {
963    pub collection_id: CollectionId,
964}
965
966impl CommandHandler for RemoveCollection {
967    fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
968        let id = self.collection_id;
969        let current = ctx
970            .exec_query_first(GetCollectionsByIds {
971                ids: vec![id.clone()],
972            })?
973            .ok_or_else(|| command_error(&ctx, format!("Collection {id} does not exist")))?;
974        let has_children = ctx
975            .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
976            .into_iter()
977            .any(|collection| collection.parent_id == id.to_string());
978        if has_children {
979            return Err(command_error(
980                &ctx,
981                "Move or remove child collections before deleting this collection",
982            ));
983        }
984        for membership in ctx
985            .exec_query(GetCollectionMembershipsByQuery(
986                CollectionMembershipQuery::default(),
987            ))?
988            .into_iter()
989            .filter(|membership| membership.collection_id == id.to_string())
990        {
991            ctx.emit_del(membership.as_ref())?;
992        }
993        ctx.emit_del(current.as_ref())?;
994        Ok(id)
995    }
996}
997
998/// Add or remove one many-to-many timeline placement.
999#[myko_command(CollectionMembershipId)]
1000pub struct SetCollectionMembership {
1001    pub collection_id: String,
1002    #[serde(alias = "shotListId")]
1003    pub timeline_id: String,
1004    pub included: bool,
1005    #[serde(default)]
1006    pub sort_order: u32,
1007}
1008
1009impl CommandHandler for SetCollectionMembership {
1010    fn execute(self, ctx: CommandContext) -> Result<CollectionMembershipId, CommandError> {
1011        let id = CollectionMembershipId::from(CollectionMembership::stable_id(
1012            &self.collection_id,
1013            &self.timeline_id,
1014        ));
1015        let existing = ctx.exec_query_first(GetCollectionMembershipsByIds {
1016            ids: vec![id.clone()],
1017        })?;
1018        if !self.included {
1019            if let Some(existing) = existing {
1020                ctx.emit_del(existing.as_ref())?;
1021            }
1022            return Ok(id);
1023        }
1024        let collection = ctx
1025            .exec_query_first(GetCollectionsByIds {
1026                ids: vec![CollectionId::from(self.collection_id.clone())],
1027            })?
1028            .ok_or_else(|| command_error(&ctx, "Collection does not exist"))?;
1029        let timeline = ctx
1030            .exec_query_first(GetTimelinesByIds {
1031                ids: vec![TimelineId::from(self.timeline_id.clone())],
1032            })?
1033            .ok_or_else(|| command_error(&ctx, "Timeline does not exist"))?;
1034        if collection.library_id != timeline.effective_library_id() {
1035            return Err(command_error(
1036                &ctx,
1037                "Collection and timeline belong to different libraries",
1038            ));
1039        }
1040        ctx.emit_set(&CollectionMembership {
1041            id: id.clone(),
1042            collection_id: self.collection_id,
1043            timeline_id: self.timeline_id,
1044            sort_order: self.sort_order,
1045        })?;
1046        Ok(id)
1047    }
1048}
1049
1050/// Atomically import a Pulse-profile OTIO document into the shared definition
1051/// library. Camera execution remains untouched; this command only persists Shot
1052/// and Timeline definitions.
1053#[myko_command(TimelineId)]
1054pub struct ImportTimelineOtio {
1055    pub otio_json: String,
1056    pub sort_order: u32,
1057}
1058
1059impl CommandHandler for ImportTimelineOtio {
1060    fn execute(self, ctx: CommandContext) -> Result<TimelineId, CommandError> {
1061        if self.otio_json.len() > MAX_OTIO_IMPORT_BYTES {
1062            return Err(command_error(
1063                &ctx,
1064                format!(
1065                    "OTIO import exceeds the {} byte limit",
1066                    MAX_OTIO_IMPORT_BYTES
1067                ),
1068            ));
1069        }
1070        let imported = crate::import_timeline_otio_json(&self.otio_json)
1071            .map_err(|error| command_error(&ctx, error.to_string()))?;
1072        if imported.shots.len() > 10_000 {
1073            return Err(command_error(&ctx, "OTIO import contains too many shots"));
1074        }
1075        ensure_unique_timeline_name(
1076            &ctx,
1077            imported.timeline.effective_library_id(),
1078            &imported.timeline.id,
1079            &imported.timeline.name,
1080        )?;
1081
1082        let mut timeline = imported.timeline;
1083        timeline.backfill_entry_parameters(&imported.shots);
1084        for shot in imported.shots {
1085            ctx.emit_set(&shot)?;
1086        }
1087
1088        if let Some(current) = ctx.exec_query_first(GetTimelinesByIds {
1089            ids: vec![timeline.id.clone()],
1090        })? {
1091            timeline.revision = timeline.revision.max(current.next_revision());
1092        }
1093        timeline.sort_order = self.sort_order;
1094        timeline.normalize_entries();
1095        let id = timeline.id.clone();
1096        ctx.emit_set(&timeline)?;
1097        Ok(id)
1098    }
1099}
1100
1101/// Atomically import a generic OTIO `SerializableCollection` subtree, including
1102/// nested bins, reusable timeline placements, timelines, and shot definitions.
1103#[myko_command(CollectionId)]
1104pub struct ImportCollectionOtio {
1105    pub otio_json: String,
1106}
1107
1108impl CommandHandler for ImportCollectionOtio {
1109    fn execute(self, ctx: CommandContext) -> Result<CollectionId, CommandError> {
1110        if self.otio_json.len() > MAX_OTIO_IMPORT_BYTES {
1111            return Err(command_error(
1112                &ctx,
1113                format!(
1114                    "OTIO import exceeds the {} byte limit",
1115                    MAX_OTIO_IMPORT_BYTES
1116                ),
1117            ));
1118        }
1119        let mut imported = crate::import_collection_otio_json(&self.otio_json)
1120            .map_err(|error| command_error(&ctx, error.to_string()))?;
1121        if imported.collections.is_empty() {
1122            return Err(command_error(&ctx, "OTIO collection is empty"));
1123        }
1124        if imported.collections.len() > 10_000
1125            || imported.memberships.len() > 100_000
1126            || imported.timelines.len() > 10_000
1127            || imported.shots.len() > 10_000
1128        {
1129            return Err(command_error(&ctx, "OTIO collection exceeds import limits"));
1130        }
1131        let root_id = imported.collections[0].id.clone();
1132        let imported_collection_ids = imported
1133            .collections
1134            .iter()
1135            .map(|collection| collection.id.to_string())
1136            .collect::<HashSet<_>>();
1137        let existing_collections = ctx
1138            .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
1139            .into_iter()
1140            .map(|collection| collection.as_ref().clone())
1141            .collect::<Vec<_>>();
1142        for collection in &imported.collections {
1143            let duplicate = imported
1144                .collections
1145                .iter()
1146                .chain(
1147                    existing_collections
1148                        .iter()
1149                        .filter(|existing| !imported_collection_ids.contains(existing.id.as_ref())),
1150                )
1151                .any(|candidate| {
1152                    candidate.id != collection.id
1153                        && candidate.library_id == collection.library_id
1154                        && candidate.parent_id == collection.parent_id
1155                        && normalized_collection_name(&candidate.name)
1156                            == normalized_collection_name(&collection.name)
1157                });
1158            if duplicate {
1159                return Err(command_error(
1160                    &ctx,
1161                    format!(
1162                        "A collection named ‘{}’ already exists at the imported location",
1163                        collection.name
1164                    ),
1165                ));
1166            }
1167        }
1168
1169        let imported_timeline_ids = imported
1170            .timelines
1171            .iter()
1172            .map(|timeline| timeline.id.to_string())
1173            .collect::<HashSet<_>>();
1174        let existing_timelines = ctx
1175            .exec_query(GetTimelinesByQuery(TimelineQuery::default()))?
1176            .into_iter()
1177            .map(|timeline| timeline.as_ref().clone())
1178            .collect::<Vec<_>>();
1179        for timeline in &imported.timelines {
1180            let duplicate = imported
1181                .timelines
1182                .iter()
1183                .chain(
1184                    existing_timelines
1185                        .iter()
1186                        .filter(|existing| !imported_timeline_ids.contains(existing.id.as_ref())),
1187                )
1188                .any(|candidate| {
1189                    candidate.id != timeline.id
1190                        && candidate.effective_library_id() == timeline.effective_library_id()
1191                        && normalized_timeline_name(&candidate.name)
1192                            == normalized_timeline_name(&timeline.name)
1193                });
1194            if duplicate {
1195                return Err(command_error(
1196                    &ctx,
1197                    format!("A timeline named ‘{}’ already exists", timeline.name),
1198                ));
1199            }
1200        }
1201
1202        for shot in imported.shots {
1203            ctx.emit_set(&shot)?;
1204        }
1205        for timeline in &mut imported.timelines {
1206            timeline.normalize_entries();
1207            if let Some(current) = existing_timelines
1208                .iter()
1209                .find(|current| current.id == timeline.id)
1210            {
1211                timeline.revision = timeline.revision.max(current.next_revision());
1212            }
1213            ctx.emit_set(timeline)?;
1214        }
1215        for collection in imported.collections {
1216            ctx.emit_set(&collection)?;
1217        }
1218        for membership in imported.memberships {
1219            ctx.emit_set(&membership)?;
1220        }
1221        Ok(root_id)
1222    }
1223}
1224
1225fn command_error(ctx: &CommandContext, message: impl Into<String>) -> CommandError {
1226    CommandError {
1227        tx: ctx.tx().to_string(),
1228        command_id: ctx.command_id.to_string(),
1229        message: message.into(),
1230    }
1231}
1232
1233/// Create, resume, or cancel a durable recorder-controller-owned RecordingJob.
1234#[myko_command(RecordingJobRequestId)]
1235pub struct ControlRecordingJob {
1236    pub streamer_id: String,
1237    #[serde(alias = "runId")]
1238    pub job_id: String,
1239    pub command_id: String,
1240    pub action: RecordingJobAction,
1241    /// Required for Start and frozen for the lifetime of the job.
1242    #[serde(default, skip_serializing_if = "Option::is_none")]
1243    #[ts(type = "unknown")]
1244    pub capture_context: Option<crate::CaptureContext>,
1245    /// Required for Start. The server resolves the authoritative Timeline and
1246    /// snapshots its current revision; clients never author execution provenance.
1247    #[serde(default, alias = "shotListId")]
1248    pub timeline_id: String,
1249    /// Optional organizational placement selected by the client. Start
1250    /// validates the membership and snapshots its root-to-leaf path.
1251    #[serde(default)]
1252    pub collection_id: String,
1253    /// Legacy client-resolved plan. Start ignores this and resolves the
1254    /// authoritative persisted Timeline; resume/cancel do not need a plan.
1255    #[serde(default, alias = "shots", alias = "items")]
1256    pub entries: Vec<ShotEntryPlan>,
1257    /// Start only. When non-empty, record just these timeline entry ids (a
1258    /// single shot, or a subset) instead of the whole timeline. Empty = the
1259    /// whole timeline. The entries still come from the authoritative persisted
1260    /// Timeline — this only narrows which of its entries run — so take
1261    /// numbering, delivery placement, and integrity are unchanged.
1262    #[serde(default)]
1263    pub entry_ids: Vec<String>,
1264    #[serde(default)]
1265    pub preset_duration_ms: u64,
1266    #[serde(default)]
1267    pub translation_speed_cm_s: f32,
1268    #[serde(default)]
1269    pub rotation_speed_deg_s: f32,
1270    #[serde(default)]
1271    pub requested_at_ms: u64,
1272}
1273
1274impl CommandHandler for ControlRecordingJob {
1275    fn execute(self, ctx: CommandContext) -> Result<RecordingJobRequestId, CommandError> {
1276        let (
1277            timeline_id,
1278            timeline_name,
1279            timeline_revision,
1280            collection_id,
1281            collection_path,
1282            entries,
1283        ) = if self.action == RecordingJobAction::StartView {
1284            // A free-camera view capture is a first-class 1-take job: one
1285            // synthetic open-ended entry, no timeline, no mount. The client
1286            // supplies only a display label (entries[0].name = the active
1287            // rig/preset or "freefly"); everything else is authored here so a
1288            // stale client cannot smuggle plan fields into execution.
1289            if self.job_id.trim().is_empty() {
1290                return Err(command_error(
1291                    &ctx,
1292                    "StartView requires a stable RecordingJob id",
1293                ));
1294            }
1295            let label = self
1296                .entries
1297                .first()
1298                .map(|entry| entry.name.trim().to_owned())
1299                .filter(|name| !name.is_empty())
1300                .unwrap_or_else(|| "freefly".to_owned());
1301            let entry = ShotEntryPlan {
1302                entry_id: "view".to_owned(),
1303                shot_id: String::new(),
1304                name: label,
1305                shot_index: None,
1306                kind: crate::ShotKind::Static,
1307                target_name: String::new(),
1308                translation_speed_cm_s: 0.0,
1309                rotation_speed_deg_s: 0.0,
1310                hold_duration_ms: 0,
1311                travel_duration_ms: 0,
1312                direction: crate::ShotDirection::default(),
1313                next_take_number: 1,
1314                open_ended: true,
1315            };
1316            (
1317                String::new(),
1318                String::new(),
1319                0,
1320                String::new(),
1321                Vec::new(),
1322                vec![entry],
1323            )
1324        } else if self.action == RecordingJobAction::Start {
1325            if self.capture_context.is_none() {
1326                return Err(command_error(
1327                    &ctx,
1328                    "Start requires editorial capture context",
1329                ));
1330            }
1331            if self.timeline_id.trim().is_empty() {
1332                return Err(CommandError {
1333                    tx: ctx.tx().to_string(),
1334                    command_id: ctx.command_id.to_string(),
1335                    message: "Start requires a persistent Timeline id".to_owned(),
1336                });
1337            }
1338            if self.job_id.trim().is_empty() {
1339                return Err(CommandError {
1340                    tx: ctx.tx().to_string(),
1341                    command_id: ctx.command_id.to_string(),
1342                    message: "Start requires a stable RecordingJob id".to_owned(),
1343                });
1344            }
1345            let timeline_id = TimelineId::from(self.timeline_id);
1346            let current = ctx
1347                .exec_query_first(GetTimelinesByIds {
1348                    ids: vec![timeline_id.clone()],
1349                })?
1350                .ok_or_else(|| CommandError {
1351                    tx: ctx.tx().to_string(),
1352                    command_id: ctx.command_id.to_string(),
1353                    message: format!("Timeline {timeline_id} does not exist"),
1354                })?;
1355            let mut timeline = (*current).clone();
1356            let (collection_id, collection_path) = if self.collection_id.trim().is_empty() {
1357                (String::new(), Vec::new())
1358            } else {
1359                let membership_id = CollectionMembershipId::from(CollectionMembership::stable_id(
1360                    &self.collection_id,
1361                    timeline_id.as_ref(),
1362                ));
1363                if ctx
1364                    .exec_query_first(GetCollectionMembershipsByIds {
1365                        ids: vec![membership_id],
1366                    })?
1367                    .is_none()
1368                {
1369                    return Err(command_error(
1370                        &ctx,
1371                        "Timeline is not assigned to the selected collection",
1372                    ));
1373                }
1374                let collections = ctx
1375                    .exec_query(GetCollectionsByQuery(CollectionQuery::default()))?
1376                    .into_iter()
1377                    .map(|collection| collection.as_ref().clone())
1378                    .collect::<Vec<_>>();
1379                let path = resolve_collection_path(&self.collection_id, &collections)
1380                    .map_err(|error| command_error(&ctx, error))?;
1381                (self.collection_id.clone(), path)
1382            };
1383            let revision = timeline.revision;
1384            let name = timeline.name.clone();
1385            let shot_rows = ctx
1386                .exec_query(GetShotsByQuery(ShotQuery::default()))?
1387                .into_iter()
1388                .map(|shot| shot.as_ref().clone())
1389                .collect::<Vec<_>>();
1390            let timeline_changed = timeline.backfill_entry_parameters(&shot_rows);
1391            let mut entries = crate::resolve_timeline_plans(&timeline, &shot_rows)
1392                .map_err(|error| command_error(&ctx, error))?;
1393            if entries.is_empty() {
1394                return Err(command_error(&ctx, "Timeline has no shot entries"));
1395            }
1396            // Optional single-shot / subset scope: keep only the requested
1397            // entry ids, preserving timeline order. Unknown ids are an error so
1398            // a stale UI can't silently record the wrong thing.
1399            if !self.entry_ids.is_empty() {
1400                let wanted: std::collections::HashSet<&str> =
1401                    self.entry_ids.iter().map(String::as_str).collect();
1402                entries.retain(|entry| wanted.contains(entry.entry_id.as_str()));
1403                if entries.len() != self.entry_ids.len() {
1404                    return Err(command_error(
1405                        &ctx,
1406                        "one or more requested shot entries are not in this timeline",
1407                    ));
1408                }
1409            }
1410            let prior_jobs = ctx
1411                .exec_query(GetRecordingJobsByQuery(RecordingJobQuery::default()))?
1412                .into_iter()
1413                .map(|job| job.as_ref().clone())
1414                .collect::<Vec<_>>();
1415            for entry in &mut entries {
1416                entry.next_take_number = crate::next_take_number_for_entry(
1417                    &prior_jobs,
1418                    &collection_id,
1419                    timeline_id.as_ref(),
1420                    entry,
1421                );
1422            }
1423            if timeline_changed {
1424                ctx.emit_set(&timeline)?;
1425            }
1426            (
1427                timeline_id.to_string(),
1428                name,
1429                revision,
1430                collection_id,
1431                collection_path,
1432                entries,
1433            )
1434        } else {
1435            (
1436                String::new(),
1437                String::new(),
1438                0,
1439                String::new(),
1440                Vec::new(),
1441                self.entries,
1442            )
1443        };
1444        let id: RecordingJobRequestId = self.streamer_id.clone().into();
1445        let capture_context = self.capture_context.map(StoredCaptureContext::from);
1446        ctx.emit_set(&RecordingJobRequest {
1447            id: id.clone(),
1448            streamer_id: self.streamer_id.clone(),
1449            job_id: self.job_id.clone(),
1450            command_id: self.command_id,
1451            action: self.action.clone(),
1452            capture_context: capture_context.clone(),
1453            timeline_id: timeline_id.clone(),
1454            timeline_name: timeline_name.clone(),
1455            timeline_revision,
1456            collection_id: collection_id.clone(),
1457            collection_path: collection_path.clone(),
1458            entries: entries.clone(),
1459            preset_duration_ms: self.preset_duration_ms,
1460            translation_speed_cm_s: self.translation_speed_cm_s,
1461            rotation_speed_deg_s: self.rotation_speed_deg_s,
1462            requested_at_ms: self.requested_at_ms,
1463        })?;
1464        if self.action == RecordingJobAction::Start {
1465            ctx.emit_set(&RecordingJob {
1466                id: RecordingJobId::from(self.job_id.clone()),
1467                job_id: self.job_id,
1468                streamer_id: self.streamer_id,
1469                capture_context,
1470                timeline_id,
1471                timeline_name,
1472                timeline_revision,
1473                collection_id,
1474                collection_path,
1475                phase: RecordingJobPhase::Idle,
1476                pause_requested: false,
1477                entries,
1478                takes: Vec::new(),
1479                error: String::new(),
1480                started_at_ms: 0,
1481                updated_at_ms: self.requested_at_ms,
1482                elapsed_ms: 0,
1483                estimated_total_ms: 0,
1484                estimated_remaining_ms: 0,
1485            })?;
1486        }
1487        Ok(id)
1488    }
1489}
1490
1491/// Recorder-side authoritative RecordingJob progress report.
1492#[myko_command(RecordingJobStatusId)]
1493pub struct SetRecordingJobStatus {
1494    pub streamer_id: String,
1495    #[serde(alias = "runId")]
1496    pub job_id: String,
1497    #[serde(default, skip_serializing_if = "Option::is_none")]
1498    #[ts(type = "unknown")]
1499    pub capture_context: Option<crate::CaptureContext>,
1500    #[serde(default, alias = "shotListId")]
1501    pub timeline_id: String,
1502    #[serde(default, alias = "shotListName")]
1503    pub timeline_name: String,
1504    #[serde(default, alias = "timelineVersion", alias = "shotListVersion")]
1505    pub timeline_revision: u32,
1506    #[serde(default)]
1507    pub collection_id: String,
1508    #[serde(default)]
1509    pub collection_path: Vec<crate::CollectionPathSegment>,
1510    pub phase: RecordingJobPhase,
1511    #[serde(default)]
1512    pub pause_requested: bool,
1513    #[serde(default, alias = "shots", alias = "items")]
1514    pub entries: Vec<ShotEntryPlan>,
1515    #[serde(default)]
1516    pub index: u32,
1517    #[serde(default)]
1518    pub completed: u32,
1519    #[serde(default)]
1520    pub error: String,
1521    #[serde(default)]
1522    pub updated_at_ms: u64,
1523    #[serde(default)]
1524    pub elapsed_ms: u64,
1525    #[serde(default)]
1526    pub estimated_total_ms: u64,
1527    #[serde(default)]
1528    pub estimated_remaining_ms: u64,
1529    #[serde(default)]
1530    pub takes: Vec<Take>,
1531    #[serde(default)]
1532    pub started_at_ms: u64,
1533}
1534
1535/// True when `candidate` says nothing `stored` did not already say, apart from
1536/// the clock.
1537///
1538/// The event store is append-only, so a publisher that republishes unchanged
1539/// state on a timer grows it forever. Comparing with the stored
1540/// `updated_at_ms` substituted in isolates exactly that case: every real
1541/// field still participates, so any actual change writes normally.
1542fn recording_job_status_is_heartbeat_only(
1543    candidate: &RecordingJobStatus,
1544    stored: &RecordingJobStatus,
1545) -> bool {
1546    let mut probe = candidate.clone();
1547    probe.updated_at_ms = stored.updated_at_ms;
1548    probe == *stored
1549}
1550
1551/// Same test for the target summaries the capture bridge mirrors on a poll.
1552fn target_summary_is_heartbeat_only(
1553    candidate: &crate::FrameCaptureTargetSummary,
1554    stored: &crate::FrameCaptureTargetSummary,
1555) -> bool {
1556    let mut probe = candidate.clone();
1557    probe.updated_at_ms = stored.updated_at_ms;
1558    probe == *stored
1559}
1560
1561impl CommandHandler for SetRecordingJobStatus {
1562    fn execute(self, ctx: CommandContext) -> Result<RecordingJobStatusId, CommandError> {
1563        let id: RecordingJobStatusId = self.streamer_id.clone().into();
1564        let mut takes = self.takes;
1565        let job_id = self.job_id.clone();
1566        let existing_job = if !job_id.trim().is_empty() {
1567            ctx.exec_query_first(GetRecordingJobsByIds {
1568                ids: vec![RecordingJobId::from(job_id.clone())],
1569            })?
1570        } else {
1571            None
1572        };
1573        if let Some(existing) = &existing_job {
1574            for take in &mut takes {
1575                let accepted = existing.takes.iter().any(|saved| {
1576                    saved.take_id == take.take_id
1577                        && saved.capture.as_ref().is_some_and(|capture| {
1578                            capture.creative_status == CreativeStatus::Accepted
1579                        })
1580                });
1581                if accepted {
1582                    if let Some(capture) = &mut take.capture {
1583                        capture.creative_status = CreativeStatus::Accepted;
1584                    }
1585                }
1586            }
1587        }
1588        let mirror_present = existing_job.is_some();
1589        let capture_context = self
1590            .capture_context
1591            .map(StoredCaptureContext::from)
1592            .or_else(|| existing_job.and_then(|job| job.capture_context.clone()));
1593        let status = RecordingJobStatus {
1594            id: id.clone(),
1595            streamer_id: self.streamer_id,
1596            job_id: job_id.clone(),
1597            capture_context,
1598            timeline_id: self.timeline_id,
1599            timeline_name: self.timeline_name,
1600            timeline_revision: self.timeline_revision,
1601            collection_id: self.collection_id,
1602            collection_path: self.collection_path,
1603            phase: self.phase,
1604            pause_requested: self.pause_requested,
1605            entries: self.entries,
1606            index: self.index,
1607            completed: self.completed,
1608            error: self.error,
1609            updated_at_ms: self.updated_at_ms,
1610            elapsed_ms: self.elapsed_ms,
1611            estimated_total_ms: self.estimated_total_ms,
1612            estimated_remaining_ms: self.estimated_remaining_ms,
1613            takes,
1614            started_at_ms: self.started_at_ms,
1615        };
1616        // A recorder heartbeat carrying no new state must not become an event.
1617        // The store is append-only, so republishing an unchanged job every few
1618        // seconds per streamer is pure growth: it was 99.9% of all events
1619        // written and ~190 MB/day, with nothing but `updated_at_ms` differing
1620        // between consecutive rows. Suppressing it also makes the field mean
1621        // what every reader already assumes — when this job last *changed* —
1622        // rather than when the recorder last spoke.
1623        //
1624        // This lives here rather than in the recorder because the cell owns
1625        // the durable store: any publisher, now or later, gets the same floor.
1626        let unchanged = ctx
1627            .exec_query_first(GetRecordingJobStatussByIds {
1628                ids: vec![id.clone()],
1629            })?
1630            .is_some_and(|existing| recording_job_status_is_heartbeat_only(&status, &existing));
1631        // An unchanged status still writes when the paired RecordingJob mirror
1632        // is missing, so a half-written pair always converges.
1633        if unchanged && (job_id.trim().is_empty() || mirror_present) {
1634            return Ok(id);
1635        }
1636        ctx.emit_set(&status)?;
1637        if !job_id.trim().is_empty() {
1638            ctx.emit_set(&RecordingJob {
1639                id: RecordingJobId::from(job_id.clone()),
1640                job_id,
1641                streamer_id: status.streamer_id.clone(),
1642                capture_context: status.capture_context.clone(),
1643                timeline_id: status.timeline_id.clone(),
1644                timeline_name: status.timeline_name.clone(),
1645                timeline_revision: status.timeline_revision,
1646                collection_id: status.collection_id.clone(),
1647                collection_path: status.collection_path.clone(),
1648                phase: status.phase.clone(),
1649                pause_requested: status.pause_requested,
1650                entries: status.entries.clone(),
1651                takes: status.takes.clone(),
1652                error: status.error.clone(),
1653                started_at_ms: status.started_at_ms,
1654                updated_at_ms: status.updated_at_ms,
1655                elapsed_ms: status.elapsed_ms,
1656                estimated_total_ms: status.estimated_total_ms,
1657                estimated_remaining_ms: status.estimated_remaining_ms,
1658            })?;
1659        }
1660        Ok(id)
1661    }
1662}
1663
1664/// Mark a delivered Take's Capture as operator-accepted without mutating its
1665/// integrity or QC evidence. Later recorder status republishes preserve review.
1666#[myko_command(RecordingJobId)]
1667pub struct AcceptTake {
1668    pub job_id: String,
1669    pub take_id: String,
1670}
1671
1672impl CommandHandler for AcceptTake {
1673    fn execute(self, ctx: CommandContext) -> Result<RecordingJobId, CommandError> {
1674        let id = RecordingJobId::from(self.job_id);
1675        let current = ctx
1676            .exec_query_first(GetRecordingJobsByIds {
1677                ids: vec![id.clone()],
1678            })?
1679            .ok_or_else(|| command_error(&ctx, format!("Recording job {id} does not exist")))?;
1680        let mut job = current.as_ref().clone();
1681        let take = job
1682            .takes
1683            .iter_mut()
1684            .find(|take| take.take_id == self.take_id)
1685            .ok_or_else(|| command_error(&ctx, "Take does not exist"))?;
1686        let Some(capture) = &mut take.capture else {
1687            return Err(command_error(&ctx, "Take has no Capture to accept"));
1688        };
1689        if take.state != TakeState::Completed
1690            || capture.delivery_status != DeliveryStatus::Delivered
1691        {
1692            return Err(command_error(
1693                &ctx,
1694                "Only delivered Captures can be accepted",
1695            ));
1696        }
1697        capture.creative_status = CreativeStatus::Accepted;
1698        ctx.emit_set(&job)?;
1699        Ok(id)
1700    }
1701}
1702
1703/// Upsert the durable global camera settings. The client sends this whenever the
1704/// operator changes a control, and reads them back (`GetCamPrefsByQuery`) on connect
1705/// to restore the panel + re-apply to the freshly-launched pawn. Full struct each
1706/// time (last-write-wins) — simplest and the payload is tiny.
1707#[myko_command(CamPrefId)]
1708pub struct SetCamPref {
1709    pub focal: f32,
1710    pub aperture: f32,
1711    pub focus_method: String,
1712    pub focus_dist: f32,
1713    pub base_speed: f32,
1714    pub look_scale: f32,
1715    pub invert: bool,
1716    pub glide: bool,
1717    pub glide_secs: f32,
1718    pub motion_blur: f32,
1719    pub rail_speed: f32,
1720}
1721
1722impl CommandHandler for SetCamPref {
1723    fn execute(self, ctx: CommandContext) -> Result<CamPrefId, CommandError> {
1724        let id = CamPref::row_id();
1725        let pref = CamPref {
1726            id: id.clone(),
1727            focal: self.focal,
1728            aperture: self.aperture,
1729            focus_method: self.focus_method,
1730            focus_dist: self.focus_dist,
1731            base_speed: self.base_speed,
1732            look_scale: self.look_scale,
1733            invert: self.invert,
1734            glide: self.glide,
1735            glide_secs: self.glide_secs,
1736            motion_blur: self.motion_blur,
1737            rail_speed: self.rail_speed,
1738        };
1739        ctx.emit_set(&pref)?;
1740        Ok(id)
1741    }
1742}
1743
1744/// Persist one stream's authored Home camera pose and lens. The command only
1745/// updates server state; moving the live camera remains an explicit client action.
1746#[myko_command(CameraHomeId)]
1747pub struct SetCameraHome {
1748    pub stream_id: String,
1749    pub location_x: f32,
1750    pub location_y: f32,
1751    pub location_z: f32,
1752    pub rotation_pitch: f32,
1753    pub rotation_yaw: f32,
1754    pub rotation_roll: f32,
1755    pub focal_length: f32,
1756}
1757
1758impl CommandHandler for SetCameraHome {
1759    fn execute(self, ctx: CommandContext) -> Result<CameraHomeId, CommandError> {
1760        if self.stream_id.trim().is_empty() {
1761            return Err(command_error(&ctx, "Camera Home requires a stream id"));
1762        }
1763        let values = [
1764            self.location_x,
1765            self.location_y,
1766            self.location_z,
1767            self.rotation_pitch,
1768            self.rotation_yaw,
1769            self.rotation_roll,
1770            self.focal_length,
1771        ];
1772        if values.iter().any(|value| !value.is_finite()) {
1773            return Err(command_error(&ctx, "Camera Home values must be finite"));
1774        }
1775        if !(1.0..=1000.0).contains(&self.focal_length) {
1776            return Err(command_error(
1777                &ctx,
1778                "Camera Home focal length must be between 1 and 1000 mm",
1779            ));
1780        }
1781
1782        let id = CameraHome::row_id(&self.stream_id);
1783        ctx.emit_set(&CameraHome {
1784            id: id.clone(),
1785            stream_id: self.stream_id,
1786            location_x: self.location_x,
1787            location_y: self.location_y,
1788            location_z: self.location_z,
1789            rotation_pitch: self.rotation_pitch,
1790            rotation_yaw: self.rotation_yaw,
1791            rotation_roll: self.rotation_roll,
1792            focal_length: self.focal_length,
1793        })?;
1794        Ok(id)
1795    }
1796}
1797
1798/// Set a stream's friendly DISPLAY name (from the cluster def's `previs.stream_name`,
1799/// written on previs launch, keyed by the StreamerId). Upsert / last-write-wins. The
1800/// StreamerId is unchanged, so duplicate names never collide.
1801#[myko_command(StreamId)]
1802pub struct SetStreamName {
1803    pub stream_id: StreamId,
1804    pub name: String,
1805}
1806
1807impl CommandHandler for SetStreamName {
1808    fn execute(self, ctx: CommandContext) -> Result<StreamId, CommandError> {
1809        let id = self.stream_id.clone();
1810        let stream = Stream {
1811            id: id.clone(),
1812            name: self.name,
1813        };
1814        ctx.emit_set(&stream)?;
1815        Ok(id)
1816    }
1817}
1818
1819/// Client-side record trigger: set the intent to record `streamer_id` (active on/off) +
1820/// camera metadata. The recorder service watches `RecordingRequest`, chooses the active
1821/// rig (rail/crane) or preset/shot as its human artifact label, and starts/stops the
1822/// server-side no-transcode capture. Keyed by streamer_id (one active recording per stream).
1823#[myko_command(RecordingRequestId)]
1824pub struct SetRecording {
1825    pub streamer_id: String,
1826    pub active: bool,
1827    #[serde(default)]
1828    pub capture_kind: crate::RecordingKind,
1829    #[serde(default)]
1830    pub rig: String,
1831    #[serde(default)]
1832    pub preset: String,
1833    #[serde(default)]
1834    pub stream_name: String,
1835    #[serde(default, skip_serializing_if = "Option::is_none")]
1836    pub travel_direction: Option<ShotDirection>,
1837    #[serde(default, skip_serializing_if = "Option::is_none")]
1838    pub shot_index: Option<u32>,
1839    #[serde(default)]
1840    pub take_number: u32,
1841    #[serde(default, alias = "clipId", alias = "cueId")]
1842    pub entry_id: String,
1843    #[serde(default, alias = "shotListId")]
1844    pub timeline_id: String,
1845    #[serde(default, alias = "shotListName")]
1846    pub timeline_name: String,
1847    #[serde(default, alias = "shotListVersion")]
1848    pub timeline_revision: u32,
1849    #[serde(default)]
1850    pub collection_path: Vec<crate::CollectionPathSegment>,
1851    #[serde(default)]
1852    pub requested_at_ms: u64,
1853}
1854impl CommandHandler for SetRecording {
1855    fn execute(self, ctx: CommandContext) -> Result<RecordingRequestId, CommandError> {
1856        let id: RecordingRequestId = self.streamer_id.clone().into();
1857        let req = RecordingRequest {
1858            id: id.clone(),
1859            streamer_id: self.streamer_id,
1860            active: self.active,
1861            capture_kind: self.capture_kind,
1862            rig: self.rig,
1863            preset: self.preset,
1864            stream_name: self.stream_name,
1865            travel_direction: self.travel_direction,
1866            shot_index: self.shot_index,
1867            take_number: self.take_number,
1868            entry_id: self.entry_id,
1869            timeline_id: self.timeline_id,
1870            timeline_name: self.timeline_name,
1871            timeline_revision: self.timeline_revision,
1872            collection_path: self.collection_path,
1873            requested_at_ms: self.requested_at_ms,
1874        };
1875        ctx.emit_set(&req)?;
1876        Ok(id)
1877    }
1878}
1879
1880/// Recorder-side status report for capture, finalization, and NAS delivery readiness.
1881#[myko_command(RecordingStatusId)]
1882pub struct SetRecordingStatus {
1883    pub streamer_id: String,
1884    pub state: RecordingState,
1885    #[serde(default)]
1886    pub file_name: String,
1887    /// Absolute NAS path, so the UI can name where the take is instead of
1888    /// inferring a location from a file name.
1889    #[serde(default)]
1890    pub nas_path: String,
1891    /// Dropbox path from the team root, once the destination is named.
1892    #[serde(default)]
1893    pub dropbox_path: String,
1894    #[serde(default)]
1895    pub error: String,
1896    #[serde(default)]
1897    pub started_at_ms: u64,
1898}
1899impl CommandHandler for SetRecordingStatus {
1900    fn execute(self, ctx: CommandContext) -> Result<RecordingStatusId, CommandError> {
1901        let id: RecordingStatusId = self.streamer_id.clone().into();
1902        let st = RecordingStatus {
1903            id: id.clone(),
1904            streamer_id: self.streamer_id,
1905            state: self.state,
1906            file_name: self.file_name,
1907            nas_path: self.nas_path,
1908            dropbox_path: self.dropbox_path,
1909            error: self.error,
1910            started_at_ms: self.started_at_ms,
1911        };
1912        ctx.emit_set(&st)?;
1913        Ok(id)
1914    }
1915}
1916
1917/// Register (or refresh) my presence on a stream.
1918#[myko_command(ViewerId)]
1919pub struct JoinStream {
1920    pub stream_id: StreamId,
1921    pub viewer_id: String,
1922    pub name: String,
1923    pub color: String,
1924    #[serde(default, skip_serializing_if = "Option::is_none")]
1925    pub identity_issuer: Option<String>,
1926    #[serde(default, skip_serializing_if = "Option::is_none")]
1927    pub identity_subject: Option<String>,
1928    #[serde(default, skip_serializing_if = "Option::is_none")]
1929    pub avatar_url: Option<String>,
1930}
1931
1932impl CommandHandler for JoinStream {
1933    fn execute(self, ctx: CommandContext) -> Result<ViewerId, CommandError> {
1934        let client_id = ctx
1935            .client_id()
1936            .map(|id| myko::entities::client::ClientId::from(id.to_owned()));
1937        // One presence row per CONNECTION: a browser's tabs share the persisted
1938        // viewer_id but must each be their own presence + drive unit. Fall back to
1939        // viewer_id only if there's somehow no client id (not a live WS connection).
1940        let conn = client_id
1941            .as_ref()
1942            .map(|c| c.to_string())
1943            .unwrap_or_else(|| self.viewer_id.clone());
1944        let id = Viewer::row_id(&self.stream_id, &conn);
1945        let stream_id = self.stream_id.clone();
1946        let viewer = Viewer {
1947            id: id.clone(),
1948            stream_id: self.stream_id,
1949            viewer_id: self.viewer_id,
1950            name: self.name,
1951            color: self.color,
1952            identity_issuer: self.identity_issuer,
1953            identity_subject: self.identity_subject,
1954            avatar_url: self.avatar_url,
1955            cursor: None,
1956            // Explicit: a command's emit_set is not auto-stamped with the client id.
1957            client_id,
1958        };
1959        ctx.emit_set(&viewer)?;
1960        // Reconcile the lock against live presence: clear a departed holder's
1961        // stale lock, and if this leaves exactly one viewer, they drive.
1962        reconcile_control(&ctx, &stream_id)?;
1963        Ok(id)
1964    }
1965}
1966
1967/// Remove this connection's presence from a stream during in-page navigation.
1968///
1969/// A full websocket disconnect is already cascade-cleaned by myko, but moving
1970/// between streams keeps that socket alive. Addressing the same connection-keyed
1971/// row as [`JoinStream`] prevents ghost viewers and immediately reconciles the old
1972/// room's wheel.
1973#[myko_command]
1974pub struct LeaveStream {
1975    pub stream_id: StreamId,
1976    pub viewer_id: String,
1977}
1978
1979impl CommandHandler for LeaveStream {
1980    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
1981        let conn = ctx
1982            .client_id()
1983            .map(|client_id| client_id.to_string())
1984            .unwrap_or(self.viewer_id);
1985        let id = Viewer::row_id(&self.stream_id, &conn);
1986        if let Some(viewer) = ctx.exec_report(GetViewerById { id })? {
1987            ctx.emit_del(&*viewer)?;
1988        }
1989        reconcile_control(&ctx, &self.stream_id)
1990    }
1991}
1992
1993/// Move my cursor (frequent, cheap).
1994#[myko_command]
1995pub struct UpdateCursor {
1996    pub stream_id: StreamId,
1997    pub viewer_id: String,
1998    pub cursor: Option<(f32, f32)>,
1999}
2000
2001impl CommandHandler for UpdateCursor {
2002    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
2003        // Address my own per-connection presence row (see JoinStream).
2004        let conn = ctx
2005            .client_id()
2006            .map(|c| c.to_string())
2007            .unwrap_or_else(|| self.viewer_id.clone());
2008        let id = Viewer::row_id(&self.stream_id, &conn);
2009        if let Some(viewer) = ctx.exec_report(GetViewerById { id })? {
2010            let updated = Viewer {
2011                cursor: self.cursor,
2012                ..(*viewer).clone()
2013            };
2014            ctx.emit_set(&updated)?;
2015        }
2016        Ok(())
2017    }
2018}
2019
2020/// Take the wheel (SET overwrites any current holder — takeover).
2021#[myko_command(ControlLockId)]
2022pub struct AcquireControl {
2023    pub stream_id: StreamId,
2024    pub viewer_id: String,
2025}
2026
2027impl CommandHandler for AcquireControl {
2028    fn execute(self, ctx: CommandContext) -> Result<ControlLockId, CommandError> {
2029        let id = ControlLock::row_id(&self.stream_id);
2030        let lock = ControlLock {
2031            id: id.clone(),
2032            stream_id: self.stream_id,
2033            viewer_id: self.viewer_id,
2034            client_id: ctx
2035                .client_id()
2036                .map(|id| myko::entities::client::ClientId::from(id.to_owned())),
2037        };
2038        ctx.emit_set(&lock)?;
2039        Ok(id)
2040    }
2041}
2042
2043/// Release the wheel — held by a PERSON (viewer_id), so any of their tabs may
2044/// release it (the request carries the caller's viewer_id).
2045#[myko_command]
2046pub struct ReleaseControl {
2047    pub stream_id: StreamId,
2048    pub viewer_id: String,
2049}
2050
2051impl CommandHandler for ReleaseControl {
2052    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
2053        let id = ControlLock::row_id(&self.stream_id);
2054        if let Some(lock) = ctx.exec_report(GetControlLockById { id })? {
2055            if lock.viewer_id == self.viewer_id {
2056                ctx.emit_del(&*lock)?;
2057            }
2058        }
2059        Ok(())
2060    }
2061}
2062
2063/// Server-internal: reconcile a stream's control lock against live presence.
2064/// Idempotent and cheap, safe to run on every join/leave.
2065///
2066/// The wheel belongs to a PERSON (viewer_id) — a person's tabs share it (browser
2067/// focus ensures only the active tab actually sends input), so a lock is kept alive
2068/// as long as ANY connection of the holder is present.
2069///
2070/// 1. **Clear a departed holder's lock.** `ControlLock` is not reliably
2071///    cascade-deleted on disconnect, so a holder who has fully left (no connection
2072///    of theirs remains) can linger; delete it. App-level backstop for the cascade.
2073/// 2. **Sole person auto-holds.** If exactly one person is present (any number of
2074///    their tabs) and doesn't already hold a valid lock, hand them the wheel.
2075/// The people behind a stream's live connections, deduplicated, sorted.
2076///
2077/// Multiple tabs of one person are one person: the wheel belongs to a human,
2078/// not to a socket.
2079fn people_present(viewers: &[Arc<Viewer>]) -> Vec<&str> {
2080    let mut people: Vec<&str> = viewers.iter().map(|v| v.viewer_id.as_str()).collect();
2081    people.sort_unstable();
2082    people.dedup();
2083    people
2084}
2085
2086/// Whether this presence row still has a browser behind it.
2087///
2088/// Liveness is a property of the connection, never of the stored row. A row
2089/// carries no proof of life: a tab that dies without a disconnect leaves it
2090/// behind, replay restores it at boot, and it then looks exactly like presence.
2091/// myko's `ClientStatus` answers from the live connection registry instead —
2092/// the same source its `ConnectedClients` view uses.
2093///
2094/// A row with no client id cannot be tied to a connection at all, so it cannot
2095/// be shown to be alive.
2096fn viewer_is_live(ctx: &CommandContext, viewer: &Viewer) -> Result<bool, CommandError> {
2097    let Some(client_id) = viewer.client_id.clone() else {
2098        return Ok(false);
2099    };
2100    Ok(ctx.exec_report(ClientStatus { client_id })?.online)
2101}
2102
2103fn reconcile_control(ctx: &CommandContext, stream_id: &StreamId) -> Result<(), CommandError> {
2104    let stored: Vec<Arc<Viewer>> = ctx.exec_query(GetViewersByQuery(ViewerQuery {
2105        stream_id: Some(IdFilter::Eq(stream_id.clone())),
2106        ..Default::default()
2107    }))?;
2108    // Every decision below is about who is *here*, so ask the connections, not
2109    // the store. Without this a ghost keeps a departed holder's lock alive and
2110    // can even be handed the wheel as the "sole" person — the recurring
2111    // "Anonymous ... has the wheel".
2112    let mut viewers = Vec::with_capacity(stored.len());
2113    for viewer in stored {
2114        if viewer_is_live(ctx, &viewer)? {
2115            viewers.push(viewer);
2116        }
2117    }
2118    let id = ControlLock::row_id(stream_id);
2119
2120    // (1) Drop a lock whose holding PERSON has left (no connection of theirs remains).
2121    if let Some(lock) = ctx.exec_report(GetControlLockById { id: id.clone() })? {
2122        let holder_present = viewers.iter().any(|v| v.viewer_id == lock.viewer_id);
2123        if !holder_present {
2124            ctx.emit_del(&*lock)?;
2125        }
2126    }
2127
2128    // (2) Sole PERSON auto-holds (multiple tabs of one person count as one).
2129    let people = people_present(&viewers);
2130    if let [sole_vid] = people.as_slice() {
2131        let held_by_sole = ctx
2132            .exec_report(GetControlLockById { id: id.clone() })?
2133            .as_deref()
2134            .is_some_and(|l| l.viewer_id.as_str() == *sole_vid);
2135        if !held_by_sole {
2136            // Carry the lock's client_id on any one of that person's connections.
2137            let conn = viewers
2138                .iter()
2139                .find(|v| v.viewer_id.as_str() == *sole_vid)
2140                .expect("present");
2141            ctx.emit_set(&ControlLock {
2142                id,
2143                stream_id: stream_id.clone(),
2144                viewer_id: (*sole_vid).to_string(),
2145                client_id: conn.client_id.clone(),
2146            })?;
2147        }
2148    }
2149    Ok(())
2150}
2151
2152/// Server-internal command emitted by the leave-reassign saga (server crate):
2153/// reconcile a stream's control lock against live presence. Exposed so the saga can emit it.
2154#[myko_command]
2155pub struct AutoAssignControl {
2156    pub stream_id: StreamId,
2157}
2158
2159impl CommandHandler for AutoAssignControl {
2160    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
2161        reconcile_control(&ctx, &self.stream_id)
2162    }
2163}
2164
2165/// Submit a synchronized nDisplay frame capture.
2166///
2167/// The client supplies ONLY typed editorial context and a typed target
2168/// selection; it never talks to Pulse Cluster and never constructs artifact
2169/// paths. The off-browser bridge picks this request up, submits it with the
2170/// cluster credential, and mirrors authoritative status + typed receipts back
2171/// as `FrameCaptureStatus`.
2172#[myko_command(FrameCaptureRequestId)]
2173pub struct SubmitFrameCapture {
2174    pub streamer_id: String,
2175    pub capture_id: String,
2176    pub command_id: String,
2177    #[ts(type = "unknown")]
2178    pub capture_context: crate::CaptureContext,
2179    pub target: crate::FrameCaptureTarget,
2180    /// Explicit operator override; see FrameCaptureRequest::force.
2181    #[serde(default)]
2182    pub force: bool,
2183    #[serde(default)]
2184    pub requested_at_ms: u64,
2185}
2186
2187impl CommandHandler for SubmitFrameCapture {
2188    fn execute(self, ctx: CommandContext) -> Result<FrameCaptureRequestId, CommandError> {
2189        if self.streamer_id.trim().is_empty() {
2190            return Err(command_error(&ctx, "Frame capture requires a stream"));
2191        }
2192        if self.capture_id.trim().is_empty() {
2193            return Err(command_error(&ctx, "Frame capture requires a capture id"));
2194        }
2195        if self.target.cluster_name.trim().is_empty() {
2196            return Err(command_error(
2197                &ctx,
2198                "Frame capture requires an explicit target cluster",
2199            ));
2200        }
2201        // Editorial identity must be explicit — never inferred from whatever
2202        // collection happens to be browsed. The typed context carries it.
2203        let id: FrameCaptureRequestId = self.streamer_id.clone().into();
2204        ctx.emit_set(&crate::FrameCaptureRequest {
2205            id: id.clone(),
2206            streamer_id: self.streamer_id,
2207            capture_id: self.capture_id,
2208            command_id: self.command_id,
2209            capture_context: self.capture_context.into(),
2210            target: self.target,
2211            force: self.force,
2212            requested_at_ms: self.requested_at_ms,
2213        })?;
2214        Ok(id)
2215    }
2216}
2217
2218/// Bridge-owned authoritative status for one stream's frame capture.
2219#[myko_command(FrameCaptureStatusId)]
2220pub struct SetFrameCaptureStatus {
2221    pub streamer_id: String,
2222    #[serde(default)]
2223    pub capture_id: String,
2224    pub phase: crate::FrameCapturePhase,
2225    #[serde(default, skip_serializing_if = "Option::is_none")]
2226    #[ts(type = "unknown")]
2227    pub capture_context: Option<crate::CaptureContext>,
2228    #[serde(default)]
2229    pub target: crate::FrameCaptureTarget,
2230    #[serde(default)]
2231    pub observed_generation: String,
2232    #[serde(default)]
2233    pub receipts: Vec<crate::FrameCaptureReceipt>,
2234    #[serde(default)]
2235    pub error: String,
2236    #[serde(default)]
2237    pub forceable: bool,
2238    #[serde(default)]
2239    pub updated_at_ms: u64,
2240}
2241
2242impl CommandHandler for SetFrameCaptureStatus {
2243    fn execute(self, ctx: CommandContext) -> Result<FrameCaptureStatusId, CommandError> {
2244        let id: FrameCaptureStatusId = self.streamer_id.clone().into();
2245        ctx.emit_set(&crate::FrameCaptureStatus {
2246            id: id.clone(),
2247            streamer_id: self.streamer_id,
2248            capture_id: self.capture_id,
2249            phase: self.phase,
2250            capture_context: self.capture_context.map(StoredCaptureContext::from),
2251            target: self.target,
2252            observed_generation: self.observed_generation,
2253            receipts: self.receipts,
2254            error: self.error,
2255            forceable: self.forceable,
2256            updated_at_ms: self.updated_at_ms,
2257        })?;
2258        Ok(id)
2259    }
2260}
2261
2262/// Bridge-mirrored summary of a cluster the operator may capture on. The
2263/// browser never queries Pulse Cluster, so these summaries are how the UI
2264/// learns valid targets at all.
2265#[myko_command(FrameCaptureTargetSummaryId)]
2266pub struct SetFrameCaptureTargetSummary {
2267    pub cluster_name: String,
2268    #[serde(default)]
2269    pub generation: String,
2270    #[serde(default)]
2271    pub capturable: bool,
2272    #[serde(default)]
2273    pub status: String,
2274    #[serde(default)]
2275    pub updated_at_ms: u64,
2276}
2277
2278impl CommandHandler for SetFrameCaptureTargetSummary {
2279    fn execute(self, ctx: CommandContext) -> Result<FrameCaptureTargetSummaryId, CommandError> {
2280        if self.cluster_name.trim().is_empty() {
2281            return Err(command_error(
2282                &ctx,
2283                "Target summary requires a cluster name",
2284            ));
2285        }
2286        let id: FrameCaptureTargetSummaryId = self.cluster_name.clone().into();
2287        let summary = crate::FrameCaptureTargetSummary {
2288            id: id.clone(),
2289            cluster_name: self.cluster_name,
2290            generation: self.generation,
2291            capturable: self.capturable,
2292            status: self.status,
2293            updated_at_ms: self.updated_at_ms,
2294        };
2295        // The bridge mirrors every target on a poll loop, so most of these
2296        // carry the same generation, status and capturability as the row
2297        // already stored — only the clock moved. An append-only store must not
2298        // record that: it was ~60k events/day for ~18 real changes.
2299        let unchanged = ctx
2300            .exec_query_first(GetFrameCaptureTargetSummarysByIds {
2301                ids: vec![id.clone()],
2302            })?
2303            .is_some_and(|existing| target_summary_is_heartbeat_only(&summary, &existing));
2304        if unchanged {
2305            return Ok(id);
2306        }
2307        ctx.emit_set(&summary)?;
2308        Ok(id)
2309    }
2310}
2311
2312#[cfg(test)]
2313mod discovery_tests {
2314    use super::*;
2315    use crate::shot::DEFAULT_SHOT_LIBRARY_ID;
2316
2317    fn tuned_legacy_shot() -> Shot {
2318        Shot {
2319            id: ShotId::from("render-11:moving:Floor Dolly"),
2320            library_id: String::new(),
2321            streamer_id: "render-11".to_owned(),
2322            name: "Floor Dolly".to_owned(),
2323            kind: ShotKind::Moving,
2324            target_name: "Floor Dolly".to_owned(),
2325            translation_speed_cm_s: 17.0,
2326            rotation_speed_deg_s: 3.5,
2327            hold_duration_ms: 8_000,
2328            travel_duration_ms: 41_000,
2329            default_entry_mode: ShotEntryMode::Both,
2330            shot_index: 9,
2331        }
2332    }
2333
2334    fn legacy_timeline(id: &str, streamer_id: &str, name: &str, shot_id: &str) -> Timeline {
2335        Timeline {
2336            id: TimelineId::from(id),
2337            library_id: String::new(),
2338            streamer_id: streamer_id.to_owned(),
2339            name: name.to_owned(),
2340            revision: 0,
2341            entries: if shot_id.is_empty() {
2342                Vec::new()
2343            } else {
2344                vec![ShotEntry {
2345                    shot_id: shot_id.to_owned(),
2346                    direction: ShotEntryMode::Forward,
2347                    ..Default::default()
2348                }]
2349            },
2350            sort_order: 0,
2351        }
2352    }
2353
2354    #[test]
2355    fn discovery_creates_only_missing_targets_and_preserves_tuned_legacy_rows() {
2356        let existing = vec![tuned_legacy_shot()];
2357        let discovered = vec![
2358            ShotDiscovery {
2359                name: "Floor Dolly".to_owned(),
2360                kind: ShotKind::Moving,
2361                target_name: "Floor Dolly".to_owned(),
2362            },
2363            ShotDiscovery {
2364                name: "Hero Push Forward".to_owned(),
2365                kind: ShotKind::Moving,
2366                target_name: "Hero Push Forward".to_owned(),
2367            },
2368            ShotDiscovery {
2369                name: "Hero Push Forward".to_owned(),
2370                kind: ShotKind::Moving,
2371                target_name: "Hero Push Forward".to_owned(),
2372            },
2373        ];
2374
2375        let created = discovered_shots_to_create(DEFAULT_SHOT_LIBRARY_ID, discovered, &existing);
2376        assert_eq!(created.len(), 1);
2377        assert_eq!(created[0].name, "Hero Push Forward");
2378        assert_eq!(created[0].shot_index, 10);
2379        assert_eq!(
2380            created[0].translation_speed_cm_s,
2381            DEFAULT_SHOT_TRANSLATION_SPEED_CM_S
2382        );
2383        assert_eq!(
2384            created[0].rotation_speed_deg_s,
2385            DEFAULT_SHOT_ROTATION_SPEED_DEG_S
2386        );
2387        assert_eq!(created[0].default_entry_mode, ShotEntryMode::Forward);
2388        assert_eq!(existing[0].translation_speed_cm_s, 17.0);
2389        assert_eq!(existing[0].rotation_speed_deg_s, 3.5);
2390    }
2391
2392    #[test]
2393    fn discovery_reconciles_live_legacy_timeline_duplicates_without_losing_clips() {
2394        let render_shot = tuned_legacy_shot();
2395        let mut studio_shot = tuned_legacy_shot();
2396        studio_shot.id = ShotId::from("Studio A:moving:Floor Dolly");
2397        studio_shot.streamer_id = "Studio A".to_owned();
2398        let timelines = vec![
2399            legacy_timeline(
2400                "render-11:timeline:default",
2401                "render-11",
2402                "Default shot list",
2403                render_shot.id.as_ref(),
2404            ),
2405            legacy_timeline(
2406                "Studio A:timeline:default",
2407                "Studio A",
2408                "Default timeline",
2409                studio_shot.id.as_ref(),
2410            ),
2411            legacy_timeline(
2412                "timeline-old",
2413                "render-11",
2414                "Supercut",
2415                render_shot.id.as_ref(),
2416            ),
2417            Timeline {
2418                id: TimelineId::from("timeline-shared"),
2419                library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
2420                streamer_id: "Studio A".to_owned(),
2421                name: " superCUT ".to_owned(),
2422                revision: 2,
2423                entries: vec![ShotEntry {
2424                    shot_id: studio_shot.id.to_string(),
2425                    direction: ShotEntryMode::Reverse,
2426                    ..Default::default()
2427                }],
2428                sort_order: 1,
2429            },
2430        ];
2431
2432        let result = reconcile_timelines(
2433            DEFAULT_SHOT_LIBRARY_ID,
2434            &[render_shot, studio_shot],
2435            timelines,
2436        );
2437
2438        assert_eq!(result.upserts.len(), 2);
2439        assert_eq!(result.deletes.len(), 3);
2440        let default = result
2441            .upserts
2442            .iter()
2443            .find(|timeline| timeline.id.to_string() == Timeline::shared_legacy_default_id())
2444            .expect("canonical shared default");
2445        assert_eq!(default.library_id, DEFAULT_SHOT_LIBRARY_ID);
2446        assert!(default.streamer_id.is_empty());
2447        assert_eq!(default.name, "Migrated timeline");
2448        assert_eq!(default.entries.len(), 1);
2449        assert_eq!(default.entries[0].shot_id, "Studio A:moving:Floor Dolly");
2450        assert_eq!(result.id_migrations.len(), 3);
2451        assert_eq!(
2452            result.id_migrations.get("render-11:timeline:default"),
2453            Some(&Timeline::shared_legacy_default_id())
2454        );
2455
2456        let supercut = result
2457            .upserts
2458            .iter()
2459            .find(|timeline| timeline.id.as_ref() == "timeline-shared")
2460            .expect("explicit shared timeline wins");
2461        assert_eq!(supercut.name, "superCUT");
2462        assert_eq!(supercut.revision, 2);
2463        assert_eq!(supercut.entries.len(), 2);
2464        assert_eq!(supercut.entries[0].direction, ShotEntryMode::Reverse);
2465        assert_eq!(supercut.entries[1].direction, ShotEntryMode::Forward);
2466        assert!(supercut
2467            .entries
2468            .iter()
2469            .all(|entry| !entry.entry_id.is_empty()));
2470    }
2471
2472    #[test]
2473    fn reconciliation_is_idempotent_for_canonical_timelines() {
2474        let shot = tuned_legacy_shot();
2475        let mut timeline = Timeline {
2476            id: TimelineId::from("shared:timeline:supercut"),
2477            library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
2478            streamer_id: String::new(),
2479            name: "Supercut".to_owned(),
2480            revision: 1,
2481            entries: vec![ShotEntry {
2482                shot_id: shot.id.to_string(),
2483                direction: ShotEntryMode::Forward,
2484                ..Default::default()
2485            }],
2486            sort_order: 0,
2487        };
2488        timeline.normalize_entries();
2489        assert!(timeline.backfill_entry_parameters(std::slice::from_ref(&shot)));
2490        assert!(!timeline.backfill_entry_parameters(std::slice::from_ref(&shot)));
2491        let result = reconcile_timelines(DEFAULT_SHOT_LIBRARY_ID, &[shot], vec![timeline]);
2492        assert!(result.upserts.is_empty());
2493        assert!(result.deletes.is_empty());
2494    }
2495
2496    #[test]
2497    fn reconciliation_snapshots_only_missing_legacy_clip_parameters() {
2498        let shot = tuned_legacy_shot();
2499        let mut timeline =
2500            legacy_timeline("shared:timeline:supercut", "", "Supercut", shot.id.as_ref());
2501        timeline.library_id = DEFAULT_SHOT_LIBRARY_ID.to_owned();
2502        timeline.entries[0].translation_speed_cm_s = Some(4.5);
2503
2504        let result = reconcile_timelines(
2505            DEFAULT_SHOT_LIBRARY_ID,
2506            std::slice::from_ref(&shot),
2507            vec![timeline],
2508        );
2509        assert_eq!(result.upserts.len(), 1);
2510        let entry = &result.upserts[0].entries[0];
2511        assert_eq!(entry.translation_speed_cm_s, Some(4.5));
2512        assert_eq!(entry.rotation_speed_deg_s, Some(shot.rotation_speed_deg_s));
2513        assert_eq!(entry.hold_duration_ms, Some(shot.hold_duration_ms));
2514        assert_eq!(
2515            entry.travel_duration_ms,
2516            Some(shot.effective_travel_duration_ms())
2517        );
2518        assert_eq!(entry.shot_index, Some(shot.shot_index));
2519    }
2520}
2521
2522#[cfg(test)]
2523mod heartbeat_suppression_tests {
2524    use super::*;
2525    use crate::recording_job_status::RecordingJobStatus;
2526
2527    fn status() -> RecordingJobStatus {
2528        RecordingJobStatus {
2529            id: "render-13".into(),
2530            streamer_id: "render-13".to_owned(),
2531            job_id: "job-1".to_owned(),
2532            capture_context: None,
2533            timeline_id: "supercut".to_owned(),
2534            timeline_name: "Supercut".to_owned(),
2535            timeline_revision: 1,
2536            collection_id: String::new(),
2537            collection_path: Vec::new(),
2538            phase: RecordingJobPhase::Traveling,
2539            pause_requested: false,
2540            entries: Vec::new(),
2541            index: 0,
2542            completed: 0,
2543            error: String::new(),
2544            updated_at_ms: 1_000,
2545            elapsed_ms: 5_000,
2546            estimated_total_ms: 10_000,
2547            estimated_remaining_ms: 5_000,
2548            takes: Vec::new(),
2549            started_at_ms: 500,
2550        }
2551    }
2552
2553    fn summary() -> crate::FrameCaptureTargetSummary {
2554        crate::FrameCaptureTargetSummary {
2555            id: "0of12_rx11".into(),
2556            cluster_name: "0of12_rx11".to_owned(),
2557            generation: "3350".to_owned(),
2558            capturable: false,
2559            status: "stopped".to_owned(),
2560            updated_at_ms: 1_000,
2561        }
2562    }
2563
2564    #[test]
2565    fn a_newer_clock_alone_is_not_a_change() {
2566        let stored = status();
2567        let mut republished = stored.clone();
2568        republished.updated_at_ms = 9_999;
2569        assert!(recording_job_status_is_heartbeat_only(
2570            &republished,
2571            &stored
2572        ));
2573    }
2574
2575    #[test]
2576    fn real_progress_still_writes() {
2577        let stored = status();
2578        for mutate in [
2579            (|s: &mut RecordingJobStatus| s.phase = RecordingJobPhase::Complete)
2580                as fn(&mut RecordingJobStatus),
2581            |s: &mut RecordingJobStatus| s.elapsed_ms = 6_000,
2582            |s: &mut RecordingJobStatus| s.completed = 1,
2583            |s: &mut RecordingJobStatus| s.error = "disk full".to_owned(),
2584            |s: &mut RecordingJobStatus| s.pause_requested = true,
2585            |s: &mut RecordingJobStatus| s.estimated_remaining_ms = 4_000,
2586        ] {
2587            let mut candidate = stored.clone();
2588            candidate.updated_at_ms = 9_999;
2589            mutate(&mut candidate);
2590            assert!(
2591                !recording_job_status_is_heartbeat_only(&candidate, &stored),
2592                "a changed field must still be written"
2593            );
2594        }
2595    }
2596
2597    #[test]
2598    fn target_summaries_follow_the_same_rule() {
2599        let stored = summary();
2600        let mut polled = stored.clone();
2601        polled.updated_at_ms = 9_999;
2602        assert!(target_summary_is_heartbeat_only(&polled, &stored));
2603
2604        for mutate in [
2605            (|s: &mut crate::FrameCaptureTargetSummary| s.capturable = true)
2606                as fn(&mut crate::FrameCaptureTargetSummary),
2607            |s: &mut crate::FrameCaptureTargetSummary| s.generation = "3351".to_owned(),
2608            |s: &mut crate::FrameCaptureTargetSummary| s.status = "running".to_owned(),
2609        ] {
2610            let mut candidate = stored.clone();
2611            candidate.updated_at_ms = 9_999;
2612            mutate(&mut candidate);
2613            assert!(!target_summary_is_heartbeat_only(&candidate, &stored));
2614        }
2615    }
2616}
2617
2618#[cfg(test)]
2619mod presence_tests {
2620    use std::sync::Arc;
2621
2622    use super::people_present;
2623    use crate::viewer::Viewer;
2624
2625    fn viewer(viewer_id: &str, client: &str) -> Arc<Viewer> {
2626        Arc::new(Viewer {
2627            id: format!("s1:{client}").into(),
2628            stream_id: "s1".into(),
2629            viewer_id: viewer_id.to_owned(),
2630            name: "Anonymous Cheetah".to_owned(),
2631            color: "#F87171".to_owned(),
2632            identity_issuer: None,
2633            identity_subject: None,
2634            avatar_url: None,
2635            cursor: None,
2636            client_id: Some(client.to_owned().into()),
2637        })
2638    }
2639
2640    #[test]
2641    fn tabs_of_one_person_are_one_person() {
2642        // Two connections, one human — the wheel belongs to the human.
2643        let viewers = vec![viewer("max", "conn-a"), viewer("max", "conn-b")];
2644        assert_eq!(people_present(&viewers), vec!["max"]);
2645    }
2646
2647    #[test]
2648    fn distinct_people_are_counted_separately_and_sorted() {
2649        let viewers = vec![
2650            viewer("zoe", "conn-c"),
2651            viewer("max", "conn-a"),
2652            viewer("max", "conn-b"),
2653        ];
2654        assert_eq!(people_present(&viewers), vec!["max", "zoe"]);
2655    }
2656
2657    #[test]
2658    fn a_stream_whose_connections_all_died_has_nobody_present() {
2659        // reconcile_control filters to live rows before asking this, so the
2660        // ghost case arrives here as an empty list: no sole holder, and the
2661        // departed holder's lock is dropped rather than kept alive by a row
2662        // nobody is behind.
2663        assert!(people_present(&[]).is_empty());
2664    }
2665}