Skip to main content

pulse_pixelstream_types/
commands.rs

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