@vosjs/studio-core 0.4.1 → 0.5.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
@@ -153,6 +153,7 @@ var DEFAULT_FRAME_STYLE = BASE_FRAME_STYLE;
153
153
  var FRAME_BORDER_DEFAULT = 0.35;
154
154
  var FRAME_BORDER_WIDTH_DEFAULT = 1.5;
155
155
  var FRAME_BORDER_COLOR_DEFAULT = "#ffffff";
156
+ var CARD_EDGE_OVERDRAW = 1;
156
157
  function platformBarKind(platform) {
157
158
  return platform === "windows" || platform === "linux" ? "windows-light" : "mac-light";
158
159
  }
@@ -1099,7 +1100,7 @@ import {
1099
1100
  lerpArray,
1100
1101
  sample,
1101
1102
  segmentRate as segmentRate2,
1102
- sortKeyframes,
1103
+ sortKeyframes as sortKeyframes2,
1103
1104
  sourceToTimeline as sourceToTimeline2,
1104
1105
  splitBySpeed,
1105
1106
  totalDuration
@@ -1107,27 +1108,33 @@ import {
1107
1108
  import { timelineRuntimeCode as timelineRuntimeCode2 } from "@vosjs/timeline/bundle";
1108
1109
 
1109
1110
  // src/layout.ts
1110
- function computeCardLayout(frame, video, W, H) {
1111
+ function computeCardLayout(frame, video, W, H, zoomFocus) {
1111
1112
  const s = H / 1080;
1112
1113
  const pad = (frame.padding || 0) * s;
1114
+ const ins = frame.inset ?? {};
1115
+ const padL = ins.left == null ? pad : ins.left * W;
1116
+ const padR = ins.right == null ? pad : ins.right * W;
1117
+ const padT = ins.top == null ? pad : ins.top * H;
1118
+ const padB = ins.bottom == null ? pad : ins.bottom * H;
1113
1119
  const bar = frame.browserBar;
1114
1120
  const vw = video.width || 16;
1115
1121
  const vh = video.height || 9;
1116
1122
  const fitCover = frame.fit === "cover";
1117
1123
  const cf = fitCover ? 1 : Math.min(1, W / H / (vw / vh));
1118
1124
  const barH = bar.kind !== "none" ? (bar.height || 44) * s * cf : 0;
1119
- const availW = Math.max(1, W - pad * 2);
1120
- const availH = Math.max(1, H - pad * 2 - barH);
1125
+ const availW = Math.max(1, W - padL - padR);
1126
+ const availH = Math.max(1, H - padT - padB - barH);
1121
1127
  if (fitCover) {
1122
1128
  const sc2 = Math.max(availW / vw, availH / vh);
1123
1129
  const dw2 = vw * sc2;
1124
1130
  const dh2 = vh * sc2;
1125
- const fcx = clamp013(frame.focus?.cx ?? 0.5);
1126
- const fcy = clamp013(frame.focus?.cy ?? 0.5);
1127
- const vTop = pad + barH;
1131
+ const follow = frame.focusFollow === "camera" && zoomFocus ? zoomFocus : null;
1132
+ const fcx = clamp013(follow ? follow.cx : frame.focus?.cx ?? 0.5);
1133
+ const fcy = clamp013(follow ? follow.cy : frame.focus?.cy ?? 0.5);
1134
+ const vTop = padT + barH;
1128
1135
  const dx2 = Math.min(
1129
- pad,
1130
- Math.max(pad + availW - dw2, pad + availW / 2 - fcx * dw2)
1136
+ padL,
1137
+ Math.max(padL + availW - dw2, padL + availW / 2 - fcx * dw2)
1131
1138
  );
1132
1139
  const dy2 = Math.min(
1133
1140
  vTop,
@@ -1140,8 +1147,8 @@ function computeCardLayout(frame, video, W, H) {
1140
1147
  dy: dy2,
1141
1148
  dw: dw2,
1142
1149
  dh: dh2,
1143
- cardX: pad,
1144
- cardY: pad,
1150
+ cardX: padL,
1151
+ cardY: padT,
1145
1152
  cardW: availW,
1146
1153
  cardH: availH + barH
1147
1154
  };
@@ -1149,8 +1156,8 @@ function computeCardLayout(frame, video, W, H) {
1149
1156
  const sc = Math.min(availW / vw, availH / vh);
1150
1157
  const dw = vw * sc;
1151
1158
  const dh = vh * sc;
1152
- const dx = (W - dw) / 2;
1153
- const dy = (H - dh + barH) / 2;
1159
+ const dx = padL + (availW - dw) / 2;
1160
+ const dy = padT + barH + (availH - dh) / 2;
1154
1161
  return {
1155
1162
  W,
1156
1163
  H,
@@ -2741,6 +2748,117 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
2741
2748
 
2742
2749
  }`;
2743
2750
 
2751
+ // src/lower/motion.ts
2752
+ import { sortKeyframes } from "@vosjs/timeline";
2753
+ var ENTRANCE_SECONDS = 1.2;
2754
+ var END_CARD_SECONDS = 2.5;
2755
+ var END_CARD_RECEDE = 0.7;
2756
+ var TILT_IN_POSE = [-9, 14];
2757
+ var PULL_OUT_LEVEL = 1.32;
2758
+ var HOLD_SOURCE_SPAN = 2e-3;
2759
+ function entranceSeconds(e) {
2760
+ if (!e || e.kind === "none") return 0;
2761
+ return Math.max(0.2, Math.min(3, e.seconds ?? ENTRANCE_SECONDS));
2762
+ }
2763
+ function withHolds(docSegments, rated) {
2764
+ const out = [];
2765
+ const pending = docSegments.filter((s) => typeof s.hold === "number" && s.hold > 0).map((s) => ({ out: s.out, hold: s.hold, used: false }));
2766
+ for (let i = 0; i < rated.length; i++) {
2767
+ const piece = rated[i];
2768
+ out.push(piece);
2769
+ const next = rated[i + 1];
2770
+ for (const p of pending) {
2771
+ if (p.used) continue;
2772
+ const endsHere = Math.abs(piece.out - p.out) < 1e-9;
2773
+ const nextContinues = next && Math.abs(next.in - p.out) < 1e-9 && next.out > p.out;
2774
+ if (endsHere && !nextContinues) {
2775
+ out.push({
2776
+ in: p.out - HOLD_SOURCE_SPAN,
2777
+ out: p.out,
2778
+ rate: HOLD_SOURCE_SPAN / p.hold
2779
+ });
2780
+ p.used = true;
2781
+ }
2782
+ }
2783
+ }
2784
+ return out;
2785
+ }
2786
+ function entranceTiltKeyframes(e) {
2787
+ const s = entranceSeconds(e);
2788
+ if (!s || e?.kind !== "tilt-in") return [];
2789
+ return [
2790
+ { t: 0, value: [...TILT_IN_POSE], ease: "none" },
2791
+ { t: s, value: [0, 0], ease: "power3.out" }
2792
+ ];
2793
+ }
2794
+ function entranceZoomKeyframes(e) {
2795
+ const s = entranceSeconds(e);
2796
+ if (!s) return [];
2797
+ if (e?.kind === "pull-out")
2798
+ return [
2799
+ { t: 0, value: [PULL_OUT_LEVEL, 0.5, 0.42], ease: "none" },
2800
+ { t: s, value: [1, 0.5, 0.5], ease: "power3.out" }
2801
+ ];
2802
+ return [
2803
+ { t: 0, value: [1, 0.5, 0.5], ease: "none" },
2804
+ { t: s, value: [1, 0.5, 0.5], ease: "none" }
2805
+ ];
2806
+ }
2807
+ function prependEntrance(track, head) {
2808
+ if (!head.length) return track;
2809
+ const end = head[head.length - 1].t;
2810
+ const rest = (track?.keyframes ?? []).filter((k) => k.t > end + 0.1);
2811
+ return { keyframes: sortKeyframes([...head, ...rest]) };
2812
+ }
2813
+ function cardPoseTrack(e, endStart, endSeconds) {
2814
+ const keyframes = [];
2815
+ const s = entranceSeconds(e);
2816
+ if (s) {
2817
+ const from = e?.kind === "rise" ? [0.96, 0.08, 0.7] : e?.kind === "pull-out" ? [1, 0, 1] : [0.94, 0.05, 0.7];
2818
+ keyframes.push({ t: 0, value: from, ease: "none" });
2819
+ keyframes.push({ t: s, value: [1, 0, 1], ease: "power3.out" });
2820
+ }
2821
+ if (endStart !== null && endSeconds > 0) {
2822
+ const t0 = Math.max(endStart, s);
2823
+ keyframes.push({ t: t0, value: [1, 0, 1], ease: "none" });
2824
+ keyframes.push({
2825
+ t: t0 + Math.min(END_CARD_RECEDE, endSeconds * 0.4),
2826
+ value: [0.9, -0.02, 0.22],
2827
+ ease: "power2.out"
2828
+ });
2829
+ }
2830
+ if (!keyframes.length) return void 0;
2831
+ return { keyframes: sortKeyframes(keyframes) };
2832
+ }
2833
+ function expandEndCard(doc, outputDuration) {
2834
+ const card = doc.endCard;
2835
+ if (!card || !doc.segments.length) return { doc, endStart: null, seconds: 0 };
2836
+ const seconds = Math.max(1, Math.min(8, card.seconds ?? END_CARD_SECONDS));
2837
+ const segments = doc.segments.map(
2838
+ (s, i) => i === doc.segments.length - 1 ? { ...s, hold: (s.hold ?? 0) + seconds } : s
2839
+ );
2840
+ const endStart = outputDuration;
2841
+ const clips = [];
2842
+ const text = (id, body, preset, y, delay) => ({
2843
+ id,
2844
+ kind: "text",
2845
+ text: body,
2846
+ preset,
2847
+ start: endStart + delay,
2848
+ duration: Math.max(0.3, seconds - delay),
2849
+ transform: { x: 0.5, y, scale: 1, rotation: 0 },
2850
+ enter: "rise",
2851
+ exit: "none",
2852
+ align: "center",
2853
+ ...card.ink ? { color: card.ink } : {}
2854
+ });
2855
+ if (card.headline?.trim()) clips.push(text("endcard-title", card.headline.trim(), "title", 0.44, 0.35));
2856
+ if (card.sub?.trim()) clips.push(text("endcard-sub", card.sub.trim(), "caption", 0.57, 0.5));
2857
+ if (card.wordmark?.trim()) clips.push(text("endcard-mark", card.wordmark.trim(), "label", 0.88, 0.65));
2858
+ const overlays = [...doc.overlays ?? [], ...clips];
2859
+ return { doc: { ...doc, segments, overlays }, endStart, seconds };
2860
+ }
2861
+
2744
2862
  // src/lower/extractClicks.ts
2745
2863
  import { segmentRate, sourceToTimeline } from "@vosjs/timeline";
2746
2864
  var CLICK_FX_PRE = 0.06;
@@ -2845,7 +2963,7 @@ var CAM_PAN_EASE = ZOOM_PAN_EASE;
2845
2963
  var PROGRAM_DURATION = 1;
2846
2964
  function ratedSegments(doc) {
2847
2965
  const segs = isRecordingDoc(doc) ? doc.segments.length ? doc.segments : [{ in: 0, out: doc.source.meta.durationMs / 1e3 }] : [{ in: 0, out: programDuration(doc) }];
2848
- return splitBySpeed(segs, doc.speed ?? []);
2966
+ return withHolds(segs, splitBySpeed(segs, doc.speed ?? []));
2849
2967
  }
2850
2968
  function durationSec(doc, rated) {
2851
2969
  const trimmed = totalDuration(rated);
@@ -2937,7 +3055,7 @@ function zoomTrackFromDoc(zoom, segments, style = ZOOM_STYLES[DEFAULT_ZOOM_STYLE
2937
3055
  chained = false;
2938
3056
  }
2939
3057
  }
2940
- return { keyframes: sortKeyframes(keyframes) };
3058
+ return { keyframes: sortKeyframes2(keyframes) };
2941
3059
  }
2942
3060
  function sameVec(a, b) {
2943
3061
  return a.length === b.length && a.every((v, i) => Math.abs(v - b[i]) < 1e-6);
@@ -2978,7 +3096,7 @@ function tiltTrackFromDoc(tilt, segments, motion = {}) {
2978
3096
  chained = false;
2979
3097
  }
2980
3098
  }
2981
- return { keyframes: sortKeyframes(keyframes) };
3099
+ return { keyframes: sortKeyframes2(keyframes) };
2982
3100
  }
2983
3101
  function camRestPose(cam, W, H = 1080) {
2984
3102
  const r = camBubbleRect(cam, W, H);
@@ -3020,7 +3138,7 @@ function camTrackFromDoc(cam, spans, segments, W, H = 1080) {
3020
3138
  chained = false;
3021
3139
  }
3022
3140
  }
3023
- return { keyframes: sortKeyframes(keyframes) };
3141
+ return { keyframes: sortKeyframes2(keyframes) };
3024
3142
  }
3025
3143
  function camRectFromPose(pose, cam, W, H = 1080) {
3026
3144
  const s = H / 1080;
@@ -3060,7 +3178,7 @@ function motionTrack(base, keys, dur) {
3060
3178
  spanEase(k.ease, MOTION_EASE)
3061
3179
  );
3062
3180
  }
3063
- return { keyframes: sortKeyframes(keyframes) };
3181
+ return { keyframes: sortKeyframes2(keyframes) };
3064
3182
  }
3065
3183
  function overlayMotionBase(o) {
3066
3184
  return [
@@ -3750,6 +3868,14 @@ var ON_FRAME = `(ctx, content, dt) => {
3750
3868
  // strip is part of the card: it takes barH from the available height and the video
3751
3869
  // sits below it \u2014 bar + video share the rounded clip and zoom together.
3752
3870
  var pad = (frame.padding || 0) * s
3871
+ // Per-side placement (frame.inset: fractions of the frame, a negative side
3872
+ // bleeds the card past the edge) overrides the symmetric padding on the
3873
+ // sides it names; absent sides keep pad, so the old math falls out.
3874
+ var ipIns = frame.inset || {}
3875
+ var ipL = ipIns.left == null ? pad : ipIns.left * W
3876
+ var ipR = ipIns.right == null ? pad : ipIns.right * W
3877
+ var ipT = ipIns.top == null ? pad : ipIns.top * H
3878
+ var ipB = ipIns.bottom == null ? pad : ipIns.bottom * H
3753
3879
  var bar = frame.browserBar || {}
3754
3880
  // Window takes carry a viewport crop (drawImage source rect, capture px) that
3755
3881
  // removes the real browser chrome \u2014 the card's source dims are then the CROP
@@ -3775,35 +3901,48 @@ var ON_FRAME = `(ctx, content, dt) => {
3775
3901
  var fitCover = frame.fit === 'cover'
3776
3902
  var cf = fitCover ? 1 : Math.min(1, (W / H) / (vw / vh))
3777
3903
  var s2 = s * cf
3904
+ // current zoom \u2014 a standard keyframe track in OUTPUT time (hold + arrival pairs
3905
+ // expanded by the lowering), sampled with the shared deterministic interpolator.
3906
+ // Sampled here, before the rect math, so a cover crop can follow it.
3907
+ var lvl = 1, zx = 0.5, zy = 0.5
3908
+ var zt = d.zoomTrack
3909
+ if (zt && zt.keyframes && zt.keyframes.length) {
3910
+ var z = TL.sample(zt, t, TL.lerpArray)
3911
+ lvl = z[0]; zx = z[1]; zy = z[2]
3912
+ }
3778
3913
  var barH = bar.kind && bar.kind !== 'none' ? (bar.height || 44) * s2 : 0
3779
- var availW = Math.max(1, W - pad * 2), availH = Math.max(1, H - pad * 2 - barH)
3914
+ var availW = Math.max(1, W - ipL - ipR), availH = Math.max(1, H - ipT - ipB - barH)
3780
3915
  var sc, dw, dh, dx, dy, cardX, cardY, cardW, cardH
3781
3916
  if (fitCover) {
3782
3917
  sc = Math.max(availW / vw, availH / vh)
3783
3918
  dw = vw * sc; dh = vh * sc
3784
- cardX = pad; cardY = pad; cardW = availW; cardH = availH + barH
3919
+ cardX = ipL; cardY = ipT; cardW = availW; cardH = availH + barH
3785
3920
  var fcv = frame.focus || {}
3786
3921
  var fcx = fcv.cx == null ? 0.5 : Math.max(0, Math.min(1, fcv.cx))
3787
3922
  var fcy = fcv.cy == null ? 0.5 : Math.max(0, Math.min(1, fcv.cy))
3788
- var vTop = pad + barH
3923
+ // focusFollow: the crop keeps the camera's focus in frame (a 9:16 cut).
3924
+ if (frame.focusFollow === 'camera' && zt && zt.keyframes && zt.keyframes.length) { fcx = zx; fcy = zy }
3925
+ var vTop = ipT + barH
3789
3926
  dx = Math.min(cardX, Math.max(cardX + availW - dw, cardX + availW / 2 - fcx * dw))
3790
3927
  dy = Math.min(vTop, Math.max(vTop + availH - dh, vTop + availH / 2 - fcy * dh))
3791
3928
  } else {
3792
3929
  sc = Math.min(availW / vw, availH / vh)
3793
3930
  dw = vw * sc; dh = vh * sc
3794
- dx = (W - dw) / 2; dy = (H - dh + barH) / 2
3931
+ // Centred inside the inset area (equal to (W - dw) / 2 when no side is
3932
+ // inset, the old formula).
3933
+ dx = ipL + (availW - dw) / 2; dy = ipT + barH + (availH - dh) / 2
3795
3934
  cardX = dx; cardY = dy - barH; cardW = dw; cardH = dh + barH
3796
3935
  }
3797
3936
  var radius = (frame.radius || 0) * s2
3798
3937
  var shadow = frame.shadow || 0
3799
-
3800
- // current zoom \u2014 a standard keyframe track in OUTPUT time (hold + arrival pairs
3801
- // expanded by the lowering), sampled with the shared deterministic interpolator.
3802
- var lvl = 1, zx = 0.5, zy = 0.5
3803
- var zt = d.zoomTrack
3804
- if (zt && zt.keyframes && zt.keyframes.length) {
3805
- var z = TL.sample(zt, t, TL.lerpArray)
3806
- lvl = z[0]; zx = z[1]; zy = z[2]
3938
+ // The shadow's colour (a #rrggbb; the strengths are its alpha) and the
3939
+ // optional tight CONTACT layer that makes a light card sit on a light
3940
+ // ground.
3941
+ var shC = frame.shadowContact || 0
3942
+ var shRgb = '0,0,0'
3943
+ var shHex = frame.shadowColor
3944
+ if (typeof shHex === 'string' && /^#[0-9a-fA-F]{6}$/.test(shHex)) {
3945
+ shRgb = parseInt(shHex.slice(1, 3), 16) + ',' + parseInt(shHex.slice(3, 5), 16) + ',' + parseInt(shHex.slice(5, 7), 16)
3807
3946
  }
3808
3947
 
3809
3948
  function rr(x, y, w, h, rd, cx) {
@@ -3815,19 +3954,49 @@ var ON_FRAME = `(ctx, content, dt) => {
3815
3954
  c.save()
3816
3955
  // d.zoomSuppressed = editor aiming mode: the host merges it into ctx.data
3817
3956
  // while the focus overlay is up so the full frame renders. Never persisted.
3957
+ // ceZs = the zoom transform's device scale, for the two values below that
3958
+ // are sized in DEVICE pixels inside a scaled user space.
3959
+ var ceZs = 1
3818
3960
  if (lvl > 1.001 && !d.zoomSuppressed) {
3819
3961
  var fx = dx + zx * dw, fy = dy + zy * dh
3820
3962
  c.translate(fx, fy); c.scale(lvl, lvl); c.translate(-fx, -fy)
3821
- }
3963
+ ceZs = lvl
3964
+ }
3965
+ // The card's shadows are cast by a body drawn OFF-CANVAS and brought back
3966
+ // by shadowOffsetX alone. Shadow offsets are device space, untouched by
3967
+ // the transform, so the body moves by shD / ceZs in user space and the
3968
+ // offset is shD; shD clears the canvas by the card's own device size, so
3969
+ // no part of the body lands on it at any zoom. Painting the body in place
3970
+ // (opaque black under the footage) left a 1px dark seam along every
3971
+ // straight card edge at a fractional coordinate: the fill's anti-aliased
3972
+ // rim showed through where the clipped footage did not cover it, and the
3973
+ // corners, drawn by the clip alone, hid theirs inside the arc.
3974
+ var shD = ceZs * (cardW + cardH) + 2 * (W + H)
3975
+ var shX = cardX - shD / ceZs
3822
3976
  // soft shadow behind the card (bar strip + video)
3823
3977
  if (shadow > 0) {
3824
3978
  c.save()
3825
- c.shadowColor = 'rgba(0,0,0,' + shadow + ')'
3826
- c.shadowBlur = 60 * s2; c.shadowOffsetY = 24 * s2
3979
+ c.shadowColor = 'rgba(' + shRgb + ',' + shadow + ')'
3980
+ c.shadowBlur = 60 * s2; c.shadowOffsetX = shD; c.shadowOffsetY = 24 * s2
3827
3981
  c.fillStyle = '#000'
3828
- rr(cardX, cardY, cardW, cardH, radius); c.fill()
3982
+ rr(shX, cardY, cardW, cardH, radius); c.fill()
3829
3983
  c.restore()
3830
3984
  }
3985
+ // contact shadow: tight and close, over the ambient layer
3986
+ if (shC > 0) {
3987
+ c.save()
3988
+ c.shadowColor = 'rgba(' + shRgb + ',' + shC + ')'
3989
+ c.shadowBlur = 10 * s2; c.shadowOffsetX = shD; c.shadowOffsetY = 3 * s2
3990
+ c.fillStyle = '#000'
3991
+ rr(shX, cardY, cardW, cardH, radius); c.fill()
3992
+ c.restore()
3993
+ }
3994
+ // The footage and the bar fill overdraw the clip by one device pixel
3995
+ // (CARD_EDGE_OVERDRAW) so the clip is the only edge: a destination rect
3996
+ // that lands exactly on the clip path anti-aliases the edge twice at a
3997
+ // fractional coordinate and leaves a half-covered seam pixel.
3998
+ var ceOv = ${CARD_EDGE_OVERDRAW} / ceZs
3999
+ var ceX = dx - ceOv, ceY = dy - ceOv, ceW = dw + 2 * ceOv, ceH = dh + 2 * ceOv
3831
4000
  // video, then bar, clipped to the card's rounded corners. The bar draws
3832
4001
  // AFTER the footage: under cover the video rect can overflow ABOVE the
3833
4002
  // bar strip (a vertical crop), and bar-first let the footage paint over
@@ -3840,9 +4009,9 @@ var ON_FRAME = `(ctx, content, dt) => {
3840
4009
  // dimension-checked at attach); false \u21D2 element path (pre-first-seek,
3841
4010
  // or the provider never attached).
3842
4011
  var wcp3 = video.__voilaWc
3843
- if (!(wcp3 && wcp3.draw(c, crp, dx, dy, dw, dh))) {
3844
- if (crp) c.drawImage(video, crp.x, crp.y, crp.w, crp.h, dx, dy, dw, dh)
3845
- else c.drawImage(video, dx, dy, dw, dh)
4012
+ if (!(wcp3 && wcp3.draw(c, crp, ceX, ceY, ceW, ceH))) {
4013
+ if (crp) c.drawImage(video, crp.x, crp.y, crp.w, crp.h, ceX, ceY, ceW, ceH)
4014
+ else c.drawImage(video, ceX, ceY, ceW, ceH)
3846
4015
  }
3847
4016
  } catch (e) {}
3848
4017
  if (barH > 0) {
@@ -3852,7 +4021,7 @@ var ON_FRAME = `(ctx, content, dt) => {
3852
4021
  // absent = the built-in graphite look
3853
4022
  var thm = (minimal && bar.theme) || null
3854
4023
  c.fillStyle = minimal ? (thm ? thm.bar : '#141417') : dark ? '#2a2a2e' : '#e9e9eb'
3855
- c.fillRect(cardX, cardY, cardW, barH)
4024
+ c.fillRect(cardX - ceOv, cardY - ceOv, cardW + 2 * ceOv, barH + ceOv)
3856
4025
  c.fillStyle = dark || (minimal && !(thm && thm.light)) ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'
3857
4026
  c.fillRect(cardX, cardY + barH - s2, cardW, s2) // hairline above the video
3858
4027
  var midY = cardY + barH / 2
@@ -4191,6 +4360,20 @@ var ON_FRAME = `(ctx, content, dt) => {
4191
4360
  }
4192
4361
  card.mesh.rotation.x = rx
4193
4362
  card.mesh.rotation.y = ry
4363
+ // The card's pose through an entrance or an end card: [scale, dy,
4364
+ // opacity]; absent = the rest pose, exactly as before the track existed.
4365
+ var cpk = d.cardPoseTrack
4366
+ var cpH = card.mesh.geometry && card.mesh.geometry.parameters ? card.mesh.geometry.parameters.height : 0
4367
+ if (cpk && cpk.keyframes && cpk.keyframes.length && card.mesh.scale && card.mesh.position) {
4368
+ var cpv = TL.sample(cpk, t, TL.lerpArray)
4369
+ card.mesh.scale.x = cpv[0]; card.mesh.scale.y = cpv[0]
4370
+ card.mesh.position.y = cpv[1] * cpH
4371
+ if (card.mesh.material) card.mesh.material.opacity = cpv[2]
4372
+ } else if (card.mesh.scale && card.mesh.position && ((card.mesh.scale.x !== undefined && card.mesh.scale.x !== 1) || (card.mesh.position.y !== undefined && card.mesh.position.y !== 0))) {
4373
+ card.mesh.scale.x = 1; card.mesh.scale.y = 1
4374
+ card.mesh.position.y = 0
4375
+ if (card.mesh.material) card.mesh.material.opacity = 1
4376
+ }
4194
4377
  var tilted = rx * rx + ry * ry > 1e-6
4195
4378
  if (card.texture && card.texture.generateMipmaps !== tilted) {
4196
4379
  var THREE2 = ctx.THREE
@@ -4372,7 +4555,10 @@ function studioLayerData(layers, duration) {
4372
4555
  } : {}
4373
4556
  };
4374
4557
  }
4375
- function lowerToComposition(doc) {
4558
+ function lowerToComposition(input) {
4559
+ const before = durationSec(input, ratedSegments(input));
4560
+ const expanded = expandEndCard(input, before);
4561
+ const doc = expanded.doc;
4376
4562
  const rated = ratedSegments(doc);
4377
4563
  const duration = durationSec(doc, rated);
4378
4564
  const fx = doc.cursor.clickFx;
@@ -4445,15 +4631,33 @@ function lowerToComposition(doc) {
4445
4631
  // intensities/colors become numbers HERE — ON_FRAME reads no registry).
4446
4632
  ...clickFxData(doc, rated),
4447
4633
  // Rated segments so zoom spans land at their speed-adjusted output times.
4448
- zoomTrack: zoomTrackFromDoc(zoomSpans, rated, zoomStyle),
4634
+ // A pull-out entrance writes the track's head.
4635
+ zoomTrack: prependEntrance(
4636
+ zoomTrackFromDoc(zoomSpans, rated, zoomStyle),
4637
+ entranceZoomKeyframes(doc.frame.entrance)
4638
+ ),
4449
4639
  // Tilt spans: OUTPUT-time [rx, ry] degree track. The rest pose is
4450
4640
  // FLAT — there is no static card tilt any more — and the motion constants
4451
4641
  // come from the camera style's tilt personality ('drift' slows its
4452
4642
  // leans, 'keynote' matches the zoom ramps). Omitted when the doc has no
4453
- // spans — byte parity.
4454
- ...doc.tilt && doc.tilt.length ? {
4455
- tiltTrack: tiltTrackFromDoc(doc.tilt, rated, zoomStyle.tilt)
4456
- } : {}
4643
+ // spans and no tilt-in entrance — byte parity. The entrance is a
4644
+ // producer of this track, never a second pose.
4645
+ ...(() => {
4646
+ const head = entranceTiltKeyframes(doc.frame.entrance);
4647
+ const spans = doc.tilt && doc.tilt.length ? tiltTrackFromDoc(doc.tilt, rated, zoomStyle.tilt) : void 0;
4648
+ const track = prependEntrance(spans, head);
4649
+ return track ? { tiltTrack: track } : {};
4650
+ })(),
4651
+ // The card's pose through the entrance and the end card: [scale, dy,
4652
+ // opacity] in output seconds. Absent when neither exists.
4653
+ ...(() => {
4654
+ const track = cardPoseTrack(
4655
+ doc.frame.entrance,
4656
+ expanded.endStart,
4657
+ expanded.seconds
4658
+ );
4659
+ return track ? { cardPoseTrack: track } : {};
4660
+ })()
4457
4661
  };
4458
4662
  const entryData = {
4459
4663
  lights: true,
@@ -5230,7 +5434,7 @@ function lowerProgramDoc(doc, opts = {}) {
5230
5434
 
5231
5435
  // src/destinations.ts
5232
5436
  var CHANNEL_SPECS_VERIFIED = "2026-08-04";
5233
- var CHANNEL_SPECS_HASH = "7a3ad4301f96507ec471a35fa37f367dcaf4bd405b008f551d0ac4cd395d61f7";
5437
+ var CHANNEL_SPECS_HASH = "e48926c22737b5dfaabf5cb33916a50a5eccd4b93498c2059a1468bd963a0a86";
5234
5438
  var DESTINATIONS = [
5235
5439
  {
5236
5440
  id: "youtube-main-demo",
@@ -5248,6 +5452,13 @@ var DESTINATIONS = [
5248
5452
  format: "mp4",
5249
5453
  accepts: "mp4 (H.264+AAC)",
5250
5454
  fit: "contain",
5455
+ text: "allowed",
5456
+ safe: {
5457
+ x: 0.05,
5458
+ y: 0.08,
5459
+ w: 0.9,
5460
+ h: 0.84
5461
+ },
5251
5462
  notes: "Captions burned in. The same public upload serves the Chrome Web Store promo video and Product Hunt video (both take YouTube URLs only)."
5252
5463
  },
5253
5464
  {
@@ -5267,6 +5478,13 @@ var DESTINATIONS = [
5267
5478
  format: "mp4",
5268
5479
  accepts: "mp4 (H.264+AAC ONLY \u2014 HEVC/VP9/AV1 rejected)",
5269
5480
  fit: "contain",
5481
+ text: "allowed",
5482
+ safe: {
5483
+ x: 0.05,
5484
+ y: 0.08,
5485
+ w: 0.9,
5486
+ h: 0.84
5487
+ },
5270
5488
  notes: "30\u201360s plays best; 140s/512MB is the free-tier ceiling. 1:1 also allowed. Native upload, never a link post."
5271
5489
  },
5272
5490
  {
@@ -5285,6 +5503,13 @@ var DESTINATIONS = [
5285
5503
  format: "mp4",
5286
5504
  accepts: "mp4 (H.264+AAC)",
5287
5505
  fit: "contain",
5506
+ text: "expected",
5507
+ safe: {
5508
+ x: 0.0833,
5509
+ y: 0.1979,
5510
+ w: 0.8333,
5511
+ h: 0.6042
5512
+ },
5288
5513
  notes: "Critical text inside a centered ~900\xD71160 safe zone \u2014 platform chrome covers the rest. Serves YouTube Shorts and LinkedIn native vertical."
5289
5514
  },
5290
5515
  {
@@ -5304,6 +5529,13 @@ var DESTINATIONS = [
5304
5529
  format: "mp4",
5305
5530
  accepts: "mp4 (H.264)",
5306
5531
  fit: "contain",
5532
+ text: "none",
5533
+ safe: {
5534
+ x: 0.05,
5535
+ y: 0.08,
5536
+ w: 0.9,
5537
+ h: 0.84
5538
+ },
5307
5539
  notes: "\u226410MB is the free-plan attachment ceiling; two-pass target bitrate from duration."
5308
5540
  },
5309
5541
  {
@@ -5322,6 +5554,14 @@ var DESTINATIONS = [
5322
5554
  format: "png",
5323
5555
  accepts: "png|jpg",
5324
5556
  fit: "cover",
5557
+ text: "expected",
5558
+ safe: {
5559
+ x: 0.05,
5560
+ y: 0.08,
5561
+ w: 0.9,
5562
+ h: 0.84
5563
+ },
5564
+ template: "split-cover",
5325
5565
  notes: ""
5326
5566
  },
5327
5567
  {
@@ -5343,6 +5583,13 @@ var DESTINATIONS = [
5343
5583
  format: "png",
5344
5584
  accepts: "png|jpg",
5345
5585
  fit: "cover",
5586
+ text: "none",
5587
+ safe: {
5588
+ x: 0,
5589
+ y: 0,
5590
+ w: 1,
5591
+ h: 1
5592
+ },
5346
5593
  notes: "Real UX only \u2014 misleading listing images are a removal-grade violation. Full bleed, square corners."
5347
5594
  },
5348
5595
  {
@@ -5360,6 +5607,14 @@ var DESTINATIONS = [
5360
5607
  format: "png",
5361
5608
  accepts: "png|jpg",
5362
5609
  fit: "cover",
5610
+ text: "none",
5611
+ safe: {
5612
+ x: 0,
5613
+ y: 0,
5614
+ w: 1,
5615
+ h: 1
5616
+ },
5617
+ template: "card-on-gradient",
5363
5618
  notes: "Listings without one rank lower. No text; fill the region; subject centered."
5364
5619
  },
5365
5620
  {
@@ -5377,6 +5632,14 @@ var DESTINATIONS = [
5377
5632
  format: "png",
5378
5633
  accepts: "png|jpg",
5379
5634
  fit: "cover",
5635
+ text: "allowed",
5636
+ safe: {
5637
+ x: 0.05,
5638
+ y: 0.08,
5639
+ w: 0.9,
5640
+ h: 0.84
5641
+ },
5642
+ template: "split-cover",
5380
5643
  notes: "Carousel eligibility."
5381
5644
  },
5382
5645
  {
@@ -5393,6 +5656,13 @@ var DESTINATIONS = [
5393
5656
  format: "png",
5394
5657
  accepts: "png",
5395
5658
  fit: "cover",
5659
+ text: "none",
5660
+ safe: {
5661
+ x: 0,
5662
+ y: 0,
5663
+ w: 1,
5664
+ h: 1
5665
+ },
5396
5666
  notes: "96\xD796 art + 16px transparent padding."
5397
5667
  },
5398
5668
  {
@@ -5411,6 +5681,14 @@ var DESTINATIONS = [
5411
5681
  format: "png",
5412
5682
  accepts: "gif|png",
5413
5683
  fit: "cover",
5684
+ text: "none",
5685
+ safe: {
5686
+ x: 0.06,
5687
+ y: 0.06,
5688
+ w: 0.88,
5689
+ h: 0.88
5690
+ },
5691
+ template: "card-on-gradient",
5414
5692
  notes: "GIF animates on hover only \u2014 the first frame must stand alone."
5415
5693
  },
5416
5694
  {
@@ -5432,6 +5710,13 @@ var DESTINATIONS = [
5432
5710
  format: "png",
5433
5711
  accepts: "png|jpg|gif",
5434
5712
  fit: "cover",
5713
+ text: "none",
5714
+ safe: {
5715
+ x: 0,
5716
+ y: 0,
5717
+ w: 1,
5718
+ h: 1
5719
+ },
5435
5720
  notes: "First image is the hero."
5436
5721
  },
5437
5722
  {
@@ -5449,6 +5734,14 @@ var DESTINATIONS = [
5449
5734
  format: "png",
5450
5735
  accepts: "png|jpg",
5451
5736
  fit: "cover",
5737
+ text: "allowed",
5738
+ safe: {
5739
+ x: 0.05,
5740
+ y: 0.08,
5741
+ w: 0.9,
5742
+ h: 0.84
5743
+ },
5744
+ template: "split-cover",
5452
5745
  notes: ""
5453
5746
  },
5454
5747
  {
@@ -5466,6 +5759,14 @@ var DESTINATIONS = [
5466
5759
  format: "png",
5467
5760
  accepts: "png|jpg",
5468
5761
  fit: "cover",
5762
+ text: "expected",
5763
+ safe: {
5764
+ x: 0.05,
5765
+ y: 0.08,
5766
+ w: 0.9,
5767
+ h: 0.84
5768
+ },
5769
+ template: "split-cover",
5469
5770
  notes: "1080\xD71350 vertical also performs in the mobile feed."
5470
5771
  },
5471
5772
  {
@@ -5484,6 +5785,14 @@ var DESTINATIONS = [
5484
5785
  format: "png",
5485
5786
  accepts: "png|jpg",
5486
5787
  fit: "cover",
5788
+ text: "expected",
5789
+ safe: {
5790
+ x: 0.05,
5791
+ y: 0.0238,
5792
+ w: 0.9,
5793
+ h: 0.9524
5794
+ },
5795
+ template: "split-cover",
5487
5796
  notes: "Text in the center ~1080\xD7600. Set twitter:card=summary_large_image explicitly \u2014 og:image alone gets the small card."
5488
5797
  },
5489
5798
  {
@@ -5502,6 +5811,14 @@ var DESTINATIONS = [
5502
5811
  format: "png",
5503
5812
  accepts: "png|jpg",
5504
5813
  fit: "cover",
5814
+ text: "expected",
5815
+ safe: {
5816
+ x: 0.0391,
5817
+ y: 0.0781,
5818
+ w: 0.9219,
5819
+ h: 0.8438
5820
+ },
5821
+ template: "card-on-gradient",
5505
5822
  notes: "Key text \u226550px from every edge."
5506
5823
  }
5507
5824
  ];
@@ -5556,6 +5873,131 @@ function studioAudioPlan(clips, duckEnv, duration, step = AUDIO_PLAN_STEP) {
5556
5873
  return { duration, step, tracks };
5557
5874
  }
5558
5875
 
5876
+ // src/look.ts
5877
+ var HOUSE_GRADIENT = "linear-gradient(135deg, #ff5148, #ffb03a)";
5878
+ var PLATE_GROUND = "#f0f2f4";
5879
+ var DARK_GROUND = "radial-gradient(ellipse at 82% 0%, #2a1d16, #07080b)";
5880
+ var LOOKS = {
5881
+ plate: {
5882
+ kind: "plate",
5883
+ ground: PLATE_GROUND,
5884
+ cardWidth: 0.84,
5885
+ headroom: 0.16,
5886
+ radius: 12,
5887
+ shadow: 0.22,
5888
+ shadowContact: 0.16,
5889
+ border: 0.08,
5890
+ borderColor: "#000000"
5891
+ },
5892
+ gradient: {
5893
+ kind: "gradient",
5894
+ ground: HOUSE_GRADIENT,
5895
+ cardWidth: 0.84,
5896
+ headroom: 0.16,
5897
+ radius: 12,
5898
+ shadow: 0.35,
5899
+ shadowContact: 0.2,
5900
+ border: 0
5901
+ },
5902
+ dark: {
5903
+ kind: "dark",
5904
+ ground: DARK_GROUND,
5905
+ cardWidth: 0.86,
5906
+ headroom: 0.14,
5907
+ radius: 12,
5908
+ shadow: 0.5,
5909
+ shadowContact: 0.3,
5910
+ shadowColor: "#000000",
5911
+ border: 0.12,
5912
+ borderColor: "#ffffff"
5913
+ }
5914
+ };
5915
+ var LOOK_KINDS = ["plate", "gradient", "dark"];
5916
+ function isLookKind(v) {
5917
+ return typeof v === "string" && LOOK_KINDS.includes(v);
5918
+ }
5919
+ function houseLook(kind) {
5920
+ return { ...LOOKS[kind] };
5921
+ }
5922
+ var HEX = /^#([0-9a-f]{6})$/i;
5923
+ function luma(hex) {
5924
+ const m = HEX.exec(hex.trim());
5925
+ if (!m) return null;
5926
+ const n = parseInt(m[1], 16);
5927
+ const r = n >> 16 & 255;
5928
+ const g = n >> 8 & 255;
5929
+ const b = n & 255;
5930
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
5931
+ }
5932
+ function lookKindForGround(bgA) {
5933
+ const l = bgA ? luma(bgA) : null;
5934
+ if (l === null) return "gradient";
5935
+ if (l >= 0.85) return "plate";
5936
+ if (l <= 0.2) return "dark";
5937
+ return "gradient";
5938
+ }
5939
+ function lookFromBrand(brand) {
5940
+ const b = brand ?? {};
5941
+ const kind = isLookKind(b.look) ? b.look : lookKindForGround(b.bgA);
5942
+ const look = houseLook(kind);
5943
+ if (kind === "plate" && b.bgB && HEX.test(b.bgB)) look.ground = b.bgB;
5944
+ if (kind === "gradient" && b.accent && b.bgC && HEX.test(b.accent)) {
5945
+ look.ground = `linear-gradient(135deg, ${b.accent}, ${b.bgC})`;
5946
+ }
5947
+ if (kind === "dark" && b.bgA && HEX.test(b.bgA)) {
5948
+ look.ground = `radial-gradient(ellipse at 82% 0%, ${mix(b.bgA, b.accent ?? "#ffffff", 0.18)}, ${b.bgA})`;
5949
+ }
5950
+ if (b.ground && b.ground.trim()) look.ground = b.ground.trim();
5951
+ return look;
5952
+ }
5953
+ function mix(a, b, t) {
5954
+ const pa = HEX.exec(a);
5955
+ const pb = HEX.exec(b);
5956
+ if (!pa || !pb) return a;
5957
+ const na = parseInt(pa[1], 16);
5958
+ const nb = parseInt(pb[1], 16);
5959
+ const ch = (sh) => {
5960
+ const x = na >> sh & 255;
5961
+ const y = nb >> sh & 255;
5962
+ return Math.round(x + (y - x) * t);
5963
+ };
5964
+ return `#${(ch(16) << 16 | ch(8) << 8 | ch(0)).toString(16).padStart(6, "0")}`;
5965
+ }
5966
+ function cardInset(look, frame, video, placement) {
5967
+ const R = frame.w / Math.max(1, frame.h);
5968
+ const V = video.w / Math.max(1, video.h);
5969
+ const cw = look.cardWidth;
5970
+ const ch = cw * R / V;
5971
+ const side = (1 - cw) / 2;
5972
+ if (placement === "card" && ch <= 1 - look.headroom) {
5973
+ const v = (1 - ch) / 2;
5974
+ return { left: side, right: side, top: v, bottom: v };
5975
+ }
5976
+ const top = look.headroom;
5977
+ const bottom = Math.min(-0.02, 1 - top - ch);
5978
+ return { left: side, right: side, top, bottom };
5979
+ }
5980
+ function applyLook(frame, look, size, video, placement) {
5981
+ const out = {
5982
+ ...frame,
5983
+ background: look.ground,
5984
+ fit: "contain",
5985
+ inset: cardInset(look, size, video, placement),
5986
+ radius: look.radius,
5987
+ shadow: look.shadow,
5988
+ shadowContact: look.shadowContact,
5989
+ border: look.border
5990
+ };
5991
+ delete out.focus;
5992
+ if (look.shadowColor) out.shadowColor = look.shadowColor;
5993
+ else delete out.shadowColor;
5994
+ if (look.border > 0) {
5995
+ out.borderWidth = 1;
5996
+ out.borderColor = look.borderColor ?? "#000000";
5997
+ }
5998
+ return out;
5999
+ }
6000
+
5559
6001
  // src/timeline/lanes.ts
5560
6002
  import {
5561
6003
  mapTime as mapTime2,
@@ -6811,6 +7253,7 @@ export {
6811
7253
  CAM_SIZE_MIN,
6812
7254
  CAM_SPAN_MIN,
6813
7255
  CAPTURE_COVERAGE_MIN,
7256
+ CARD_EDGE_OVERDRAW,
6814
7257
  CARD_FOV,
6815
7258
  CARD_Z,
6816
7259
  CHANNEL_SPECS_HASH,
@@ -6831,6 +7274,7 @@ export {
6831
7274
  CURSOR_IDLE_FADE_IN,
6832
7275
  CURSOR_IDLE_FADE_OUT,
6833
7276
  CURSOR_IDLE_HOLD,
7277
+ DARK_GROUND,
6834
7278
  DEFAULT_BROWSER_BAR,
6835
7279
  DEFAULT_CAM_POSE,
6836
7280
  DEFAULT_CAM_STYLE,
@@ -6848,6 +7292,8 @@ export {
6848
7292
  DIGEST_VERSION,
6849
7293
  DOC_SCHEMA_VERSION,
6850
7294
  DRAG_FIT_LEVEL,
7295
+ END_CARD_SECONDS,
7296
+ ENTRANCE_SECONDS,
6851
7297
  EXPORT_RESOLUTION_OPTIONS,
6852
7298
  EXPORT_SHORT_EDGE,
6853
7299
  FOLLOW_RECENTER,
@@ -6855,6 +7301,8 @@ export {
6855
7301
  FRAME_BORDER_COLOR_DEFAULT,
6856
7302
  FRAME_BORDER_DEFAULT,
6857
7303
  FRAME_BORDER_WIDTH_DEFAULT,
7304
+ HOUSE_GRADIENT,
7305
+ LOOK_KINDS,
6858
7306
  MINIMAL_BAR_THEMES,
6859
7307
  MOTION_DELTA,
6860
7308
  MOTION_EASE,
@@ -6869,6 +7317,7 @@ export {
6869
7317
  OVERLAY_SIZE_MIN,
6870
7318
  OVERLAY_TRANSITION_DUR,
6871
7319
  OVERLAY_Z,
7320
+ PLATE_GROUND,
6872
7321
  PLAYBACK_ACTIVITY,
6873
7322
  PROGRAM_RETIME,
6874
7323
  REJECT_OVERLAP,
@@ -6912,6 +7361,7 @@ export {
6912
7361
  ZOOM_STYLE_OPTIONS,
6913
7362
  anchorKindOf,
6914
7363
  anchorSourceDuration,
7364
+ applyLook,
6915
7365
  aspectRatioValue,
6916
7366
  audioLane,
6917
7367
  backdropMedia,
@@ -6923,7 +7373,9 @@ export {
6923
7373
  camRectFromPose,
6924
7374
  camRestPose,
6925
7375
  camTrackFromDoc,
7376
+ cardInset,
6926
7377
  cardPointToScreen,
7378
+ cardPoseTrack,
6927
7379
  clampCamFrac,
6928
7380
  clampCamSize,
6929
7381
  clampFocus,
@@ -6947,8 +7399,10 @@ export {
6947
7399
  docToFullSpace,
6948
7400
  duckCurve,
6949
7401
  effectiveSegments,
7402
+ entranceSeconds,
6950
7403
  envelopeAt,
6951
7404
  envelopeValueAt,
7405
+ expandEndCard,
6952
7406
  expectedFrameSize,
6953
7407
  exportSizeFor,
6954
7408
  extractClicks,
@@ -6956,13 +7410,17 @@ export {
6956
7410
  followFocusEvents,
6957
7411
  frameGeometry,
6958
7412
  hexToRgbTriplet,
7413
+ houseLook,
6959
7414
  idleGaps,
7415
+ isLookKind,
6960
7416
  isMusicBed,
6961
7417
  isPlayback,
6962
7418
  isProgramDoc,
6963
7419
  isRecordingDoc,
6964
7420
  isRejected,
6965
7421
  levelForFocusFraction,
7422
+ lookFromBrand,
7423
+ lookKindForGround,
6966
7424
  lowerProgramDoc,
6967
7425
  lowerStudioDoc,
6968
7426
  lowerToComposition,
@@ -7024,6 +7482,7 @@ export {
7024
7482
  videoLane,
7025
7483
  voiceKey,
7026
7484
  withBackdrop,
7485
+ withHolds,
7027
7486
  withoutRejected,
7028
7487
  wrapProgramLength,
7029
7488
  zoomCoversRect,