#include "editor/ActionTimelineEditor.h" #include #include namespace eve::editor { namespace { constexpr std::string_view kReplaceOperation = "action.timeline.replace"; EditorResult editorError(EditorStatus status, std::string rule, std::string message) { return EditorResult::error(status, RuleId(std::move(rule)), std::move(message)); } std::string commonMessage(const Status& status, std::string fallback) { if (!status.diagnostics().empty() && !status.diagnostics().front().message().empty()) return status.diagnostics().front().message(); return fallback; } action::ActionTrack* findTrack(action::ActionTimeline& timeline, const LogicalId& id) { auto found = std::find_if(timeline.tracks.begin(), timeline.tracks.end(), [&](const action::ActionTrack& track) { return track.id == id; }); return found == timeline.tracks.end() ? nullptr : &*found; } const action::ActionTrack* findTrack(const action::ActionTimeline& timeline, const LogicalId& id) { auto found = std::find_if(timeline.tracks.begin(), timeline.tracks.end(), [&](const action::ActionTrack& track) { return track.id == id; }); return found == timeline.tracks.end() ? nullptr : &*found; } void sortTrack(action::ActionTrack& track) { std::stable_sort(track.notifies.begin(), track.notifies.end(), [](const auto& left, const auto& right) { return left.time == right.time ? left.id.format() < right.id.format() : left.time < right.time; }); std::stable_sort(track.states.begin(), track.states.end(), [](const auto& left, const auto& right) { return left.start == right.start ? left.id.format() < right.id.format() : left.start < right.start; }); } EditorValue replacementPayload(const std::string& json) { EditorValue::Object payload; payload["json"] = json; return EditorValue(std::move(payload)); } const std::string* jsonPayload(const EditorValue& value) { const auto* object = value.getIf(); if (!object) return nullptr; const auto found = object->find("json"); return found == object->end() ? nullptr : found->second.getIf(); } } // namespace ActionTimelineTarget::ActionTimelineTarget(std::string targetId, action::ActionTimeline timeline) : targetId_(std::move(targetId)), timeline_(std::move(timeline)) {} EditorResult ActionTimelineTarget::applyDomainOperation(const DomainOperation& operation) { if (operation.target.value() != targetId_) return editorError(EditorStatus::Rejected, "editor.action.timeline.target-mismatch", "Action timeline operation targets another document"); if (operation.type != kReplaceOperation) return editorError(EditorStatus::Unsupported, "editor.action.timeline.operation-unsupported", "Action timeline target only accepts canonical replace operations"); const std::string* json = jsonPayload(operation.payload); if (!json) return editorError(EditorStatus::Rejected, "editor.action.timeline.payload-invalid", "Action timeline replacement requires a JSON string"); auto value = Value::fromJson(*json); if (!value) return editorError(EditorStatus::Rejected, "editor.action.timeline.json-invalid", commonMessage(value.status(), "Action timeline JSON is invalid")); auto decoded = action::ActionTimeline::fromValue(value.value()); if (!decoded) return editorError(EditorStatus::Rejected, "editor.action.timeline.asset-invalid", commonMessage(decoded.status(), "Action timeline asset is invalid")); timeline_ = std::move(decoded).takeValue(); ++revision_; return EditorResult::applied(); } std::unique_ptr ActionTimelineTarget::cloneDomainState() const { auto clone = std::make_unique(targetId_, timeline_); clone->revision_ = revision_; return clone; } EditorResult ActionTimelineTarget::commitDomainState(std::unique_ptr candidate) { auto* typed = dynamic_cast(candidate.get()); if (!typed || typed->targetId_ != targetId_) return editorError(EditorStatus::Conflict, "editor.action.timeline.candidate-mismatch", "Action timeline candidate belongs to another target"); auto valid = typed->timeline_.validate(); if (!valid) return editorError(EditorStatus::Rejected, "editor.action.timeline.candidate-invalid", commonMessage(valid.status(), "Action timeline candidate is invalid")); timeline_ = std::move(typed->timeline_); ++revision_; return EditorResult::applied(); } ActionTimelineEditor::ActionTimelineEditor(std::string targetId, action::ActionTimeline timeline) : target_(std::move(targetId), std::move(timeline)), authority_(&target_), transactions_(&authority_) {} EditorResult ActionTimelineEditor::rejected(std::string rule, std::string message) { return editorError(EditorStatus::Rejected, std::move(rule), std::move(message)); } EditorResult ActionTimelineEditor::configureWorkspace(EditorWorkspace& workspace) const { EditorWorkspace candidate = workspace; struct Panel { const char* id; const char* title; const char* region; int order; }; constexpr Panel panels[] = { {"action.assets", "Actions", "left", 100}, {"action.preview", "Action Preview", "center", 100}, {"action.inspector", "Action Inspector", "right", 100}, {"action.timeline", "Action Timeline", "bottom", 100}, }; for (const auto& panel : panels) { if (!candidate.registerPanel(panel.id, panel.title, panel.region, panel.order) || !candidate.setPanelCapability(panel.id, "action.timeline") || !candidate.setPanelContext(panel.id, "timeline")) return rejected("editor.action.timeline.workspace-conflict", "Could not install the action editor workspace composition"); } if (!candidate.activatePanel("action.timeline")) return rejected("editor.action.timeline.workspace-activate-failed", "Could not activate the action timeline panel"); workspace = std::move(candidate); return EditorResult::applied(); } EditorResult ActionTimelineEditor::commit(action::ActionTimeline candidate, std::string label, std::string mergeKey) { auto valid = candidate.validate(); if (!valid) return rejected("editor.action.timeline.candidate-invalid", commonMessage(valid.status(), "Action timeline edit is invalid")); auto beforeValue = target_.timeline().toValue(); auto afterValue = candidate.toValue(); if (!beforeValue || !afterValue) return rejected("editor.action.timeline.encode-failed", "Could not encode the action timeline edit"); auto beforeJson = beforeValue.value().toJson(); auto afterJson = afterValue.value().toJson(); if (!beforeJson || !afterJson) return rejected("editor.action.timeline.encode-failed", "Could not serialize the action timeline edit"); if (beforeJson.value() == afterJson.value()) { EditorResult result; result.status = EditorStatus::NoOp; return result; } const std::string operationMergeKey = mergeKey; TransactionSpec transaction; transaction.id = TransactionId("action.timeline.tx." + std::to_string(++transactionSequence_)); transaction.label = std::move(label); transaction.target = TargetId(target_.targetId()); transaction.baseRevision = target_.revision(); transaction.mergeKey = std::move(mergeKey); auto begun = transactions_.begin(std::move(transaction)); if (!begun.accepted()) return editorError(begun.status, "editor.action.timeline.begin-failed", "Could not begin action edit"); DomainOperation operation; operation.type = std::string(kReplaceOperation); operation.inverseType = std::string(kReplaceOperation); operation.target = TargetId(target_.targetId()); operation.payload = replacementPayload(afterJson.value()); operation.inverse = replacementPayload(beforeJson.value()); operation.hasInverse = true; operation.affectedObjects.push_back({operation.target, target_.timeline().actionId.format(), target_.revision()}); operation.affectedProperties.push_back("timeline"); operation.mergeKey = operationMergeKey; auto appended = transactions_.append(std::move(operation)); if (!appended.accepted()) { auto discarded = transactions_.discard(); (void)discarded; return appended; } auto committed = transactions_.commit(); if (!committed.accepted()) return editorError(committed.status, "editor.action.timeline.commit-failed", "Action edit was rejected"); return EditorResult::applied(); } EditorResult ActionTimelineEditor::addTrack(action::ActionTrack track) { action::ActionTimeline candidate = target_.timeline(); candidate.tracks.push_back(std::move(track)); return commit(std::move(candidate), "Add action track", "action.timeline.track.add"); } EditorResult ActionTimelineEditor::addNotify(const LogicalId& trackId, action::ActionNotify notify) { action::ActionTimeline candidate = target_.timeline(); auto* track = findTrack(candidate, trackId); if (!track) return rejected("editor.action.timeline.track-not-found", "Action track was not found"); if (track->locked) return rejected("editor.action.timeline.track-locked", "Action track is locked"); track->notifies.push_back(std::move(notify)); sortTrack(*track); return commit(std::move(candidate), "Add action notify", "action.timeline.notify.add"); } EditorResult ActionTimelineEditor::addState(const LogicalId& trackId, action::ActionNotifyState state) { action::ActionTimeline candidate = target_.timeline(); auto* track = findTrack(candidate, trackId); if (!track) return rejected("editor.action.timeline.track-not-found", "Action track was not found"); if (track->locked) return rejected("editor.action.timeline.track-locked", "Action track is locked"); track->states.push_back(std::move(state)); sortTrack(*track); return commit(std::move(candidate), "Add action notify state", "action.timeline.state.add"); } EditorResult ActionTimelineEditor::moveItem(const LogicalId& itemId, Duration delta) { action::ActionTimeline candidate = target_.timeline(); for (auto& track : candidate.tracks) { for (auto& notify : track.notifies) { if (notify.id != itemId) continue; if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked"); auto moved = notify.time.tryAdd(delta); if (!moved) return rejected("editor.action.timeline.time-overflow", "Notify time overflowed"); notify.time = std::move(moved).takeValue(); sortTrack(track); return commit(std::move(candidate), "Move action notify", "action.timeline.item.move"); } for (auto& state : track.states) { if (state.id != itemId) continue; if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked"); auto start = state.start.tryAdd(delta); auto end = state.end.tryAdd(delta); if (!start || !end) return rejected("editor.action.timeline.time-overflow", "Notify-state time overflowed"); state.start = std::move(start).takeValue(); state.end = std::move(end).takeValue(); sortTrack(track); return commit(std::move(candidate), "Move action notify state", "action.timeline.item.move"); } } return rejected("editor.action.timeline.item-not-found", "Timeline item was not found"); } EditorResult ActionTimelineEditor::resizeState(const LogicalId& itemId, Duration start, Duration end) { if (start < Duration::zero() || end < start || end > target_.timeline().duration) return rejected("editor.action.timeline.state-range", "Notify-state range is outside the timeline"); action::ActionTimeline candidate = target_.timeline(); for (auto& track : candidate.tracks) { auto found = std::find_if(track.states.begin(), track.states.end(), [&](const auto& value) { return value.id == itemId; }); if (found == track.states.end()) continue; if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked"); found->start = start; found->end = end; sortTrack(track); return commit(std::move(candidate), "Resize action notify state", "action.timeline.state.resize"); } return rejected("editor.action.timeline.state-not-found", "Notify state was not found"); } EditorResult ActionTimelineEditor::updateItem(const LogicalId& itemId, LogicalId type, Value::Object payload) { if (!type.isValid()) return rejected("editor.action.timeline.type-invalid", "Timeline item type is invalid"); action::ActionTimeline candidate = target_.timeline(); for (auto& track : candidate.tracks) { for (auto& notify : track.notifies) { if (notify.id != itemId) continue; if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked"); notify.type = std::move(type); notify.payload = std::move(payload); return commit(std::move(candidate), "Edit action notify", "action.timeline.item.properties"); } for (auto& state : track.states) { if (state.id != itemId) continue; if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked"); state.type = std::move(type); state.payload = std::move(payload); return commit(std::move(candidate), "Edit action notify state", "action.timeline.item.properties"); } } return rejected("editor.action.timeline.item-not-found", "Timeline item was not found"); } EditorResult ActionTimelineEditor::editItem(const LogicalId& itemId, Duration start, Duration end, LogicalId type, Value::Object payload) { if (!type.isValid()) return rejected("editor.action.timeline.type-invalid", "Timeline item type is invalid"); if (start < Duration::zero() || end < start || end > target_.timeline().duration) return rejected("editor.action.timeline.item-range", "Timeline item range is outside the timeline"); action::ActionTimeline candidate = target_.timeline(); for (auto& track : candidate.tracks) { for (auto& notify : track.notifies) { if (notify.id != itemId) continue; if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked"); if (start != end) return rejected("editor.action.timeline.notify-range", "Instant notify start and end must match"); notify.time = start; notify.type = std::move(type); notify.payload = std::move(payload); sortTrack(track); return commit(std::move(candidate), "Edit action notify", "action.timeline.item.edit"); } for (auto& state : track.states) { if (state.id != itemId) continue; if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked"); state.start = start; state.end = end; state.type = std::move(type); state.payload = std::move(payload); sortTrack(track); return commit(std::move(candidate), "Edit action notify state", "action.timeline.item.edit"); } } return rejected("editor.action.timeline.item-not-found", "Timeline item was not found"); } EditorResult ActionTimelineEditor::removeItem(const LogicalId& itemId) { action::ActionTimeline candidate = target_.timeline(); for (auto& track : candidate.tracks) { const auto notify = std::find_if(track.notifies.begin(), track.notifies.end(), [&](const auto& value) { return value.id == itemId; }); if (notify != track.notifies.end()) { if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked"); track.notifies.erase(notify); selection_.erase(itemId.format()); return commit(std::move(candidate), "Delete action notify", "action.timeline.item.delete"); } const auto state = std::find_if(track.states.begin(), track.states.end(), [&](const auto& value) { return value.id == itemId; }); if (state != track.states.end()) { if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked"); track.states.erase(state); selection_.erase(itemId.format()); return commit(std::move(candidate), "Delete action notify state", "action.timeline.item.delete"); } } return rejected("editor.action.timeline.item-not-found", "Timeline item was not found"); } EditorResult ActionTimelineEditor::setTrackMuted(const LogicalId& trackId, bool muted) { action::ActionTimeline candidate = target_.timeline(); auto* track = findTrack(candidate, trackId); if (!track) return rejected("editor.action.timeline.track-not-found", "Action track was not found"); track->muted = muted; return commit(std::move(candidate), muted ? "Mute action track" : "Unmute action track", "action.timeline.track.mute"); } EditorResult ActionTimelineEditor::boxSelect(Duration start, Duration end) { if (start < Duration::zero() || end < start || end > target_.timeline().duration) return EditorResult::error(EditorStatus::Rejected, RuleId("editor.action.timeline.selection-range"), "Selection range is outside the timeline"); selection_.clear(); for (const auto& track : target_.timeline().tracks) { for (const auto& notify : track.notifies) if (notify.time >= start && notify.time <= end) selection_.insert(notify.id.format()); for (const auto& state : track.states) if (state.end >= start && state.start <= end) selection_.insert(state.id.format()); } return EditorResult::applied(selection_.size()); } EditorResult ActionTimelineEditor::selectItem(const LogicalId& itemId, bool additive) { bool found = false; for (const auto& track : target_.timeline().tracks) { found = found || std::any_of(track.notifies.begin(), track.notifies.end(), [&](const auto& item) { return item.id == itemId; }); found = found || std::any_of(track.states.begin(), track.states.end(), [&](const auto& item) { return item.id == itemId; }); } if (!found) return rejected("editor.action.timeline.item-not-found", "Timeline item was not found"); if (!additive) selection_.clear(); selection_.insert(itemId.format()); return EditorResult::applied(); } std::vector ActionTimelineEditor::selectedItemIds() const { std::vector result; result.reserve(selection_.size()); for (const auto& value : selection_) { auto parsed = LogicalId::parse(value); if (parsed) result.push_back(std::move(*parsed)); } return result; } EditorResult ActionTimelineEditor::copySelection() { clipboard_.clear(); for (const auto& track : target_.timeline().tracks) { for (const auto& notify : track.notifies) if (selection_.contains(notify.id.format())) clipboard_.push_back({track.id, false, notify, {}}); for (const auto& state : track.states) if (selection_.contains(state.id.format())) clipboard_.push_back({track.id, true, {}, state}); } return EditorResult::applied(clipboard_.size()); } LogicalId ActionTimelineEditor::copiedId(const LogicalId& source) { const std::string name = std::string(source.name()) + ".copy." + std::to_string(++copySequence_); auto id = LogicalId::fromParts(source.namespaceName(), name); return id ? std::move(*id) : LogicalId{}; } EditorResult ActionTimelineEditor::paste(Duration offset) { if (clipboard_.empty()) return EditorResult::error(EditorStatus::Rejected, RuleId("editor.action.timeline.clipboard-empty"), "Action timeline clipboard is empty"); action::ActionTimeline candidate = target_.timeline(); std::set pastedSelection; for (const auto& item : clipboard_) { auto* track = findTrack(candidate, item.trackId); if (!track) return EditorResult::error(EditorStatus::Conflict, RuleId("editor.action.timeline.track-not-found"), "Clipboard track no longer exists"); if (track->locked) return EditorResult::error( EditorStatus::Rejected, RuleId("editor.action.timeline.track-locked"), "Clipboard track is locked"); if (item.state) { auto copy = item.notifyState; copy.id = copiedId(copy.id); auto start = copy.start.tryAdd(offset); auto end = copy.end.tryAdd(offset); if (!start || !end) return EditorResult::error(EditorStatus::Rejected, RuleId("editor.action.timeline.time-overflow"), "Pasted notify-state time overflowed"); copy.start = std::move(start).takeValue(); copy.end = std::move(end).takeValue(); pastedSelection.insert(copy.id.format()); track->states.push_back(std::move(copy)); } else { auto copy = item.notify; copy.id = copiedId(copy.id); auto time = copy.time.tryAdd(offset); if (!time) return EditorResult::error(EditorStatus::Rejected, RuleId("editor.action.timeline.time-overflow"), "Pasted notify time overflowed"); copy.time = std::move(time).takeValue(); pastedSelection.insert(copy.id.format()); track->notifies.push_back(std::move(copy)); } sortTrack(*track); } const std::size_t count = pastedSelection.size(); auto committed = commit(std::move(candidate), "Paste action timeline items", "action.timeline.item.paste"); if (!committed.accepted()) return EditorResult::error(committed.status, RuleId("editor.action.timeline.paste-failed"), "Could not paste action timeline items"); selection_ = std::move(pastedSelection); return EditorResult::applied(count); } EditorResult ActionTimelineEditor::deleteSelection() { if (selection_.empty()) return rejected("editor.action.timeline.selection-empty", "No timeline items are selected"); action::ActionTimeline candidate = target_.timeline(); for (auto& track : candidate.tracks) { const bool touchesLocked = track.locked && (std::any_of(track.notifies.begin(), track.notifies.end(), [&](const auto& item) { return selection_.contains(item.id.format()); }) || std::any_of(track.states.begin(), track.states.end(), [&](const auto& item) { return selection_.contains(item.id.format()); })); if (touchesLocked) return rejected("editor.action.timeline.track-locked", "Selection contains an item on a locked track"); std::erase_if(track.notifies, [&](const auto& item) { return selection_.contains(item.id.format()); }); std::erase_if(track.states, [&](const auto& item) { return selection_.contains(item.id.format()); }); } auto result = commit(std::move(candidate), "Delete action timeline selection", "action.timeline.item.delete"); if (result.accepted()) selection_.clear(); return result; } EditorResult ActionTimelineEditor::undo() { return transactions_.undo(); } EditorResult ActionTimelineEditor::redo() { return transactions_.redo(); } EditorResult ActionTimelineEditor::seek(Duration time) { if (time < Duration::zero() || time > target_.timeline().duration) return rejected("editor.action.timeline.seek-range", "Preview seek is outside the timeline"); previewTime_ = time; previewStarted_ = false; previewEvents_.clear(); return EditorResult::applied(); } EditorResult ActionTimelineEditor::update(Duration delta) { auto plan = planPreview(delta); if (!plan.accepted()) return EditorResult::error(plan.status, RuleId("editor.action.timeline.preview-plan"), "Could not prepare preview advance"); if (plan.status == EditorStatus::NoOp) { previewEvents_.clear(); EditorResult result; result.status = EditorStatus::NoOp; result.value = 0; return result; } return applyPreviewPlan(std::move(*plan.value)); } EditorResult ActionTimelineEditor::planPreview(Duration delta) const { if (!playing_) { ActionTimelinePreviewPlan plan; plan.timelineRevision = target_.revision(); plan.previous = previewTime_; plan.current = previewTime_; EditorResult result; result.status = EditorStatus::NoOp; result.value = std::move(plan); return result; } if (delta < Duration::zero()) return EditorResult::error(EditorStatus::Rejected, RuleId("editor.action.timeline.preview-delta"), "Preview delta must be non-negative"); auto next = previewTime_.tryAdd(delta); if (!next) return EditorResult::error( EditorStatus::Rejected, RuleId("editor.action.timeline.preview-overflow"), "Preview time overflowed"); Duration current = std::move(next).takeValue(); if (current > target_.timeline().duration) current = target_.timeline().duration; auto sampled = target_.timeline().sample(previewTime_, current, !previewStarted_); if (!sampled) return EditorResult::error( EditorStatus::Rejected, RuleId("editor.action.timeline.preview-invalid"), commonMessage(sampled.status(), "Could not sample action timeline")); ActionTimelinePreviewPlan plan; plan.timelineRevision = target_.revision(); plan.previous = previewTime_; plan.current = current; plan.includeStart = !previewStarted_; plan.reachesEnd = current == target_.timeline().duration; plan.events = std::move(sampled).takeValue(); return EditorResult::applied(std::move(plan)); } EditorResult ActionTimelineEditor::applyPreviewPlan(ActionTimelinePreviewPlan plan) { if (!playing_ || plan.timelineRevision != target_.revision() || plan.previous != previewTime_ || plan.includeStart == previewStarted_) return EditorResult::error(EditorStatus::Conflict, RuleId("editor.action.timeline.preview-plan-stale"), "Prepared preview advance no longer matches transport state"); auto expected = target_.timeline().sample(plan.previous, plan.current, plan.includeStart); if (!expected || expected.value() != plan.events || plan.reachesEnd != (plan.current == target_.timeline().duration)) return EditorResult::error(EditorStatus::Rejected, RuleId("editor.action.timeline.preview-plan-invalid"), "Prepared preview advance does not match the authoritative timeline"); previewEvents_ = std::move(plan.events); previewTime_ = plan.current; previewStarted_ = true; if (plan.reachesEnd) playing_ = false; return EditorResult::applied(previewEvents_.size()); } } // namespace eve::editor