@vosjs/studio-core 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -424,12 +424,358 @@ function projectFromArtifact(artifact, videoUrl, opts = {}) {
424
424
  return { doc, videoUrl };
425
425
  }
426
426
 
427
+ // src/lower/motion.ts
428
+ import { sortKeyframes } from "@vosjs/timeline";
429
+
430
+ // src/anim.ts
431
+ var CARD_ENTER_KINDS = [
432
+ "none",
433
+ "fade",
434
+ "rise",
435
+ "tilt-in",
436
+ "pull-out"
437
+ ];
438
+ var CARD_EXIT_KINDS = ["none", "fade", "recede"];
439
+ var TEXT_ENTER_KINDS = [
440
+ "none",
441
+ "fade",
442
+ "rise",
443
+ "pop",
444
+ "blur",
445
+ "typewriter"
446
+ ];
447
+ var TEXT_EXIT_KINDS = ["none", "fade", "rise"];
448
+ var MEDIA_ENTER_KINDS = ["none", "fade", "rise"];
449
+ var MEDIA_EXIT_KINDS = ["none", "fade", "rise"];
450
+ var IDLE_KINDS = ["spin", "float"];
451
+ var UNIT_KINDS = /* @__PURE__ */ new Set(["pop", "blur", "typewriter"]);
452
+ function animStep(step) {
453
+ if (step == null) return null;
454
+ if (typeof step === "string") return { kind: step };
455
+ return step;
456
+ }
457
+ function enterOf(anim) {
458
+ return animStep(anim?.enter);
459
+ }
460
+ function exitOf(anim) {
461
+ return animStep(anim?.exit);
462
+ }
463
+ function isUnitStep(step) {
464
+ if (!step) return false;
465
+ if (UNIT_KINDS.has(step.kind)) return true;
466
+ if (step.kind !== "fade" && step.kind !== "rise") return false;
467
+ return step.unit !== void 0 || step.stagger !== void 0 || step.seconds !== void 0 || step.direction !== void 0;
468
+ }
469
+ function overlayFxSpec(clip) {
470
+ const step = enterOf(clip.anim);
471
+ if (!isUnitStep(step) || !step || step.kind === "none") return null;
472
+ const kind = step.kind;
473
+ const fx = kind === "fade" || kind === "rise" || kind === "pop" || kind === "blur" || kind === "typewriter" ? kind : "rise";
474
+ return {
475
+ fx,
476
+ ...step.unit !== void 0 ? { unit: step.unit } : {},
477
+ ...step.direction !== void 0 ? { direction: step.direction } : {},
478
+ ...step.stagger !== void 0 ? { stagger: step.stagger } : {},
479
+ ...step.seconds !== void 0 ? { duration: step.seconds } : {}
480
+ };
481
+ }
482
+ var BLOCK_KINDS = /* @__PURE__ */ new Set(["none", "fade", "rise"]);
483
+ function enterKey(clip) {
484
+ const step = enterOf(clip.anim);
485
+ if (!step) return "rise";
486
+ return BLOCK_KINDS.has(step.kind) ? step.kind : "rise";
487
+ }
488
+ function exitKey(clip) {
489
+ const step = exitOf(clip.anim);
490
+ if (!step) return "fade";
491
+ return BLOCK_KINDS.has(step.kind) ? step.kind : "fade";
492
+ }
493
+ function idleKey(clip) {
494
+ const idle = clip.anim?.idle;
495
+ return idle === "spin" || idle === "float" ? idle : null;
496
+ }
497
+ function lastLayerEnd(doc) {
498
+ let end = 0;
499
+ for (const o of doc.overlays ?? []) end = Math.max(end, o.start + o.duration);
500
+ for (const o of doc.objects ?? [])
501
+ if (o.span) end = Math.max(end, o.span.start + o.span.duration);
502
+ return Number.isFinite(end) && end > 0 ? end : 0;
503
+ }
504
+
505
+ // src/lower/motion.ts
506
+ import { splitBySpeed, totalDuration } from "@vosjs/timeline";
507
+ function docRestTime(doc) {
508
+ if (doc.endCard) return null;
509
+ const last = doc.segments.at(-1);
510
+ const hold = last?.hold;
511
+ if (typeof hold !== "number" || !(hold > 0)) return null;
512
+ const segs = doc.segments.length ? doc.segments : [{ in: 0, out: doc.source.meta.durationMs / 1e3 }];
513
+ const total = totalDuration(
514
+ withHolds(segs, splitBySpeed(segs, doc.speed ?? []))
515
+ );
516
+ const rest = total - hold;
517
+ if (!(rest >= 0)) return null;
518
+ return Math.round(rest * 1e3) / 1e3;
519
+ }
520
+ var ENTRANCE_SECONDS = 1.2;
521
+ var END_CARD_SECONDS = 2.5;
522
+ var END_CARD_RECEDE = 0.7;
523
+ var CARD_FADE_SECONDS = 0.35;
524
+ var TILT_IN_POSE = [-9, 14];
525
+ var PULL_OUT_LEVEL = 1.32;
526
+ var END_CARD_FROM = "endcard";
527
+ var HOLD_SOURCE_SPAN = 2e-3;
528
+ function cardEnter(frame) {
529
+ return enterOf(frame.anim);
530
+ }
531
+ function cardExit(frame) {
532
+ return exitOf(frame.anim);
533
+ }
534
+ function entranceSeconds(e) {
535
+ if (!e || e.kind === "none") return 0;
536
+ return Math.max(0.2, Math.min(3, e.seconds ?? ENTRANCE_SECONDS));
537
+ }
538
+ function exitSeconds(e) {
539
+ if (!e || e.kind === "none") return 0;
540
+ const house = e.kind === "fade" ? CARD_FADE_SECONDS : END_CARD_RECEDE;
541
+ return Math.max(0.1, Math.min(3, e.seconds ?? house));
542
+ }
543
+ function withHolds(docSegments, rated) {
544
+ const out = [];
545
+ const pending = docSegments.filter((s) => typeof s.hold === "number" && s.hold > 0).map((s) => ({ out: s.out, hold: s.hold, used: false }));
546
+ for (let i = 0; i < rated.length; i++) {
547
+ const piece = rated[i];
548
+ out.push(piece);
549
+ const next = rated[i + 1];
550
+ for (const p of pending) {
551
+ if (p.used) continue;
552
+ const endsHere = Math.abs(piece.out - p.out) < 1e-9;
553
+ const nextContinues = next && Math.abs(next.in - p.out) < 1e-9 && next.out > p.out;
554
+ if (endsHere && !nextContinues) {
555
+ out.push({
556
+ in: p.out - HOLD_SOURCE_SPAN,
557
+ out: p.out,
558
+ rate: HOLD_SOURCE_SPAN / p.hold
559
+ });
560
+ p.used = true;
561
+ }
562
+ }
563
+ }
564
+ return out;
565
+ }
566
+ function entranceTiltKeyframes(e) {
567
+ const s = entranceSeconds(e);
568
+ if (!s || e?.kind !== "tilt-in") return [];
569
+ return [
570
+ { t: 0, value: [...TILT_IN_POSE], ease: "none" },
571
+ { t: s, value: [0, 0], ease: "power3.out" }
572
+ ];
573
+ }
574
+ function entranceZoomKeyframes(e) {
575
+ const s = entranceSeconds(e);
576
+ if (!s) return [];
577
+ if (e?.kind === "pull-out")
578
+ return [
579
+ { t: 0, value: [PULL_OUT_LEVEL, 0.5, 0.42], ease: "none" },
580
+ { t: s, value: [1, 0.5, 0.5], ease: "power3.out" }
581
+ ];
582
+ return [
583
+ { t: 0, value: [1, 0.5, 0.5], ease: "none" },
584
+ { t: s, value: [1, 0.5, 0.5], ease: "none" }
585
+ ];
586
+ }
587
+ function prependEntrance(track, head) {
588
+ if (!head.length) return track;
589
+ const end = head[head.length - 1].t;
590
+ const rest = (track?.keyframes ?? []).filter((k) => k.t > end + 0.1);
591
+ return { keyframes: sortKeyframes([...head, ...rest]) };
592
+ }
593
+ function exitPose(e) {
594
+ return e?.kind === "fade" ? [1, 0, 0] : [0.9, -0.02, 0.22];
595
+ }
596
+ function cardPoseTrack(enter, exit, footageEnd, outputEnd2 = footageEnd) {
597
+ const keyframes = [];
598
+ const s = entranceSeconds(enter);
599
+ if (s) {
600
+ const from = enter?.kind === "rise" ? [0.96, 0.08, 0.7] : enter?.kind === "pull-out" || enter?.kind === "fade" ? enter.kind === "fade" ? [1, 0, 0] : [1, 0, 1] : [0.94, 0.05, 0.7];
601
+ keyframes.push({ t: 0, value: from, ease: "none" });
602
+ keyframes.push({ t: s, value: [1, 0, 1], ease: "power3.out" });
603
+ }
604
+ const x = exitSeconds(exit);
605
+ if (x > 0 && footageEnd > 0) {
606
+ const room = outputEnd2 - footageEnd;
607
+ const t0 = Math.max(s, room > 1e-6 ? footageEnd : footageEnd - x);
608
+ const t1 = room > 1e-6 ? t0 + Math.min(x, Math.max(0.1, room)) : t0 + x;
609
+ keyframes.push({ t: t0, value: [1, 0, 1], ease: "none" });
610
+ keyframes.push({ t: t1, value: exitPose(exit), ease: "power2.out" });
611
+ }
612
+ if (!keyframes.length) return void 0;
613
+ return { keyframes: sortKeyframes(keyframes) };
614
+ }
615
+ function endCardClips(card, doc, endStart) {
616
+ const seconds = Math.max(1, Math.min(8, card.seconds ?? END_CARD_SECONDS));
617
+ const anim = { enter: "rise", exit: "none" };
618
+ const clips = [];
619
+ const text = (id, body, preset, y, delay) => ({
620
+ id,
621
+ kind: "text",
622
+ text: body,
623
+ preset,
624
+ start: endStart + delay,
625
+ duration: Math.max(0.3, seconds - delay),
626
+ transform: { x: 0.5, y, scale: 1, rotation: 0 },
627
+ anim,
628
+ from: END_CARD_FROM,
629
+ align: "center",
630
+ ...card.ink ? { color: card.ink } : {},
631
+ shadow: 0
632
+ });
633
+ if (card.headline?.trim())
634
+ clips.push(text("endcard-title", card.headline.trim(), "title", 0.44, 0.35));
635
+ if (card.sub?.trim())
636
+ clips.push(text("endcard-sub", card.sub.trim(), "caption", 0.57, 0.5));
637
+ const mark = card.mark;
638
+ const wide = !!mark && mark.aspect > 2.2;
639
+ if (mark && mark.key) {
640
+ const size = resolveExportSize(doc);
641
+ const designW = 1080 * size.width / Math.max(1, size.height);
642
+ const hPx = wide ? 44 : 30;
643
+ const clip = {
644
+ id: "endcard-markimg",
645
+ kind: "image",
646
+ key: mark.key,
647
+ width: Math.min(0.9, hPx * Math.max(0.2, mark.aspect) / designW),
648
+ radius: 0,
649
+ shadow: "none",
650
+ start: endStart + 0.65,
651
+ duration: Math.max(0.3, seconds - 0.65),
652
+ transform: { x: 0.5, y: wide ? 0.88 : 0.835, scale: 1, rotation: 0 },
653
+ anim,
654
+ from: END_CARD_FROM
655
+ };
656
+ clips.push(clip);
657
+ }
658
+ if (card.wordmark?.trim() && !wide)
659
+ clips.push(
660
+ text(
661
+ "endcard-mark",
662
+ card.wordmark.trim(),
663
+ "label",
664
+ mark && mark.key ? 0.895 : 0.88,
665
+ 0.65
666
+ )
667
+ );
668
+ return clips;
669
+ }
670
+ function expandEndCard(doc, outputDuration) {
671
+ const card = doc.endCard;
672
+ if (!card || !doc.segments.length) return { doc, endStart: null, seconds: 0 };
673
+ const seconds = Math.max(1, Math.min(8, card.seconds ?? END_CARD_SECONDS));
674
+ const segments = doc.segments.map(
675
+ (s, i) => i === doc.segments.length - 1 ? { ...s, hold: (s.hold ?? 0) + seconds } : s
676
+ );
677
+ const endStart = outputDuration;
678
+ const overlays = [
679
+ ...doc.overlays ?? [],
680
+ ...endCardClips(card, doc, endStart)
681
+ ];
682
+ return { doc: { ...doc, segments, overlays }, endStart, seconds };
683
+ }
684
+ var hasLegacyClipMotion = (o) => o.enter !== void 0 || o.exit !== void 0 || o.kind === "text" && o.fx !== void 0;
685
+ function migrateClipAnim(o) {
686
+ const anim = { ...o.anim ?? {} };
687
+ if (o.kind === "text" && o.fx) {
688
+ const fx = o.fx;
689
+ anim.enter = {
690
+ kind: fx.fx,
691
+ unit: fx.unit ?? "block",
692
+ ...fx.direction !== void 0 ? { direction: fx.direction } : {},
693
+ ...fx.stagger !== void 0 ? { stagger: fx.stagger } : {},
694
+ ...fx.duration !== void 0 ? { seconds: fx.duration } : {}
695
+ };
696
+ } else if (o.enter !== void 0 && anim.enter === void 0) {
697
+ anim.enter = o.enter;
698
+ }
699
+ if (o.exit !== void 0 && anim.exit === void 0) anim.exit = o.exit;
700
+ return anim;
701
+ }
702
+ function migrateMotion(doc) {
703
+ const frame = doc.frame ?? {};
704
+ const segments = Array.isArray(doc.segments) ? doc.segments : [];
705
+ const overlays = Array.isArray(doc.overlays) ? doc.overlays : [];
706
+ const objects = Array.isArray(doc.objects) ? doc.objects : [];
707
+ const legacy = frame.entrance !== void 0 || doc.endCard !== void 0 || overlays.some(hasLegacyClipMotion) || objects.some((o) => o.animation !== void 0);
708
+ if (!legacy) return doc;
709
+ const out = { ...doc };
710
+ const nextFrame = { ...frame };
711
+ if (frame.entrance !== void 0) {
712
+ const e = frame.entrance;
713
+ if (e && e.kind !== "none") {
714
+ nextFrame.anim = {
715
+ ...frame.anim ?? {},
716
+ enter: e.seconds !== void 0 ? { kind: e.kind, seconds: e.seconds } : e.kind
717
+ };
718
+ }
719
+ delete nextFrame.entrance;
720
+ }
721
+ let nextOverlays = overlays.map((o) => {
722
+ if (!hasLegacyClipMotion(o)) return o;
723
+ const next = { ...o, anim: migrateClipAnim(o) };
724
+ delete next.enter;
725
+ delete next.exit;
726
+ if (next.kind === "text") delete next.fx;
727
+ return next;
728
+ });
729
+ if (doc.endCard !== void 0) {
730
+ const card = doc.endCard;
731
+ if (card && segments.length) {
732
+ const seconds = Math.max(1, Math.min(8, card.seconds ?? END_CARD_SECONDS));
733
+ const rated = withHolds(segments, splitBySpeed(segments, doc.speed ?? []));
734
+ const endStart = totalDuration(rated);
735
+ nextOverlays = [...nextOverlays, ...endCardClips(card, doc, endStart)];
736
+ nextFrame.anim = {
737
+ ...nextFrame.anim ?? {},
738
+ exit: {
739
+ kind: "recede",
740
+ seconds: Math.min(END_CARD_RECEDE, seconds * 0.4)
741
+ }
742
+ };
743
+ }
744
+ delete out.endCard;
745
+ }
746
+ const nextObjects = objects.map((o) => {
747
+ if (o.animation === void 0) return o;
748
+ const next = {
749
+ ...o,
750
+ anim: {
751
+ ...o.anim ?? {},
752
+ ...o.anim?.idle === void 0 ? { idle: o.animation ?? null } : {}
753
+ }
754
+ };
755
+ delete next.animation;
756
+ return next;
757
+ });
758
+ if (doc.frame !== void 0 || nextFrame.anim !== void 0)
759
+ out.frame = nextFrame;
760
+ if (doc.overlays !== void 0 || nextOverlays.length)
761
+ out.overlays = nextOverlays;
762
+ if (doc.objects !== void 0) out.objects = nextObjects;
763
+ return out;
764
+ }
765
+ function outputEnd(doc, footageEnd) {
766
+ return Math.max(footageEnd, lastLayerEnd(doc));
767
+ }
768
+
427
769
  // src/docVersion.ts
428
- var DOC_SCHEMA_VERSION = 2;
770
+ var DOC_SCHEMA_VERSION = 3;
429
771
  function migrateHostedDoc(raw) {
430
772
  const version = typeof raw.docSchemaVersion === "number" ? raw.docSchemaVersion : 0;
431
773
  if (version >= DOC_SCHEMA_VERSION) return raw;
432
- return { ...raw, docSchemaVersion: DOC_SCHEMA_VERSION };
774
+ let doc = raw;
775
+ if (version < 3 && doc.source && typeof doc.source === "object") {
776
+ doc = migrateMotion(doc);
777
+ }
778
+ return { ...doc, docSchemaVersion: DOC_SCHEMA_VERSION };
433
779
  }
434
780
 
435
781
  // src/doc/studioDoc.ts
@@ -1452,7 +1798,7 @@ function overlaySegments(text, unit) {
1452
1798
  }
1453
1799
  var FX_DIR = { forward: 0, reverse: 1, center: 2 };
1454
1800
  function resolveOverlayFx(clip, clipDuration) {
1455
- const spec = clip.fx;
1801
+ const spec = overlayFxSpec(clip) ?? clip.fx ?? null;
1456
1802
  if (!spec) return null;
1457
1803
  const unit = spec.unit ?? "block";
1458
1804
  const units = overlaySegments(clip.text, unit);
@@ -2772,164 +3118,6 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
2772
3118
 
2773
3119
  }`;
2774
3120
 
2775
- // src/lower/motion.ts
2776
- import { sortKeyframes } from "@vosjs/timeline";
2777
- import { splitBySpeed, totalDuration } from "@vosjs/timeline";
2778
- function docRestTime(doc) {
2779
- if (doc.endCard) return null;
2780
- const last = doc.segments.at(-1);
2781
- const hold = last?.hold;
2782
- if (typeof hold !== "number" || !(hold > 0)) return null;
2783
- const segs = doc.segments.length ? doc.segments : [{ in: 0, out: doc.source.meta.durationMs / 1e3 }];
2784
- const total = totalDuration(
2785
- withHolds(segs, splitBySpeed(segs, doc.speed ?? []))
2786
- );
2787
- const rest = total - hold;
2788
- if (!(rest >= 0)) return null;
2789
- return Math.round(rest * 1e3) / 1e3;
2790
- }
2791
- var ENTRANCE_SECONDS = 1.2;
2792
- var END_CARD_SECONDS = 2.5;
2793
- var END_CARD_RECEDE = 0.7;
2794
- var TILT_IN_POSE = [-9, 14];
2795
- var PULL_OUT_LEVEL = 1.32;
2796
- var HOLD_SOURCE_SPAN = 2e-3;
2797
- function entranceSeconds(e) {
2798
- if (!e || e.kind === "none") return 0;
2799
- return Math.max(0.2, Math.min(3, e.seconds ?? ENTRANCE_SECONDS));
2800
- }
2801
- function withHolds(docSegments, rated) {
2802
- const out = [];
2803
- const pending = docSegments.filter((s) => typeof s.hold === "number" && s.hold > 0).map((s) => ({ out: s.out, hold: s.hold, used: false }));
2804
- for (let i = 0; i < rated.length; i++) {
2805
- const piece = rated[i];
2806
- out.push(piece);
2807
- const next = rated[i + 1];
2808
- for (const p of pending) {
2809
- if (p.used) continue;
2810
- const endsHere = Math.abs(piece.out - p.out) < 1e-9;
2811
- const nextContinues = next && Math.abs(next.in - p.out) < 1e-9 && next.out > p.out;
2812
- if (endsHere && !nextContinues) {
2813
- out.push({
2814
- in: p.out - HOLD_SOURCE_SPAN,
2815
- out: p.out,
2816
- rate: HOLD_SOURCE_SPAN / p.hold
2817
- });
2818
- p.used = true;
2819
- }
2820
- }
2821
- }
2822
- return out;
2823
- }
2824
- function entranceTiltKeyframes(e) {
2825
- const s = entranceSeconds(e);
2826
- if (!s || e?.kind !== "tilt-in") return [];
2827
- return [
2828
- { t: 0, value: [...TILT_IN_POSE], ease: "none" },
2829
- { t: s, value: [0, 0], ease: "power3.out" }
2830
- ];
2831
- }
2832
- function entranceZoomKeyframes(e) {
2833
- const s = entranceSeconds(e);
2834
- if (!s) return [];
2835
- if (e?.kind === "pull-out")
2836
- return [
2837
- { t: 0, value: [PULL_OUT_LEVEL, 0.5, 0.42], ease: "none" },
2838
- { t: s, value: [1, 0.5, 0.5], ease: "power3.out" }
2839
- ];
2840
- return [
2841
- { t: 0, value: [1, 0.5, 0.5], ease: "none" },
2842
- { t: s, value: [1, 0.5, 0.5], ease: "none" }
2843
- ];
2844
- }
2845
- function prependEntrance(track, head) {
2846
- if (!head.length) return track;
2847
- const end = head[head.length - 1].t;
2848
- const rest = (track?.keyframes ?? []).filter((k) => k.t > end + 0.1);
2849
- return { keyframes: sortKeyframes([...head, ...rest]) };
2850
- }
2851
- function cardPoseTrack(e, endStart, endSeconds) {
2852
- const keyframes = [];
2853
- const s = entranceSeconds(e);
2854
- if (s) {
2855
- const from = e?.kind === "rise" ? [0.96, 0.08, 0.7] : e?.kind === "pull-out" ? [1, 0, 1] : [0.94, 0.05, 0.7];
2856
- keyframes.push({ t: 0, value: from, ease: "none" });
2857
- keyframes.push({ t: s, value: [1, 0, 1], ease: "power3.out" });
2858
- }
2859
- if (endStart !== null && endSeconds > 0) {
2860
- const t0 = Math.max(endStart, s);
2861
- keyframes.push({ t: t0, value: [1, 0, 1], ease: "none" });
2862
- keyframes.push({
2863
- t: t0 + Math.min(END_CARD_RECEDE, endSeconds * 0.4),
2864
- value: [0.9, -0.02, 0.22],
2865
- ease: "power2.out"
2866
- });
2867
- }
2868
- if (!keyframes.length) return void 0;
2869
- return { keyframes: sortKeyframes(keyframes) };
2870
- }
2871
- function expandEndCard(doc, outputDuration) {
2872
- const card = doc.endCard;
2873
- if (!card || !doc.segments.length) return { doc, endStart: null, seconds: 0 };
2874
- const seconds = Math.max(1, Math.min(8, card.seconds ?? END_CARD_SECONDS));
2875
- const segments = doc.segments.map(
2876
- (s, i) => i === doc.segments.length - 1 ? { ...s, hold: (s.hold ?? 0) + seconds } : s
2877
- );
2878
- const endStart = outputDuration;
2879
- const clips = [];
2880
- const text = (id, body, preset, y, delay) => ({
2881
- id,
2882
- kind: "text",
2883
- text: body,
2884
- preset,
2885
- start: endStart + delay,
2886
- duration: Math.max(0.3, seconds - delay),
2887
- transform: { x: 0.5, y, scale: 1, rotation: 0 },
2888
- enter: "rise",
2889
- exit: "none",
2890
- align: "center",
2891
- ...card.ink ? { color: card.ink } : {},
2892
- shadow: 0
2893
- });
2894
- if (card.headline?.trim())
2895
- clips.push(text("endcard-title", card.headline.trim(), "title", 0.44, 0.35));
2896
- if (card.sub?.trim())
2897
- clips.push(text("endcard-sub", card.sub.trim(), "caption", 0.57, 0.5));
2898
- const mark = card.mark;
2899
- const wide = !!mark && mark.aspect > 2.2;
2900
- if (mark && mark.key) {
2901
- const size = resolveExportSize(doc);
2902
- const designW = 1080 * size.width / Math.max(1, size.height);
2903
- const hPx = wide ? 44 : 30;
2904
- const clip = {
2905
- id: "endcard-markimg",
2906
- kind: "image",
2907
- key: mark.key,
2908
- width: Math.min(0.9, hPx * Math.max(0.2, mark.aspect) / designW),
2909
- radius: 0,
2910
- shadow: "none",
2911
- start: endStart + 0.65,
2912
- duration: Math.max(0.3, seconds - 0.65),
2913
- transform: { x: 0.5, y: wide ? 0.88 : 0.835, scale: 1, rotation: 0 },
2914
- enter: "rise",
2915
- exit: "none"
2916
- };
2917
- clips.push(clip);
2918
- }
2919
- if (card.wordmark?.trim() && !wide)
2920
- clips.push(
2921
- text(
2922
- "endcard-mark",
2923
- card.wordmark.trim(),
2924
- "label",
2925
- mark && mark.key ? 0.895 : 0.88,
2926
- 0.65
2927
- )
2928
- );
2929
- const overlays = [...doc.overlays ?? [], ...clips];
2930
- return { doc: { ...doc, segments, overlays }, endStart, seconds };
2931
- }
2932
-
2933
3121
  // src/lower/extractClicks.ts
2934
3122
  import { segmentRate, sourceToTimeline } from "@vosjs/timeline";
2935
3123
  var CLICK_FX_PRE = 0.06;
@@ -3772,6 +3960,15 @@ var ON_FRAME = `(ctx, content, dt) => {
3772
3960
  // range); the paused/seek path needs nothing \u2014 mapTime already lands srcT.
3773
3961
  var srcRate = TL.rateAt ? TL.rateAt(d.segments || [], t) : 1
3774
3962
  var playRate = Math.min(16, Math.max(0.0625, srcRate))
3963
+ // Where the FOOTAGE ends in output seconds. The output may outlast it
3964
+ // (clips placed after the footage), and a hold is a freeze piece whose
3965
+ // rate is tiny: in both the element must stay PAUSED on its frame. An
3966
+ // ended element's play() rewinds it to zero and the drift guard then
3967
+ // seeks it back, which reads as frames jumping back and forth; a hold
3968
+ // crawling at the clamped 1/16 speed toward that same end was the same
3969
+ // bug in slow motion.
3970
+ var footEnd = TL.totalDuration ? TL.totalDuration(d.segments || []) : Infinity
3971
+ var frozen = srcRate < 0.01 || t >= footEnd - 1e-3
3775
3972
  // Drive a <video> to the on-screen SOURCE moment: play natively while playing (drift
3776
3973
  // correction covers cut-boundary jumps), else step to the exact frame (registering a
3777
3974
  // decode promise so the deterministic export awaits it). Shared by the screen video
@@ -3779,7 +3976,7 @@ var ON_FRAME = `(ctx, content, dt) => {
3779
3976
  ${FRAME_STEP_SRC}
3780
3977
  function syncVid(vid) {
3781
3978
  try {
3782
- if (playing) {
3979
+ if (playing && !frozen) {
3783
3980
  if (vid.playbackRate !== playRate) {
3784
3981
  vid.playbackRate = playRate
3785
3982
  // Resampled (tape-style) speed, matching the export's offline mix.
@@ -4622,7 +4819,7 @@ function studioLayerData(layers, duration) {
4622
4819
  ry: round5(o.transform3d.ry),
4623
4820
  rz: round5(o.transform3d.rz),
4624
4821
  scale: round5(o.transform3d.scale || OBJECT_DEFAULT_SCALE),
4625
- anim: o.animation ?? null,
4822
+ anim: idleKey(o),
4626
4823
  // Pose keyframes: clip-local [x,y,z,rx,ry,rz,scale] track,
4627
4824
  // sampled at t − span.start. Omitted when absent — parity.
4628
4825
  ...(() => {
@@ -4654,8 +4851,8 @@ function studioLayerData(layers, duration) {
4654
4851
  y: round5(o.transform.y),
4655
4852
  scale: round5(o.transform.scale || 1),
4656
4853
  rot: round5(o.transform.rotation || 0),
4657
- enter: o.enter ?? "rise",
4658
- exit: o.exit ?? "fade",
4854
+ enter: enterKey(o),
4855
+ exit: exitKey(o),
4659
4856
  // Pose keyframes: a CLIP-LOCAL [x, y, scale, rot, opacity]
4660
4857
  // track, sampled in ON_FRAME at t − start. Omitted when the clip
4661
4858
  // has no motion — data byte parity.
@@ -4743,11 +4940,10 @@ function studioLayerData(layers, duration) {
4743
4940
  };
4744
4941
  }
4745
4942
  function lowerToComposition(input) {
4746
- const before = durationSec(input, ratedSegments(input));
4747
- const expanded = expandEndCard(input, before);
4748
- const doc = expanded.doc;
4943
+ const doc = migrateMotion(input);
4749
4944
  const rated = ratedSegments(doc);
4750
- const duration = durationSec(doc, rated);
4945
+ const footageEnd = durationSec(doc, rated);
4946
+ const duration = outputEnd(doc, footageEnd);
4751
4947
  const fx = doc.cursor.clickFx;
4752
4948
  const smoothed = smoothCursor(doc.source.cursor, {
4753
4949
  factor: doc.cursor.smoothing,
@@ -4821,7 +5017,7 @@ function lowerToComposition(input) {
4821
5017
  // A pull-out entrance writes the track's head.
4822
5018
  zoomTrack: prependEntrance(
4823
5019
  zoomTrackFromDoc(zoomSpans, rated, zoomStyle),
4824
- entranceZoomKeyframes(doc.frame.entrance)
5020
+ entranceZoomKeyframes(cardEnter(doc.frame))
4825
5021
  ),
4826
5022
  // Tilt spans: OUTPUT-time [rx, ry] degree track. The rest pose is
4827
5023
  // FLAT — there is no static card tilt any more — and the motion constants
@@ -4830,18 +5026,19 @@ function lowerToComposition(input) {
4830
5026
  // spans and no tilt-in entrance — byte parity. The entrance is a
4831
5027
  // producer of this track, never a second pose.
4832
5028
  ...(() => {
4833
- const head = entranceTiltKeyframes(doc.frame.entrance);
5029
+ const head = entranceTiltKeyframes(cardEnter(doc.frame));
4834
5030
  const spans = doc.tilt && doc.tilt.length ? tiltTrackFromDoc(doc.tilt, rated, zoomStyle.tilt) : void 0;
4835
5031
  const track = prependEntrance(spans, head);
4836
5032
  return track ? { tiltTrack: track } : {};
4837
5033
  })(),
4838
- // The card's pose through the entrance and the end card: [scale, dy,
4839
- // opacity] in output seconds. Absent when neither exists.
5034
+ // The card's pose through its enter and its exit: [scale, dy, opacity]
5035
+ // in output seconds. Absent when the card carries neither.
4840
5036
  ...(() => {
4841
5037
  const track = cardPoseTrack(
4842
- doc.frame.entrance,
4843
- expanded.endStart,
4844
- expanded.seconds
5038
+ cardEnter(doc.frame),
5039
+ cardExit(doc.frame),
5040
+ footageEnd,
5041
+ duration
4845
5042
  );
4846
5043
  return track ? { cardPoseTrack: track } : {};
4847
5044
  })()
@@ -5292,11 +5489,15 @@ function zoomCoversRect(span, rect, layout, tol = 0.02) {
5292
5489
  return rect.x >= w.x0 - tol && rect.x + rect.w <= w.x1 + tol && rect.y >= w.y0 - tol && rect.y + rect.h <= w.y1 + tol;
5293
5490
  }
5294
5491
 
5492
+ // src/digest/style.ts
5493
+ import { totalDuration as totalDuration4 } from "@vosjs/timeline";
5494
+
5295
5495
  // src/audioBeds.ts
5296
5496
  import { totalDuration as totalDuration3 } from "@vosjs/timeline";
5297
5497
  var round32 = (v) => Math.round(v * 1e3) / 1e3;
5298
5498
  function docOutputDuration(doc) {
5299
- return totalDuration3(ratedSegments(doc));
5499
+ const footage = totalDuration3(ratedSegments(doc));
5500
+ return isRecordingDoc(doc) ? outputEnd(migrateMotion(doc), footage) : footage;
5300
5501
  }
5301
5502
  function musicBedClip(input) {
5302
5503
  const track = round32(input.trackDuration);
@@ -5418,7 +5619,7 @@ var LAYOUT_FRAME_FIELDS = [
5418
5619
  "focus",
5419
5620
  "focusFollow",
5420
5621
  "parallax",
5421
- "entrance"
5622
+ "anim"
5422
5623
  ];
5423
5624
  var STAGE_CLIP_PREFIX = "stage-";
5424
5625
  var REST_TILT_ID = "rest";
@@ -5504,6 +5705,99 @@ function copyLayout(from, to, opts = {}) {
5504
5705
  if (typeof hold === "number" && hold > 0 && last) last.hold = hold;
5505
5706
  return { doc, notes };
5506
5707
  }
5708
+ var shiftBy = (at, templateEnd, takeEnd) => at === "start" ? 0 : at === "end" ? takeEnd - templateEnd : at;
5709
+ function clipsFrom(doc, from) {
5710
+ return {
5711
+ overlays: (doc.overlays ?? []).filter((o) => o.from === from),
5712
+ objects: (doc.objects ?? []).filter((o) => o.from === from),
5713
+ audio: (doc.audio ?? []).filter((a) => a.from === from)
5714
+ };
5715
+ }
5716
+ function dropTemplate(doc, from) {
5717
+ const has = clipsFrom(doc, from);
5718
+ if (!has.overlays.length && !has.objects.length && !has.audio.length)
5719
+ return doc;
5720
+ const out = { ...doc };
5721
+ if (doc.overlays) out.overlays = doc.overlays.filter((o) => o.from !== from);
5722
+ if (doc.objects) out.objects = doc.objects.filter((o) => o.from !== from);
5723
+ out.audio = doc.audio.filter((a) => a.from !== from);
5724
+ return out;
5725
+ }
5726
+ function applyTemplate(template, take, opts) {
5727
+ const from = opts.from ?? "template";
5728
+ const notes = [];
5729
+ const base = dropTemplate(take, from);
5730
+ const doc = structuredClone(base);
5731
+ const templateEnd = totalDuration4(ratedSegments(template));
5732
+ const takeEnd = totalDuration4(ratedSegments(take));
5733
+ const shift = shiftBy(opts.at, templateEnd, takeEnd);
5734
+ const own = new Map((take.overlays ?? []).map((o) => [o.id, o]));
5735
+ const overlays = (doc.overlays ?? []).filter(
5736
+ (o) => !(template.overlays ?? []).some((t) => t.id === o.id)
5737
+ );
5738
+ const skipped = [];
5739
+ for (const clip of template.overlays ?? []) {
5740
+ const next = structuredClone(clip);
5741
+ next.start = Math.max(0, round33(clip.start + shift));
5742
+ next.from = from;
5743
+ const mine = own.get(clip.id);
5744
+ if (next.kind === "text") {
5745
+ const word = opts.words?.[clip.id];
5746
+ if (word !== void 0) next.text = word;
5747
+ else if (mine?.kind === "text") next.text = mine.text;
5748
+ } else if (opts.keys?.[clip.id]) {
5749
+ next.key = opts.keys[clip.id];
5750
+ } else if (mine && mine.kind !== "text") {
5751
+ next.key = mine.key;
5752
+ } else if (!/^(https?:|\/\/)/.test(next.key)) {
5753
+ skipped.push(clip.id);
5754
+ continue;
5755
+ }
5756
+ overlays.push(next);
5757
+ }
5758
+ if (skipped.length)
5759
+ notes.push(
5760
+ `${skipped.join(", ")}: the template's media key is its own; pass keys[id] or the take's clip`
5761
+ );
5762
+ if (overlays.length || doc.overlays) doc.overlays = overlays;
5763
+ const objects = (doc.objects ?? []).filter(
5764
+ (o) => !(template.objects ?? []).some((t) => t.id === o.id)
5765
+ );
5766
+ for (const clip of template.objects ?? []) {
5767
+ const next = structuredClone(clip);
5768
+ if (next.span)
5769
+ next.span.start = Math.max(0, round33(next.span.start + shift));
5770
+ next.from = from;
5771
+ objects.push(next);
5772
+ }
5773
+ if (objects.length || doc.objects) doc.objects = objects;
5774
+ const audio = doc.audio.filter(
5775
+ (a) => !template.audio.some((t) => t.id === a.id)
5776
+ );
5777
+ for (const clip of template.audio) {
5778
+ const next = structuredClone(clip);
5779
+ next.start = Math.max(0, round33(clip.start + shift));
5780
+ next.from = from;
5781
+ audio.push(next);
5782
+ }
5783
+ doc.audio = audio;
5784
+ if (template.frame.anim) {
5785
+ doc.frame = {
5786
+ ...doc.frame,
5787
+ anim: { ...doc.frame.anim ?? {}, ...template.frame.anim }
5788
+ };
5789
+ }
5790
+ if (opts.look) {
5791
+ const laid = copyLayout(template, doc, { keys: opts.keys });
5792
+ laid.doc.overlays = (laid.doc.overlays ?? []).map(
5793
+ (o) => isStageClip(o) ? { ...o, from } : o
5794
+ );
5795
+ notes.push(...laid.notes);
5796
+ return { doc: laid.doc, notes };
5797
+ }
5798
+ return { doc, notes };
5799
+ }
5800
+ var round33 = (v) => Math.round(v * 1e3) / 1e3;
5507
5801
 
5508
5802
  // src/digest/geometry.ts
5509
5803
  var CROP_MIN_FRAC = 0.25;
@@ -5580,7 +5874,6 @@ function cropBox(m, geo, meta) {
5580
5874
  }
5581
5875
 
5582
5876
  // src/digest/build.ts
5583
- import { totalDuration as totalDuration4 } from "@vosjs/timeline";
5584
5877
  var DIGEST_VERSION = 1;
5585
5878
  function buildDigest(input) {
5586
5879
  const { doc, meta } = { doc: input.doc, meta: input.doc.source.meta };
@@ -5648,7 +5941,7 @@ function buildDigest(input) {
5648
5941
  };
5649
5942
  }
5650
5943
  function outputDurationOf(doc) {
5651
- return totalDuration4(ratedSegments(doc));
5944
+ return docOutputDuration(doc);
5652
5945
  }
5653
5946
  function round8(v) {
5654
5947
  return Math.round(v * 1e3) / 1e3;
@@ -7541,6 +7834,9 @@ export {
7541
7834
  CAM_SPAN_MIN,
7542
7835
  CAPTURE_COVERAGE_MIN,
7543
7836
  CARD_EDGE_OVERDRAW,
7837
+ CARD_ENTER_KINDS,
7838
+ CARD_EXIT_KINDS,
7839
+ CARD_FADE_SECONDS,
7544
7840
  CARD_FOV,
7545
7841
  CARD_Z,
7546
7842
  CHANNEL_SPECS_HASH,
@@ -7579,6 +7875,8 @@ export {
7579
7875
  DIGEST_VERSION,
7580
7876
  DOC_SCHEMA_VERSION,
7581
7877
  DRAG_FIT_LEVEL,
7878
+ END_CARD_FROM,
7879
+ END_CARD_RECEDE,
7582
7880
  END_CARD_SECONDS,
7583
7881
  ENTRANCE_SECONDS,
7584
7882
  EXPORT_RESOLUTION_OPTIONS,
@@ -7589,8 +7887,11 @@ export {
7589
7887
  FRAME_BORDER_DEFAULT,
7590
7888
  FRAME_BORDER_WIDTH_DEFAULT,
7591
7889
  HOUSE_GRADIENT,
7890
+ IDLE_KINDS,
7592
7891
  LAYOUT_FRAME_FIELDS,
7593
7892
  LOOK_KINDS,
7893
+ MEDIA_ENTER_KINDS,
7894
+ MEDIA_EXIT_KINDS,
7594
7895
  MINIMAL_BAR_THEMES,
7595
7896
  MOTION_DELTA,
7596
7897
  MOTION_EASE,
@@ -7621,6 +7922,8 @@ export {
7621
7922
  TEXT3D_DEPTH_DEFAULT,
7622
7923
  TEXT3D_DEPTH_MAX,
7623
7924
  TEXT3D_DEPTH_MIN,
7925
+ TEXT_ENTER_KINDS,
7926
+ TEXT_EXIT_KINDS,
7624
7927
  TEXT_PRESETS,
7625
7928
  TILT_AUTO_DEAD_ZONE,
7626
7929
  TILT_AUTO_MIN,
@@ -7651,7 +7954,9 @@ export {
7651
7954
  ZOOM_STYLE_OPTIONS,
7652
7955
  anchorKindOf,
7653
7956
  anchorSourceDuration,
7957
+ animStep,
7654
7958
  applyLook,
7959
+ applyTemplate,
7655
7960
  aspectRatioValue,
7656
7961
  audioLane,
7657
7962
  backdropMedia,
@@ -7663,6 +7968,8 @@ export {
7663
7968
  camRectFromPose,
7664
7969
  camRestPose,
7665
7970
  camTrackFromDoc,
7971
+ cardEnter,
7972
+ cardExit,
7666
7973
  cardInset,
7667
7974
  cardPointToScreen,
7668
7975
  cardPoseTrack,
@@ -7674,6 +7981,7 @@ export {
7674
7981
  clampZoomLevel,
7675
7982
  clipEnvelope,
7676
7983
  clipLength,
7984
+ clipsFrom,
7677
7985
  computeCardLayout,
7678
7986
  computeMicRms,
7679
7987
  computePeaks,
@@ -7689,11 +7997,18 @@ export {
7689
7997
  docRestTime,
7690
7998
  docToCropSpace,
7691
7999
  docToFullSpace,
8000
+ dropTemplate,
7692
8001
  duckCurve,
7693
8002
  effectiveSegments,
8003
+ endCardClips,
8004
+ enterKey,
8005
+ enterOf,
7694
8006
  entranceSeconds,
7695
8007
  envelopeAt,
7696
8008
  envelopeValueAt,
8009
+ exitKey,
8010
+ exitOf,
8011
+ exitSeconds,
7697
8012
  expandEndCard,
7698
8013
  expectedFrameSize,
7699
8014
  exportSizeFor,
@@ -7704,6 +8019,7 @@ export {
7704
8019
  hexToRgbTriplet,
7705
8020
  houseLook,
7706
8021
  idleGaps,
8022
+ idleKey,
7707
8023
  isLookKind,
7708
8024
  isMusicBed,
7709
8025
  isPlayback,
@@ -7711,6 +8027,8 @@ export {
7711
8027
  isRecordingDoc,
7712
8028
  isRejected,
7713
8029
  isStageClip,
8030
+ isUnitStep,
8031
+ lastLayerEnd,
7714
8032
  layoutOf,
7715
8033
  levelForFocusFraction,
7716
8034
  lookFromBrand,
@@ -7720,6 +8038,7 @@ export {
7720
8038
  lowerToComposition,
7721
8039
  micLane,
7722
8040
  migrateHostedDoc,
8041
+ migrateMotion,
7723
8042
  momentsFromDoc,
7724
8043
  motionTrack,
7725
8044
  musicBedClip,
@@ -7728,11 +8047,13 @@ export {
7728
8047
  objectMotionPoseAt,
7729
8048
  objectsLane,
7730
8049
  outputDurationOf,
8050
+ outputEnd,
7731
8051
  outputRangeToSource,
7732
8052
  overlapFraction,
7733
8053
  overlayFaceFor,
7734
8054
  overlayFontFaces,
7735
8055
  overlayFontString,
8056
+ overlayFxSpec,
7736
8057
  overlayHit,
7737
8058
  overlayLines,
7738
8059
  overlayMotionBase,