17constexpr float kHandleRadius = 6.0f;
18constexpr float kNotifyWidth = 8.0f;
19constexpr std::size_t kMaximumWaveformBuckets = 512;
20constexpr std::size_t kMaximumCurveSegments = 128;
38std::optional<ItemView> findItem(
const action::ActionTimeline& timeline,
const LogicalId&
itemId) {
39 for (
const auto& section : timeline.animationSections) {
40 if (section.id !=
itemId)
continue;
47 {{
"animationUri",
Value(section.animationUri)}, {
"blendInNs",
Value(section.blendIn.nanoseconds())}},
52 for (
const auto& track : timeline.
tracks) {
53 for (
const auto& notify : track.notifies)
55 return ItemView{track.
id, notify.
id, notify.
type, notify.
time,
57 for (
const auto& state : track.states)
65Duration difference(Duration
left, Duration
right) {
69Result<void> adapt(
const Result<std::size_t>& result, std::string rule, std::string
message) {
70 if (result.ok())
return eve::editing::applied<void>();
72 return eve::editing::failed<void>(result.code(),
RuleId(std::move(rule)), std::move(
message));
80std::string resourceName(std::string
value) {
81 const auto slash =
value.find_last_of(
"/\\");
82 return slash == std::string::npos ?
value :
value.substr(slash + 1);
104 if (
type ==
"combat:damage") {
105 const auto amount =
payload.find(
"amount");
106 if (amount !=
payload.end() && (amount->second.isInt64() || amount->second.isDouble())) {
107 std::ostringstream stream;
108 if (amount->second.isInt64())
109 stream << amount->second.asInt();
111 stream << amount->second.asDouble();
116 if (
type ==
"presentation:vfx" ||
type ==
"presentation:vfx-state" ||
117 type ==
"presentation:audio" ||
type ==
"presentation:audio-state" ||
118 type ==
"gameplay:prefab-spawn")
133 const action::ActionNotifyRegistry& registry) {
134 geometry.visual = visualFor(
type.format());
135 geometry.detail = detailFor(
type.format(),
payload);
137 geometry.displayName =
"Animation";
167 : editor_(editor), registry_(registry) {}
170 if (!std::isfinite(
width) || !std::isfinite(rowHeight) || !std::isfinite(labelWidth) ||
width <= 0.0f ||
171 rowHeight < 12.0f || labelWidth < 0.0f || labelWidth + 16.0f >=
width)
172 return widgetError(
"editor.action.timeline.widget.viewport",
"Timeline widget dimensions are invalid");
174 rowHeight_ = rowHeight;
175 labelWidth_ = labelWidth;
176 return eve::editing::applied<void>();
181 return widgetError(
"editor.action.timeline.widget.snap",
"Timeline snap interval must be non-negative");
182 snapInterval_ = interval;
183 return eve::editing::applied<void>();
189 return widgetError(
"editor.action.timeline.widget.visible-range",
190 "Timeline visible range must be ordered and inside the asset duration");
191 visibleStart_ =
start;
193 return eve::editing::applied<void>();
197 if (!std::isfinite(factor) || factor <= 0.0 || !std::isfinite(normalizedAnchor) || normalizedAnchor < 0.0 ||
198 normalizedAnchor > 1.0)
199 return widgetError(
"editor.action.timeline.widget.zoom",
"Timeline zoom factor or anchor is invalid");
203 const double oldSpan =
static_cast<double>(
end.nanoseconds() -
start.nanoseconds());
204 const double minimumSpan = std::max(1.0,
static_cast<double>(
duration.nanoseconds()) / 10000.0);
205 const auto newSpan =
static_cast<std::int64_t
>(
206 std::llround(std::clamp(oldSpan / factor, minimumSpan,
static_cast<double>(
duration.nanoseconds()))));
207 const auto anchor =
start.nanoseconds() +
static_cast<std::int64_t
>(std::llround(oldSpan * normalizedAnchor));
208 auto newStart =
anchor -
static_cast<std::int64_t
>(std::llround(newSpan * normalizedAnchor));
209 newStart = std::clamp<std::int64_t>(newStart, 0,
duration.nanoseconds() - newSpan);
212 return eve::editing::applied<void>();
224 return eve::editing::applied<void>();
227float ActionTimelineWidget::timeToX(
Duration time)
const noexcept {
230 const auto end = visibleEnd_ > visibleStart_ ? visibleEnd_ :
duration;
231 const auto span =
end.nanoseconds() -
start.nanoseconds();
232 if (span <= 0)
return labelWidth_;
233 const double fraction =
static_cast<double>(
time.nanoseconds() -
start.nanoseconds()) /
static_cast<double>(span);
234 return labelWidth_ +
static_cast<float>(fraction *
static_cast<double>(width_ - labelWidth_));
237Duration ActionTimelineWidget::xToTime(
float x)
const noexcept {
238 const float clamped = std::clamp(
x, labelWidth_, width_);
239 const float fraction = (clamped - labelWidth_) / (width_ - labelWidth_);
242 const auto end = visibleEnd_ > visibleStart_ ? visibleEnd_ : assetDuration;
243 const auto span =
end.nanoseconds() -
start.nanoseconds();
245 start.nanoseconds() +
static_cast<std::int64_t
>(std::llround(
static_cast<double>(fraction) * span));
246 if (snapInterval_.nanoseconds() > 0) {
247 const double steps =
static_cast<double>(
time) /
static_cast<double>(snapInterval_.nanoseconds());
248 time =
static_cast<std::int64_t
>(std::llround(
steps)) * snapInterval_.nanoseconds();
249 time = std::clamp<std::int64_t>(
time,
start.nanoseconds(),
end.nanoseconds());
254std::optional<Duration> ActionTimelineWidget::magneticSnap(Duration candidate,
255 const LogicalId& excludedItem)
const noexcept {
256 constexpr double kSnapPixels = 8.0;
259 const auto end = visibleEnd_ > visibleStart_ ? visibleEnd_ : timeline.duration;
261 const auto excluded = [&](
const LogicalId&
id) {
262 return id == excludedItem || std::find(selected.begin(), selected.end(),
id) != selected.end();
264 const auto pixels =
static_cast<double>(width_ - labelWidth_);
265 if (
pixels <= 0.0)
return std::nullopt;
266 const auto threshold =
static_cast<std::int64_t
>(
267 std::llround(
static_cast<double>(
end.nanoseconds() -
start.nanoseconds()) * kSnapPixels /
pixels));
268 std::optional<Duration>
best;
269 auto bestDelta = threshold + 1;
270 const auto consider = [&](Duration
target) {
271 const auto delta =
target > candidate ?
target.nanoseconds() - candidate.nanoseconds()
272 : candidate.nanoseconds() -
target.nanoseconds();
273 if (delta <= threshold && delta < bestDelta) {
280 for (
const auto split : timeline.splitTimestamps) consider(
split);
281 for (
const auto& section : timeline.animationSections) {
282 if (excluded(section.id))
continue;
283 consider(section.start);
284 consider(section.end);
286 for (
const auto& track : timeline.
tracks) {
287 for (
const auto& notify : track.notifies) {
288 if (!excluded(notify.id)) consider(notify.time);
290 for (
const auto& state : track.states) {
291 if (excluded(
state.id))
continue;
292 consider(
state.start);
301 result.
width = width_;
304 const std::size_t rowOffset = hasAnimationLane ? 1U : 0U;
309 return std::find(selected.begin(), selected.end(),
id) != selected.end();
313 const std::int64_t interval = snapInterval_.
nanoseconds();
314 const auto visibleStart = visibleEnd_ > visibleStart_ ? visibleStart_.
nanoseconds() : 0;
315 const auto visibleEnd = visibleEnd_ > visibleStart_ ? visibleEnd_.
nanoseconds() :
duration;
316 std::int64_t
time = (visibleStart / interval) * interval;
317 if (
time < visibleStart)
time += interval;
324 if (hasAnimationLane) {
330 if (drag_ && drag_->itemId == section.id) {
331 start = drag_->previewStart;
332 end = drag_->previewEnd;
334 result.
items.push_back({animationTrack, section.id, animationType,
true, isSelected(section.id),
335 timeToX(
start), std::max(timeToX(
end), timeToX(
start) + 4.0f), 3.0f,
337 decorate(result.
items.back(), animationType,
338 {{
"animationUri", Value(section.animationUri)}}, registry_);
341 for (std::size_t row = 0; row < editor_.
target().timeline().
tracks.size(); ++row) {
343 const float top =
static_cast<float>(row + rowOffset) * rowHeight_;
344 for (
const auto& notify : track.notifies) {
346 if (drag_ && drag_->itemId == notify.id)
time = drag_->previewStart;
348 result.items.push_back({track.id, notify.id, notify.type,
false, isSelected(notify.id),
349 center - kNotifyWidth * 0.5f,
center + kNotifyWidth * 0.5f,
top + 3.0f,
350 top + rowHeight_ - 3.0f});
351 decorate(result.items.back(), notify.type, notify.payload, registry_);
353 for (
const auto& state : track.states) {
356 if (drag_ && drag_->itemId ==
state.id) {
357 start = drag_->previewStart;
358 end = drag_->previewEnd;
363 top + 3.0f,
top + rowHeight_ - 3.0f});
364 decorate(result.items.back(),
state.type,
state.payload, registry_);
371 const auto& timeline = editor_.target().timeline();
372 const bool hasAnimationLane = !timeline.animationSections.empty();
373 const std::size_t rowOffset = hasAnimationLane ? 1U : 0U;
374 if (hasAnimationLane) {
375 overlay.
rectangle({0.0f, 0.0f, 0.0f}, {width_, rowHeight_, 0.0f}, {0x20252cffU, 1.0f,
true});
376 overlay.
line({0.0f, rowHeight_, 0.0f}, {width_, rowHeight_, 0.0f}, {0x4a5260ffU, 1.0f,
false});
377 overlay.
text({4.0f, 4.0f, 0.0f},
"Animation", {0xd8dee9ffU, 1.0f,
false});
379 for (std::size_t row = 0; row < timeline.tracks.size(); ++row) {
380 const float top =
static_cast<float>(row + rowOffset) * rowHeight_;
382 overlay.
rectangle({0.0f,
top, 0.0f}, {width_,
bottom, 0.0f}, {0x20252cffU, 1.0f,
true});
383 overlay.
line({0.0f,
bottom, 0.0f}, {width_,
bottom, 0.0f}, {0x4a5260ffU, 1.0f,
false});
384 overlay.
text({4.0f,
top + 4.0f, 0.0f}, timeline.tracks[row].label,
385 {timeline.tracks[row].muted ? 0x7f8792ffU : 0xd8dee9ffU, 1.0f,
false});
387 for (
const auto& item :
layout().items) {
389 const unsigned int color =
390 item.selected ? 0xf2b84bffU : (
animationSection ? 0xa66bd4ffU : (item.state ? 0x568bd7ffU : 0x61c28bffU));
391 overlay.
rectangle({item.minimumX, item.minimumY, 0.0f}, {item.maximumX, item.maximumY, 0.0f},
392 {
color, 1.0f,
true});
393 if (item.maximumX - item.minimumX >= 48.0f)
394 overlay.
text({item.minimumX + 5.0f, item.minimumY + 3.0f, 0.0f}, item.displayName,
395 {0xffffffffU, 1.0f,
false});
396 if (item.state && item.type.format() ==
"presentation:audio-state") {
397 const auto view = findItem(timeline, item.itemId);
398 auto* waveforms = eve::cap::query<action::IActionAudioWaveformProvider>();
399 if (
view && waveforms) {
402 const auto pixels =
static_cast<std::size_t
>(
403 std::max(1.0f, std::floor(item.maximumX - item.minimumX)));
407 std::min(
pixels, kMaximumWaveformBuckets)};
408 auto waveform = waveforms->waveform(
request);
409 if (waveform && !waveform.value().buckets.empty()) {
410 const float middle = (item.minimumY + item.maximumY) * 0.5f;
411 const float amplitude = std::max(1.0f, (item.maximumY - item.minimumY) * 0.42f);
412 const float step = (item.maximumX - item.minimumX) /
413 static_cast<float>(waveform.value().buckets.size());
414 for (std::size_t
index = 0;
index < waveform.value().buckets.size(); ++
index) {
415 const auto&
bucket = waveform.value().buckets[
index];
416 const float x = item.minimumX + (
static_cast<float>(
index) + 0.5f) *
step;
417 overlay.
line({
x, middle - std::clamp(
bucket.maximum, -1.0f, 1.0f) * amplitude, 0.0f},
418 {
x, middle - std::clamp(
bucket.minimum, -1.0f, 1.0f) * amplitude, 0.0f},
419 {0xe7f4ffffU, 1.0f,
false});
425 if (item.state && item.type.format() ==
"presentation:parameter-curve") {
426 const auto view = findItem(timeline, item.itemId);
433 for (
const auto&
key :
binding.value().keys) {
442 const float x = item.minimumX +
static_cast<float>(
time) * (item.maximumX - item.minimumX);
444 const float y = item.maximumY - 2.0f - normalized * (item.maximumY - item.minimumY - 4.0f);
447 const auto segments = std::min<std::size_t>(
448 kMaximumCurveSegments,
449 std::max<std::size_t>(2,
static_cast<std::size_t
>(item.maximumX - item.minimumX)));
451 for (std::size_t segment = 1; segment <= segments; ++segment) {
452 const double time =
static_cast<double>(segment) /
static_cast<double>(segments);
459 {item.selected ? 0xffffffffU : 0xffdc7affU, 1.0f,
true});
463 overlay.
line({item.minimumX, item.minimumY, 0.0f}, {item.minimumX, item.maximumY, 0.0f},
464 {0xffffffffU, 2.0f,
false});
465 overlay.
line({item.maximumX, item.minimumY, 0.0f}, {item.maximumX, item.maximumY, 0.0f},
466 {0xffffffffU, 2.0f,
false});
469 const float height = rowHeight_ *
static_cast<float>(timeline.tracks.size() + rowOffset);
470 overlay.
line({timeToX(editor_.previewTime()), 0.0f, 0.0f}, {timeToX(editor_.previewTime()),
height, 0.0f},
471 {0xff5b5bffU, 2.0f,
false});
474std::optional<TimelineHit> ActionTimelineWidget::hitTest(
float x,
float y)
const {
477 return y >= item.minimumY &&
y <= item.maximumY;
481 if (!containsY(*it) || !it->state)
continue;
482 if (it->state && std::abs(
x - it->minimumX) <= kHandleRadius)
484 if (it->state && std::abs(
x - it->maximumX) <= kHandleRadius)
489 if (!containsY(*it) || it->state)
continue;
490 if (
x >= it->minimumX && x <= it->maximumX)
return TimelineHit{it->
itemId, TimelineHitPart::Body};
493 if (!containsY(*it) || !it->state)
continue;
494 if (
x >= it->minimumX && x <= it->maximumX)
return TimelineHit{it->
itemId, TimelineHitPart::Body};
499Result<void> ActionTimelineWidget::pointerDown(
float x,
float y,
bool additiveSelection) {
500 if (drag_)
return widgetError(
"editor.action.timeline.widget.drag-active",
"A timeline drag is already active");
501 const auto hit = hitTest(
x,
y);
503 if (!additiveSelection) editor_.clearSelection();
506 auto item = findItem(editor_.target().timeline(),
hit->itemId);
508 return widgetError(
"editor.action.timeline.widget.item-missing",
"Timeline item no longer exists",
509 EditorStatus::Conflict);
510 if (item->locked)
return widgetError(
"editor.action.timeline.track-locked",
"Action track is locked");
511 const auto selectedIds = editor_.selectedItemIds();
512 const bool alreadySelected = std::find(selectedIds.begin(), selectedIds.end(), item->itemId) != selectedIds.end();
513 auto selected = editor_.selectItem(item->itemId, additiveSelection || alreadySelected);
514 if (!selected.ok())
return selected;
515 drag_ = DragState{item->itemId,
hit->part, xToTime(
x), item->start, item->end, item->start, item->end, item->state};
516 return eve::editing::applied<void>();
520 if (!drag_)
return widgetError(
"editor.action.timeline.widget.drag-missing",
"No timeline drag is active");
525 auto moved = drag_->originalStart.tryAdd(delta);
526 if (!moved)
return widgetError(
"editor.action.timeline.widget.drag-overflow",
"Notify drag overflowed");
528 drag_->previewStart = magneticSnap(candidate, drag_->itemId).value_or(candidate);
529 drag_->previewEnd = drag_->previewStart;
530 return eve::editing::applied<void>();
532 if (drag_->part == TimelineHitPart::StartHandle) {
533 auto moved = drag_->originalStart.tryAdd(delta);
534 if (!moved)
return widgetError(
"editor.action.timeline.widget.drag-overflow",
"State start drag overflowed");
535 const auto candidate = std::clamp(moved.value(),
Duration::zero(), drag_->originalEnd);
536 drag_->previewStart = magneticSnap(candidate, drag_->itemId).value_or(candidate);
537 drag_->previewStart = std::min(drag_->previewStart, drag_->originalEnd);
538 drag_->previewEnd = drag_->originalEnd;
539 return eve::editing::applied<void>();
541 if (drag_->part == TimelineHitPart::EndHandle) {
542 auto moved = drag_->originalEnd.tryAdd(delta);
543 if (!moved)
return widgetError(
"editor.action.timeline.widget.drag-overflow",
"State end drag overflowed");
544 drag_->previewStart = drag_->originalStart;
545 const auto candidate = std::clamp(moved.value(), drag_->originalStart,
duration);
546 drag_->previewEnd = magneticSnap(candidate, drag_->itemId).value_or(candidate);
547 drag_->previewEnd = std::max(drag_->previewEnd, drag_->originalStart);
548 return eve::editing::applied<void>();
550 const auto span = drag_->originalEnd.nanoseconds() - drag_->originalStart.nanoseconds();
551 auto moved = drag_->originalStart.tryAdd(delta);
552 if (!moved)
return widgetError(
"editor.action.timeline.widget.drag-overflow",
"State drag overflowed");
554 drag_->previewStart = std::clamp(moved.value(),
Duration::zero(), latestStart);
556 const auto snappedStart = magneticSnap(drag_->previewStart, drag_->itemId);
557 const auto snappedEnd = magneticSnap(drag_->previewEnd, drag_->itemId);
559 if (snappedStart && snappedEnd) {
560 const auto startDelta = difference(*snappedStart, drag_->previewStart);
561 const auto endDelta = difference(*snappedEnd, drag_->previewEnd);
562 adjustment = std::abs(startDelta.nanoseconds()) <= std::abs(endDelta.nanoseconds()) ? startDelta : endDelta;
563 }
else if (snappedStart) {
564 adjustment = difference(*snappedStart, drag_->previewStart);
565 }
else if (snappedEnd) {
566 adjustment = difference(*snappedEnd, drag_->previewEnd);
568 const auto adjustedStart = drag_->previewStart.tryAdd(adjustment);
569 if (adjustedStart && adjustedStart.value() >=
Duration::zero() && adjustedStart.value() <= latestStart) {
570 drag_->previewStart = adjustedStart.value();
573 return eve::editing::applied<void>();
581 const DragState completed = *drag_;
583 const auto item = findItem(editor_.target().timeline(), completed.itemId);
584 if (completed.part == TimelineHitPart::Body && editor_.selectionCount() > 1)
585 return editor_.moveSelection(difference(completed.previewStart, completed.originalStart));
586 if (item && item->animationSection) {
587 if (completed.part == TimelineHitPart::Body)
588 return editor_.moveAnimationSection(completed.itemId,
589 difference(completed.previewStart, completed.originalStart));
590 const auto section = std::find_if(editor_.target().timeline().animationSections.begin(),
591 editor_.target().timeline().animationSections.end(),
592 [&](
const auto&
value) { return value.id == completed.itemId; });
593 if (section == editor_.target().timeline().animationSections.end())
594 return widgetError(
"editor.action.timeline.animation-section-not-found",
595 "Animation section no longer exists", EditorStatus::Conflict);
596 return editor_.resizeAnimationSection(completed.itemId, completed.previewStart, completed.previewEnd,
599 if (completed.state && completed.part != TimelineHitPart::Body)
600 return editor_.resizeState(completed.itemId, completed.previewStart, completed.previewEnd);
601 return editor_.moveItem(completed.itemId, difference(completed.previewStart, completed.originalStart));
604Result<void> ActionTimelineWidget::seek(
float x) {
return editor_.seek(xToTime(
x)); }
607 const auto selected = editor_.selectedItemIds();
608 if (selected.size() > 1) {
609 auto range = editor_.selectionRange();
611 float startSeconds =
static_cast<float>(
range.value().start.seconds());
612 float endSeconds =
static_cast<float>(
range.value().end.seconds());
613 const float maximum =
static_cast<float>(editor_.target().timeline().duration.seconds());
614 inspector.
beginGroup(
"action.timeline.selection",
"Selection");
615 bool changed = inspector.
scalar(
"start",
"Start", startSeconds, 0.0f,
maximum);
616 changed = inspector.
scalar(
"end",
"End", endSeconds, 0.0f,
maximum) || changed;
622 return widgetError(
"editor.action.timeline.widget.selection-time-invalid",
623 "Selection bounds are invalid");
624 return editor_.scaleSelection(
start.value(),
end.value());
627 auto item = findItem(editor_.target().timeline(), selected.front());
629 return widgetError(
"editor.action.timeline.widget.item-missing",
"Selected item no longer exists",
630 EditorStatus::Conflict);
632 if (item->animationSection) {
633 const auto section = std::find_if(editor_.target().timeline().animationSections.begin(),
634 editor_.target().timeline().animationSections.end(),
635 [&](
const auto&
value) { return value.id == item->itemId; });
636 if (section == editor_.target().timeline().animationSections.end())
637 return widgetError(
"editor.action.timeline.animation-section-not-found",
638 "Animation section no longer exists", EditorStatus::Conflict);
639 float startSeconds =
static_cast<float>(section->start.seconds());
640 float endSeconds =
static_cast<float>(section->end.seconds());
641 float blendSeconds =
static_cast<float>(section->blendIn.seconds());
642 float sourceStartSeconds =
static_cast<float>(section->sourceStart.seconds());
643 float sourceEndSeconds =
static_cast<float>(section->sourceEnd.seconds());
644 std::string animationUri = section->animationUri;
646 const float maximum =
static_cast<float>(editor_.target().timeline().duration.seconds());
647 inspector.
beginGroup(
"action.timeline.animation-section",
"Animation Section");
648 bool changed = inspector.
scalar(
"start",
"Start", startSeconds, 0.0f,
maximum);
649 changed = inspector.
scalar(
"end",
"End", endSeconds, 0.0f,
maximum) || changed;
650 changed = inspector.
scalar(
"blendIn",
"Blend In", blendSeconds, 0.0f,
maximum) || changed;
652 inspector.
scalar(
"sourceStart",
"Trim In", sourceStartSeconds, 0.0f, std::numeric_limits<float>::max()) ||
654 changed = inspector.
scalar(
"sourceEnd",
"Trim Out (0 = clip end)", sourceEndSeconds, 0.0f,
655 std::numeric_limits<float>::max()) ||
657 changed = inspector.
string(
"blendCurve",
"Blend Curve", blendCurve) || changed;
658 changed = inspector.
string(
"animationUri",
"Animation", animationUri) || changed;
660 if (!changed)
return eve::editing::applied<void>();
667 if (!
start || !
end || !
blend || !sourceStart || !sourceEnd || !parsedCurve)
668 return widgetError(
"editor.action.timeline.widget.time-invalid",
"Animation section time is invalid");
677 return editor_.editAnimationSectionFull(std::move(edited));
680 if (item->state && item->type.format() ==
"presentation:parameter-curve") {
683 return widgetError(
"editor.action.timeline.widget.parameter-curve-invalid",
684 "Selected parameter curve payload is invalid");
688 bool changed =
false;
689 inspector.
beginGroup(
"action.timeline.parameter-curve",
"Parameter Curve");
690 changed = inspector.
string(
"target",
"Target",
target) || changed;
691 changed = inspector.
string(
"operation",
"Operation",
operation) || changed;
694 float time =
static_cast<float>(
key.time);
695 float value =
static_cast<float>(
key.value);
696 float inTangent =
static_cast<float>(
key.inTangent);
697 float outTangent =
static_cast<float>(
key.outTangent);
699 inspector.
beginGroup(
"action.timeline.parameter-key." + std::to_string(
index),
700 "Key " + std::to_string(
index));
702 const float minimumTime = endpoint ?
static_cast<float>(
key.time)
703 :
static_cast<float>(
binding.value().keys[
index - 1].time);
704 const float maximumTime = endpoint ?
static_cast<float>(
key.time)
705 :
static_cast<float>(
binding.value().keys[
index + 1].time);
706 changed = inspector.
scalar(
"time",
"Time",
time, minimumTime, maximumTime) || changed;
707 changed = inspector.
scalar(
"value",
"Value",
value, std::numeric_limits<float>::lowest(),
708 std::numeric_limits<float>::max()) || changed;
709 changed = inspector.
scalar(
"inTangent",
"In Tangent", inTangent,
710 std::numeric_limits<float>::lowest(),
711 std::numeric_limits<float>::max()) || changed;
712 changed = inspector.
scalar(
"outTangent",
"Out Tangent", outTangent,
713 std::numeric_limits<float>::lowest(),
714 std::numeric_limits<float>::max()) || changed;
715 changed = inspector.
string(
"interpolation",
"Interpolation", interpolation) || changed;
718 {
"value",
static_cast<double>(
value)},
719 {
"inTangent",
static_cast<double>(inTangent)},
720 {
"outTangent",
static_cast<double>(outTangent)},
721 {
"interpolation", std::move(interpolation)}});
724 if (!changed)
return eve::editing::applied<void>();
731 return widgetError(
"editor.action.timeline.widget.parameter-curve-contract",
732 "Parameter curve fields violate the block contract");
733 return editor_.updateItem(item->itemId, item->type,
734 validated.value().toPayload(std::move(
payload)));
737 float startSeconds =
static_cast<float>(item->start.seconds());
738 float endSeconds =
static_cast<float>(item->end.seconds());
742 return widgetError(
"editor.action.timeline.widget.payload-encode",
"Could not encode item payload");
743 std::string
payload = payloadJson.value();
745 inspector.
beginGroup(
"action.timeline.item", item->state ?
"Notify State" :
"Notify");
746 const float maximum =
static_cast<float>(editor_.target().timeline().duration.seconds());
747 bool timingChanged = inspector.
scalar(
"start", item->state ?
"Start" :
"Time", startSeconds, 0.0f,
maximum);
748 if (item->state) timingChanged = inspector.
scalar(
"end",
"End", endSeconds, 0.0f,
maximum) || timingChanged;
749 const bool typeChanged = inspector.
string(
"type",
"Type",
type);
750 const bool payloadChanged = inspector.
string(
"payload",
"Payload JSON",
payload);
753 if (!timingChanged && !typeChanged && !payloadChanged)
return eve::editing::applied<void>();
758 if (!parsedStart || !parsedEnd)
759 return widgetError(
"editor.action.timeline.widget.time-invalid",
"Inspector time is invalid");
761 if (!parsedType)
return widgetError(
"editor.action.timeline.widget.type-invalid",
"Notify type is invalid");
763 if (!parsedPayload || !parsedPayload.value().getIf<
Value::Object>())
764 return widgetError(
"editor.action.timeline.widget.payload-invalid",
"Payload must be a JSON object");
767 return widgetError(
"editor.action.timeline.widget.type-unregistered",
"Notify type is not registered");
770 return widgetError(
"editor.action.timeline.widget.shape-mismatch",
"Notify type shape does not match item");
771 const auto*
object = parsedPayload.value().getIf<
Value::Object>();
780 return widgetError(
"editor.action.timeline.widget.payload-contract",
"Payload violates notify contract");
781 return editor_.editItem(item->itemId, parsedStart.
value(), item->state ? parsedEnd.
value() : parsedStart.
value(),
782 std::move(*parsedType), *
object);
785std::vector<TimelineWidgetCommandDescriptor> ActionTimelineWidget::commands()
const {
786 const bool selected = editor_.selectionCount() > 0;
787 const bool multi = editor_.selectionCount() > 1;
788 return {{TimelineWidgetCommand::Copy,
"Copy",
"Ctrl+C", selected},
789 {TimelineWidgetCommand::Paste,
"Paste at Playhead",
"Ctrl+V", clipboardAnchor_.has_value()},
790 {TimelineWidgetCommand::DeleteSelection,
"Delete",
"Delete", selected},
791 {TimelineWidgetCommand::AlignSelectionStart,
"Align Starts",
"Shift+[", multi},
792 {TimelineWidgetCommand::AlignSelectionEnd,
"Align Ends",
"Shift+]", multi},
793 {TimelineWidgetCommand::Undo,
"Undo",
"Ctrl+Z", editor_.canUndo()},
794 {TimelineWidgetCommand::Redo,
"Redo",
"Ctrl+Y", editor_.canRedo()},
795 {TimelineWidgetCommand::PlayPause, editor_.playing() ?
"Pause" :
"Play",
"Space",
true}};
800 case TimelineWidgetCommand::Copy: {
801 const auto selected = editor_.selectedItemIds();
802 if (selected.empty())
803 return widgetError(
"editor.action.timeline.selection-empty",
"No timeline items are selected");
804 std::optional<Duration> earliest;
805 for (
const auto&
id : selected) {
806 const auto item = findItem(editor_.target().timeline(),
id);
807 if (item && (!earliest || item->start < *earliest)) earliest = item->start;
809 auto copied = editor_.copySelection();
810 if (copied.ok()) clipboardAnchor_ = earliest;
811 return adapt(copied,
"editor.action.timeline.widget.copy",
"Could not copy timeline selection");
813 case TimelineWidgetCommand::Paste: {
814 if (!clipboardAnchor_)
815 return widgetError(
"editor.action.timeline.clipboard-empty",
"Action timeline clipboard is empty");
816 return adapt(editor_.paste(difference(editor_.previewTime(), *clipboardAnchor_)),
817 "editor.action.timeline.widget.paste",
"Could not paste timeline selection");
819 case TimelineWidgetCommand::DeleteSelection:
return editor_.deleteSelection();
820 case TimelineWidgetCommand::AlignSelectionStart:
return editor_.alignSelectionStart();
821 case TimelineWidgetCommand::AlignSelectionEnd:
return editor_.alignSelectionEnd();
822 case TimelineWidgetCommand::Undo: {
823 auto result = editor_.undo();
824 if (result.ok())
return eve::editing::applied<void>();
827 case TimelineWidgetCommand::Redo: {
828 auto result = editor_.redo();
829 if (result.ok())
return eve::editing::applied<void>();
832 case TimelineWidgetCommand::PlayPause:
833 if (editor_.playing())
837 return eve::editing::applied<void>();
839 return widgetError(
"editor.action.timeline.widget.command",
"Timeline command is unsupported",
840 EditorStatus::Unsupported);
843Result<void> ActionTimelineWidget::handleShortcut(std::string_view shortcut) {
844 if (shortcut ==
"Ctrl+C")
return invoke(TimelineWidgetCommand::Copy);
845 if (shortcut ==
"Ctrl+V")
return invoke(TimelineWidgetCommand::Paste);
846 if (shortcut ==
"Delete" || shortcut ==
"Backspace")
return invoke(TimelineWidgetCommand::DeleteSelection);
847 if (shortcut ==
"Shift+[")
return invoke(TimelineWidgetCommand::AlignSelectionStart);
848 if (shortcut ==
"Shift+]")
return invoke(TimelineWidgetCommand::AlignSelectionEnd);
849 if (shortcut ==
"Ctrl+Z")
return invoke(TimelineWidgetCommand::Undo);
850 if (shortcut ==
"Ctrl+Y" || shortcut ==
"Ctrl+Shift+Z")
return invoke(TimelineWidgetCommand::Redo);
851 if (shortcut ==
"Space")
return invoke(TimelineWidgetCommand::PlayPause);
852 return widgetError(
"editor.action.timeline.widget.shortcut",
"Timeline shortcut is unsupported",
853 EditorStatus::Unsupported);
856std::vector<action::ActionNotifyDescriptor> ActionTimelineWidget::insertableTypes(
863LogicalId ActionTimelineWidget::generatedItemId() {
865 auto id =
LogicalId::fromParts(
"editor",
"timeline-item." + std::to_string(++generatedSequence_));
867 if (!findItem(editor_.target().timeline(), *
id))
return std::move(*
id);
874 if (!parsed)
return widgetError(
"editor.action.timeline.widget.type-invalid",
"Notify type is invalid");
878 if (!
valid)
return widgetError(
"editor.action.timeline.widget.notify-invalid",
"Notify payload is invalid");
879 return editor_.addNotify(
trackId, {
event.itemId,
event.type,
event.time, std::move(
payload)});
885 return widgetError(
"editor.action.timeline.widget.state-duration",
"Notify-state duration is negative");
887 if (!parsed)
return widgetError(
"editor.action.timeline.widget.type-invalid",
"Notify type is invalid");
889 if (!
end ||
end.value() > editor_.target().timeline().duration)
890 return widgetError(
"editor.action.timeline.widget.state-range",
"Notify-state exceeds the timeline");
895 editor_.previewTime(),
898 if (!
valid)
return widgetError(
"editor.action.timeline.widget.state-invalid",
"Notify-state payload is invalid");
899 return editor_.addState(
trackId, {
event.itemId,
event.type,
event.time,
end.value(), std::move(
payload)});
Typed audio action-block payload contract.
ActionParameterOperation operation
Typed continuous parameter-curve action block.
const GltfImportRequest & request
wgpu::PopErrorScopeStatus status
std::vector< Track > tracks
graphics::Canvas * previous
eve::action::ActionVfxBinding binding
std::array< std::uint64_t, kPixelChunkSize *kPixelChunkSize > updated
std::map< Cell, int > best
static Diagnostic error(DiagnosticCode code, std::string message, std::string path={}, DiagnosticDetails details={}, std::string source={})
Construct an error diagnostic with the standard error severity.
Signed, fixed-resolution simulation duration in nanoseconds.
constexpr std::int64_t nanoseconds() const noexcept
Return the exact signed nanosecond representation.
static Result< Duration > fromSeconds(double seconds)
Convert finite seconds to the nearest nanosecond.
static constexpr Duration fromNanoseconds(std::int64_t nanoseconds) noexcept
Construct an exact duration from nanoseconds.
static constexpr Duration zero() noexcept
Return zero duration.
Human-readable, scoped identifier in the form namespace:name.
static std::optional< LogicalId > fromParts(std::string_view namespaceName, std::string_view name)
Builds a logical ID from separate namespace and name components.
const std::string & format() const noexcept
Returns the canonical namespace:name representation.
static std::optional< LogicalId > parse(std::string_view text)
Parses a scoped logical name.
Move-only operation result carrying either a value or Status.
static Result success(T value)
Construct a successful result owning value.
const T & value() const &
Borrow the value from a const lvalue after checking success.
static Result failure(Status status)
Construct a failed result from a structured status.
The canonical owning dynamic value used by data-facing protocols.
Result< std::string > toJson() const
Serialize this value as deterministic compact JSON.
static Result< Value > fromJson(std::string_view json)
Parse one strict JSON value into an owning Value.
std::map< std::string, Value > Object
std::vector< Value > Array
Canonical notify descriptor owner and runtime handler router.
Result< void > validate(const ActionTimelineEvent &event) const
Validate type, shape and required payload fields.
std::vector< ActionNotifyDescriptor > descriptors() const
Return descriptors in lexical type order.
Result< ActionNotifyDescriptor > descriptor(std::string_view type) const
Find an owning descriptor copy.
Timeline editing controller shared by developer and in-game editors.
Duration previewTime() const noexcept
Current deterministic preview cursor.
std::vector< LogicalId > selectedItemIds() const
Return selected item IDs in lexical order as owning values.
const ActionTimelineTarget & target() const noexcept
Borrow the authoritative target.
const action::ActionTimeline & timeline() const noexcept
Borrow the authoritative timeline until the next mutation.
UI-independent property editor used by tools to expose settings.
virtual void endGroup()=0
End the current logical group.
virtual void beginGroup(const std::string &id, const std::string &label)=0
Begin a logical group of properties.
virtual bool scalar(const std::string &id, const std::string &label, float &value, float minimum, float maximum)=0
Present an editable scalar property.
virtual bool string(const std::string &id, const std::string &label, std::string &value)=0
Present an editable text property.
Sink implemented by a 2D or 3D viewport to render tool feedback.
virtual void text(const OverlayPoint &position, const std::string &value, const OverlayStyle &style)=0
Draw a short text annotation.
virtual void circle(const OverlayPoint ¢er, float radius, const OverlayStyle &style)=0
Draw a circle whose orientation is supplied by the viewport.
virtual void line(const OverlayPoint &from, const OverlayPoint &to, const OverlayStyle &style)=0
Draw a line between two viewport-space points.
virtual void rectangle(const OverlayPoint &minimum, const OverlayPoint &maximum, const OverlayStyle &style)=0
Draw an axis-aligned rectangle in viewport coordinates.
std::optional< ActionBlendCurve > actionBlendCurveFromName(std::string_view name) noexcept
Parse a persisted montage cross-fade curve name.
std::string_view actionBlendCurveName(ActionBlendCurve curve) noexcept
Stable persisted name for a montage cross-fade curve.
std::string_view actionParameterOperationName(ActionParameterOperation value) noexcept
Stable persisted operation spelling.
ActionNotifyShape
Declares whether a notify is instantaneous or an enter/exit state.
std::string_view actionParameterInterpolationName(ActionParameterInterpolation value) noexcept
Stable persisted interpolation spelling.
std::variant< std::monostate, std::int64_t, double, std::string, bool > Value
Result< void > noOp(std::vector< Diagnostic > diagnostics={})
Construct a successful void result for an operation that made no change.
TimelineWidgetCommand
Standard context-menu and keyboard actions exposed by the widget.
eve::editing::RuleId RuleId
std::string_view timelineItemVisualName(TimelineItemVisual visual) noexcept
Return the stable script-facing name for a timeline visual kind.
TimelineItemVisual
Stable semantic thumbnail kind projected for one timeline block.
eve::StatusCode EditorStatus
Objective animation segment placed on the montage presentation lane.
Duration sourceStart
Clip-local trim-in timestamp.
ActionBlendCurve blendCurve
Curve used for the incoming cross-fade.
Duration sourceEnd
Clip-local trim-out timestamp; zero means the physical clip end.
static Result< ActionAudioBinding > fromPayload(const Value::Object &payload, ActionAudioShape shape)
Decode an audio payload transactionally.
static Result< ActionParameterCurveBinding > fromPayload(const Value::Object &payload)
Decode and validate target, operation and normalized ordered keys.
Owning event projection returned by the runtime sampler.
std::vector< ActionAnimationSection > animationSections
std::vector< ActionTrack > tracks
A point in the coordinate space chosen by an editor viewport adapter.
Result of widget hit testing.
Owning geometry projected for one timeline item.