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