@vosjs/studio-core 0.4.1 → 0.5.0

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
@@ -1099,7 +1099,7 @@ import {
1099
1099
  lerpArray,
1100
1100
  sample,
1101
1101
  segmentRate as segmentRate2,
1102
- sortKeyframes,
1102
+ sortKeyframes as sortKeyframes2,
1103
1103
  sourceToTimeline as sourceToTimeline2,
1104
1104
  splitBySpeed,
1105
1105
  totalDuration
@@ -1107,27 +1107,33 @@ import {
1107
1107
  import { timelineRuntimeCode as timelineRuntimeCode2 } from "@vosjs/timeline/bundle";
1108
1108
 
1109
1109
  // src/layout.ts
1110
- function computeCardLayout(frame, video, W, H) {
1110
+ function computeCardLayout(frame, video, W, H, zoomFocus) {
1111
1111
  const s = H / 1080;
1112
1112
  const pad = (frame.padding || 0) * s;
1113
+ const ins = frame.inset ?? {};
1114
+ const padL = ins.left == null ? pad : ins.left * W;
1115
+ const padR = ins.right == null ? pad : ins.right * W;
1116
+ const padT = ins.top == null ? pad : ins.top * H;
1117
+ const padB = ins.bottom == null ? pad : ins.bottom * H;
1113
1118
  const bar = frame.browserBar;
1114
1119
  const vw = video.width || 16;
1115
1120
  const vh = video.height || 9;
1116
1121
  const fitCover = frame.fit === "cover";
1117
1122
  const cf = fitCover ? 1 : Math.min(1, W / H / (vw / vh));
1118
1123
  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);
1124
+ const availW = Math.max(1, W - padL - padR);
1125
+ const availH = Math.max(1, H - padT - padB - barH);
1121
1126
  if (fitCover) {
1122
1127
  const sc2 = Math.max(availW / vw, availH / vh);
1123
1128
  const dw2 = vw * sc2;
1124
1129
  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;
1130
+ const follow = frame.focusFollow === "camera" && zoomFocus ? zoomFocus : null;
1131
+ const fcx = clamp013(follow ? follow.cx : frame.focus?.cx ?? 0.5);
1132
+ const fcy = clamp013(follow ? follow.cy : frame.focus?.cy ?? 0.5);
1133
+ const vTop = padT + barH;
1128
1134
  const dx2 = Math.min(
1129
- pad,
1130
- Math.max(pad + availW - dw2, pad + availW / 2 - fcx * dw2)
1135
+ padL,
1136
+ Math.max(padL + availW - dw2, padL + availW / 2 - fcx * dw2)
1131
1137
  );
1132
1138
  const dy2 = Math.min(
1133
1139
  vTop,
@@ -1140,8 +1146,8 @@ function computeCardLayout(frame, video, W, H) {
1140
1146
  dy: dy2,
1141
1147
  dw: dw2,
1142
1148
  dh: dh2,
1143
- cardX: pad,
1144
- cardY: pad,
1149
+ cardX: padL,
1150
+ cardY: padT,
1145
1151
  cardW: availW,
1146
1152
  cardH: availH + barH
1147
1153
  };
@@ -1149,8 +1155,8 @@ function computeCardLayout(frame, video, W, H) {
1149
1155
  const sc = Math.min(availW / vw, availH / vh);
1150
1156
  const dw = vw * sc;
1151
1157
  const dh = vh * sc;
1152
- const dx = (W - dw) / 2;
1153
- const dy = (H - dh + barH) / 2;
1158
+ const dx = padL + (availW - dw) / 2;
1159
+ const dy = padT + barH + (availH - dh) / 2;
1154
1160
  return {
1155
1161
  W,
1156
1162
  H,
@@ -2741,6 +2747,117 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
2741
2747
 
2742
2748
  }`;
2743
2749
 
2750
+ // src/lower/motion.ts
2751
+ import { sortKeyframes } from "@vosjs/timeline";
2752
+ var ENTRANCE_SECONDS = 1.2;
2753
+ var END_CARD_SECONDS = 2.5;
2754
+ var END_CARD_RECEDE = 0.7;
2755
+ var TILT_IN_POSE = [-9, 14];
2756
+ var PULL_OUT_LEVEL = 1.32;
2757
+ var HOLD_SOURCE_SPAN = 2e-3;
2758
+ function entranceSeconds(e) {
2759
+ if (!e || e.kind === "none") return 0;
2760
+ return Math.max(0.2, Math.min(3, e.seconds ?? ENTRANCE_SECONDS));
2761
+ }
2762
+ function withHolds(docSegments, rated) {
2763
+ const out = [];
2764
+ const pending = docSegments.filter((s) => typeof s.hold === "number" && s.hold > 0).map((s) => ({ out: s.out, hold: s.hold, used: false }));
2765
+ for (let i = 0; i < rated.length; i++) {
2766
+ const piece = rated[i];
2767
+ out.push(piece);
2768
+ const next = rated[i + 1];
2769
+ for (const p of pending) {
2770
+ if (p.used) continue;
2771
+ const endsHere = Math.abs(piece.out - p.out) < 1e-9;
2772
+ const nextContinues = next && Math.abs(next.in - p.out) < 1e-9 && next.out > p.out;
2773
+ if (endsHere && !nextContinues) {
2774
+ out.push({
2775
+ in: p.out - HOLD_SOURCE_SPAN,
2776
+ out: p.out,
2777
+ rate: HOLD_SOURCE_SPAN / p.hold
2778
+ });
2779
+ p.used = true;
2780
+ }
2781
+ }
2782
+ }
2783
+ return out;
2784
+ }
2785
+ function entranceTiltKeyframes(e) {
2786
+ const s = entranceSeconds(e);
2787
+ if (!s || e?.kind !== "tilt-in") return [];
2788
+ return [
2789
+ { t: 0, value: [...TILT_IN_POSE], ease: "none" },
2790
+ { t: s, value: [0, 0], ease: "power3.out" }
2791
+ ];
2792
+ }
2793
+ function entranceZoomKeyframes(e) {
2794
+ const s = entranceSeconds(e);
2795
+ if (!s) return [];
2796
+ if (e?.kind === "pull-out")
2797
+ return [
2798
+ { t: 0, value: [PULL_OUT_LEVEL, 0.5, 0.42], ease: "none" },
2799
+ { t: s, value: [1, 0.5, 0.5], ease: "power3.out" }
2800
+ ];
2801
+ return [
2802
+ { t: 0, value: [1, 0.5, 0.5], ease: "none" },
2803
+ { t: s, value: [1, 0.5, 0.5], ease: "none" }
2804
+ ];
2805
+ }
2806
+ function prependEntrance(track, head) {
2807
+ if (!head.length) return track;
2808
+ const end = head[head.length - 1].t;
2809
+ const rest = (track?.keyframes ?? []).filter((k) => k.t > end + 0.1);
2810
+ return { keyframes: sortKeyframes([...head, ...rest]) };
2811
+ }
2812
+ function cardPoseTrack(e, endStart, endSeconds) {
2813
+ const keyframes = [];
2814
+ const s = entranceSeconds(e);
2815
+ if (s) {
2816
+ const from = e?.kind === "rise" ? [0.96, 0.08, 0.7] : e?.kind === "pull-out" ? [1, 0, 1] : [0.94, 0.05, 0.7];
2817
+ keyframes.push({ t: 0, value: from, ease: "none" });
2818
+ keyframes.push({ t: s, value: [1, 0, 1], ease: "power3.out" });
2819
+ }
2820
+ if (endStart !== null && endSeconds > 0) {
2821
+ const t0 = Math.max(endStart, s);
2822
+ keyframes.push({ t: t0, value: [1, 0, 1], ease: "none" });
2823
+ keyframes.push({
2824
+ t: t0 + Math.min(END_CARD_RECEDE, endSeconds * 0.4),
2825
+ value: [0.9, -0.02, 0.22],
2826
+ ease: "power2.out"
2827
+ });
2828
+ }
2829
+ if (!keyframes.length) return void 0;
2830
+ return { keyframes: sortKeyframes(keyframes) };
2831
+ }
2832
+ function expandEndCard(doc, outputDuration) {
2833
+ const card = doc.endCard;
2834
+ if (!card || !doc.segments.length) return { doc, endStart: null, seconds: 0 };
2835
+ const seconds = Math.max(1, Math.min(8, card.seconds ?? END_CARD_SECONDS));
2836
+ const segments = doc.segments.map(
2837
+ (s, i) => i === doc.segments.length - 1 ? { ...s, hold: (s.hold ?? 0) + seconds } : s
2838
+ );
2839
+ const endStart = outputDuration;
2840
+ const clips = [];
2841
+ const text = (id, body, preset, y, delay) => ({
2842
+ id,
2843
+ kind: "text",
2844
+ text: body,
2845
+ preset,
2846
+ start: endStart + delay,
2847
+ duration: Math.max(0.3, seconds - delay),
2848
+ transform: { x: 0.5, y, scale: 1, rotation: 0 },
2849
+ enter: "rise",
2850
+ exit: "none",
2851
+ align: "center",
2852
+ ...card.ink ? { color: card.ink } : {}
2853
+ });
2854
+ if (card.headline?.trim()) clips.push(text("endcard-title", card.headline.trim(), "title", 0.44, 0.35));
2855
+ if (card.sub?.trim()) clips.push(text("endcard-sub", card.sub.trim(), "caption", 0.57, 0.5));
2856
+ if (card.wordmark?.trim()) clips.push(text("endcard-mark", card.wordmark.trim(), "label", 0.88, 0.65));
2857
+ const overlays = [...doc.overlays ?? [], ...clips];
2858
+ return { doc: { ...doc, segments, overlays }, endStart, seconds };
2859
+ }
2860
+
2744
2861
  // src/lower/extractClicks.ts
2745
2862
  import { segmentRate, sourceToTimeline } from "@vosjs/timeline";
2746
2863
  var CLICK_FX_PRE = 0.06;
@@ -2845,7 +2962,7 @@ var CAM_PAN_EASE = ZOOM_PAN_EASE;
2845
2962
  var PROGRAM_DURATION = 1;
2846
2963
  function ratedSegments(doc) {
2847
2964
  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 ?? []);
2965
+ return withHolds(segs, splitBySpeed(segs, doc.speed ?? []));
2849
2966
  }
2850
2967
  function durationSec(doc, rated) {
2851
2968
  const trimmed = totalDuration(rated);
@@ -2937,7 +3054,7 @@ function zoomTrackFromDoc(zoom, segments, style = ZOOM_STYLES[DEFAULT_ZOOM_STYLE
2937
3054
  chained = false;
2938
3055
  }
2939
3056
  }
2940
- return { keyframes: sortKeyframes(keyframes) };
3057
+ return { keyframes: sortKeyframes2(keyframes) };
2941
3058
  }
2942
3059
  function sameVec(a, b) {
2943
3060
  return a.length === b.length && a.every((v, i) => Math.abs(v - b[i]) < 1e-6);
@@ -2978,7 +3095,7 @@ function tiltTrackFromDoc(tilt, segments, motion = {}) {
2978
3095
  chained = false;
2979
3096
  }
2980
3097
  }
2981
- return { keyframes: sortKeyframes(keyframes) };
3098
+ return { keyframes: sortKeyframes2(keyframes) };
2982
3099
  }
2983
3100
  function camRestPose(cam, W, H = 1080) {
2984
3101
  const r = camBubbleRect(cam, W, H);
@@ -3020,7 +3137,7 @@ function camTrackFromDoc(cam, spans, segments, W, H = 1080) {
3020
3137
  chained = false;
3021
3138
  }
3022
3139
  }
3023
- return { keyframes: sortKeyframes(keyframes) };
3140
+ return { keyframes: sortKeyframes2(keyframes) };
3024
3141
  }
3025
3142
  function camRectFromPose(pose, cam, W, H = 1080) {
3026
3143
  const s = H / 1080;
@@ -3060,7 +3177,7 @@ function motionTrack(base, keys, dur) {
3060
3177
  spanEase(k.ease, MOTION_EASE)
3061
3178
  );
3062
3179
  }
3063
- return { keyframes: sortKeyframes(keyframes) };
3180
+ return { keyframes: sortKeyframes2(keyframes) };
3064
3181
  }
3065
3182
  function overlayMotionBase(o) {
3066
3183
  return [
@@ -3750,6 +3867,14 @@ var ON_FRAME = `(ctx, content, dt) => {
3750
3867
  // strip is part of the card: it takes barH from the available height and the video
3751
3868
  // sits below it \u2014 bar + video share the rounded clip and zoom together.
3752
3869
  var pad = (frame.padding || 0) * s
3870
+ // Per-side placement (frame.inset: fractions of the frame, a negative side
3871
+ // bleeds the card past the edge) overrides the symmetric padding on the
3872
+ // sides it names; absent sides keep pad, so the old math falls out.
3873
+ var ipIns = frame.inset || {}
3874
+ var ipL = ipIns.left == null ? pad : ipIns.left * W
3875
+ var ipR = ipIns.right == null ? pad : ipIns.right * W
3876
+ var ipT = ipIns.top == null ? pad : ipIns.top * H
3877
+ var ipB = ipIns.bottom == null ? pad : ipIns.bottom * H
3753
3878
  var bar = frame.browserBar || {}
3754
3879
  // Window takes carry a viewport crop (drawImage source rect, capture px) that
3755
3880
  // removes the real browser chrome \u2014 the card's source dims are then the CROP
@@ -3775,35 +3900,48 @@ var ON_FRAME = `(ctx, content, dt) => {
3775
3900
  var fitCover = frame.fit === 'cover'
3776
3901
  var cf = fitCover ? 1 : Math.min(1, (W / H) / (vw / vh))
3777
3902
  var s2 = s * cf
3903
+ // current zoom \u2014 a standard keyframe track in OUTPUT time (hold + arrival pairs
3904
+ // expanded by the lowering), sampled with the shared deterministic interpolator.
3905
+ // Sampled here, before the rect math, so a cover crop can follow it.
3906
+ var lvl = 1, zx = 0.5, zy = 0.5
3907
+ var zt = d.zoomTrack
3908
+ if (zt && zt.keyframes && zt.keyframes.length) {
3909
+ var z = TL.sample(zt, t, TL.lerpArray)
3910
+ lvl = z[0]; zx = z[1]; zy = z[2]
3911
+ }
3778
3912
  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)
3913
+ var availW = Math.max(1, W - ipL - ipR), availH = Math.max(1, H - ipT - ipB - barH)
3780
3914
  var sc, dw, dh, dx, dy, cardX, cardY, cardW, cardH
3781
3915
  if (fitCover) {
3782
3916
  sc = Math.max(availW / vw, availH / vh)
3783
3917
  dw = vw * sc; dh = vh * sc
3784
- cardX = pad; cardY = pad; cardW = availW; cardH = availH + barH
3918
+ cardX = ipL; cardY = ipT; cardW = availW; cardH = availH + barH
3785
3919
  var fcv = frame.focus || {}
3786
3920
  var fcx = fcv.cx == null ? 0.5 : Math.max(0, Math.min(1, fcv.cx))
3787
3921
  var fcy = fcv.cy == null ? 0.5 : Math.max(0, Math.min(1, fcv.cy))
3788
- var vTop = pad + barH
3922
+ // focusFollow: the crop keeps the camera's focus in frame (a 9:16 cut).
3923
+ if (frame.focusFollow === 'camera' && zt && zt.keyframes && zt.keyframes.length) { fcx = zx; fcy = zy }
3924
+ var vTop = ipT + barH
3789
3925
  dx = Math.min(cardX, Math.max(cardX + availW - dw, cardX + availW / 2 - fcx * dw))
3790
3926
  dy = Math.min(vTop, Math.max(vTop + availH - dh, vTop + availH / 2 - fcy * dh))
3791
3927
  } else {
3792
3928
  sc = Math.min(availW / vw, availH / vh)
3793
3929
  dw = vw * sc; dh = vh * sc
3794
- dx = (W - dw) / 2; dy = (H - dh + barH) / 2
3930
+ // Centred inside the inset area (equal to (W - dw) / 2 when no side is
3931
+ // inset, the old formula).
3932
+ dx = ipL + (availW - dw) / 2; dy = ipT + barH + (availH - dh) / 2
3795
3933
  cardX = dx; cardY = dy - barH; cardW = dw; cardH = dh + barH
3796
3934
  }
3797
3935
  var radius = (frame.radius || 0) * s2
3798
3936
  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]
3937
+ // The shadow's colour (a #rrggbb; the strengths are its alpha) and the
3938
+ // optional tight CONTACT layer that makes a light card sit on a light
3939
+ // ground.
3940
+ var shC = frame.shadowContact || 0
3941
+ var shRgb = '0,0,0'
3942
+ var shHex = frame.shadowColor
3943
+ if (typeof shHex === 'string' && /^#[0-9a-fA-F]{6}$/.test(shHex)) {
3944
+ shRgb = parseInt(shHex.slice(1, 3), 16) + ',' + parseInt(shHex.slice(3, 5), 16) + ',' + parseInt(shHex.slice(5, 7), 16)
3807
3945
  }
3808
3946
 
3809
3947
  function rr(x, y, w, h, rd, cx) {
@@ -3822,12 +3960,21 @@ var ON_FRAME = `(ctx, content, dt) => {
3822
3960
  // soft shadow behind the card (bar strip + video)
3823
3961
  if (shadow > 0) {
3824
3962
  c.save()
3825
- c.shadowColor = 'rgba(0,0,0,' + shadow + ')'
3963
+ c.shadowColor = 'rgba(' + shRgb + ',' + shadow + ')'
3826
3964
  c.shadowBlur = 60 * s2; c.shadowOffsetY = 24 * s2
3827
3965
  c.fillStyle = '#000'
3828
3966
  rr(cardX, cardY, cardW, cardH, radius); c.fill()
3829
3967
  c.restore()
3830
3968
  }
3969
+ // contact shadow: tight and close, over the ambient layer
3970
+ if (shC > 0) {
3971
+ c.save()
3972
+ c.shadowColor = 'rgba(' + shRgb + ',' + shC + ')'
3973
+ c.shadowBlur = 10 * s2; c.shadowOffsetY = 3 * s2
3974
+ c.fillStyle = '#000'
3975
+ rr(cardX, cardY, cardW, cardH, radius); c.fill()
3976
+ c.restore()
3977
+ }
3831
3978
  // video, then bar, clipped to the card's rounded corners. The bar draws
3832
3979
  // AFTER the footage: under cover the video rect can overflow ABOVE the
3833
3980
  // bar strip (a vertical crop), and bar-first let the footage paint over
@@ -4191,6 +4338,20 @@ var ON_FRAME = `(ctx, content, dt) => {
4191
4338
  }
4192
4339
  card.mesh.rotation.x = rx
4193
4340
  card.mesh.rotation.y = ry
4341
+ // The card's pose through an entrance or an end card: [scale, dy,
4342
+ // opacity]; absent = the rest pose, exactly as before the track existed.
4343
+ var cpk = d.cardPoseTrack
4344
+ var cpH = card.mesh.geometry && card.mesh.geometry.parameters ? card.mesh.geometry.parameters.height : 0
4345
+ if (cpk && cpk.keyframes && cpk.keyframes.length && card.mesh.scale && card.mesh.position) {
4346
+ var cpv = TL.sample(cpk, t, TL.lerpArray)
4347
+ card.mesh.scale.x = cpv[0]; card.mesh.scale.y = cpv[0]
4348
+ card.mesh.position.y = cpv[1] * cpH
4349
+ if (card.mesh.material) card.mesh.material.opacity = cpv[2]
4350
+ } 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))) {
4351
+ card.mesh.scale.x = 1; card.mesh.scale.y = 1
4352
+ card.mesh.position.y = 0
4353
+ if (card.mesh.material) card.mesh.material.opacity = 1
4354
+ }
4194
4355
  var tilted = rx * rx + ry * ry > 1e-6
4195
4356
  if (card.texture && card.texture.generateMipmaps !== tilted) {
4196
4357
  var THREE2 = ctx.THREE
@@ -4372,7 +4533,10 @@ function studioLayerData(layers, duration) {
4372
4533
  } : {}
4373
4534
  };
4374
4535
  }
4375
- function lowerToComposition(doc) {
4536
+ function lowerToComposition(input) {
4537
+ const before = durationSec(input, ratedSegments(input));
4538
+ const expanded = expandEndCard(input, before);
4539
+ const doc = expanded.doc;
4376
4540
  const rated = ratedSegments(doc);
4377
4541
  const duration = durationSec(doc, rated);
4378
4542
  const fx = doc.cursor.clickFx;
@@ -4445,15 +4609,33 @@ function lowerToComposition(doc) {
4445
4609
  // intensities/colors become numbers HERE — ON_FRAME reads no registry).
4446
4610
  ...clickFxData(doc, rated),
4447
4611
  // Rated segments so zoom spans land at their speed-adjusted output times.
4448
- zoomTrack: zoomTrackFromDoc(zoomSpans, rated, zoomStyle),
4612
+ // A pull-out entrance writes the track's head.
4613
+ zoomTrack: prependEntrance(
4614
+ zoomTrackFromDoc(zoomSpans, rated, zoomStyle),
4615
+ entranceZoomKeyframes(doc.frame.entrance)
4616
+ ),
4449
4617
  // Tilt spans: OUTPUT-time [rx, ry] degree track. The rest pose is
4450
4618
  // FLAT — there is no static card tilt any more — and the motion constants
4451
4619
  // come from the camera style's tilt personality ('drift' slows its
4452
4620
  // 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
- } : {}
4621
+ // spans and no tilt-in entrance — byte parity. The entrance is a
4622
+ // producer of this track, never a second pose.
4623
+ ...(() => {
4624
+ const head = entranceTiltKeyframes(doc.frame.entrance);
4625
+ const spans = doc.tilt && doc.tilt.length ? tiltTrackFromDoc(doc.tilt, rated, zoomStyle.tilt) : void 0;
4626
+ const track = prependEntrance(spans, head);
4627
+ return track ? { tiltTrack: track } : {};
4628
+ })(),
4629
+ // The card's pose through the entrance and the end card: [scale, dy,
4630
+ // opacity] in output seconds. Absent when neither exists.
4631
+ ...(() => {
4632
+ const track = cardPoseTrack(
4633
+ doc.frame.entrance,
4634
+ expanded.endStart,
4635
+ expanded.seconds
4636
+ );
4637
+ return track ? { cardPoseTrack: track } : {};
4638
+ })()
4457
4639
  };
4458
4640
  const entryData = {
4459
4641
  lights: true,
@@ -5230,7 +5412,7 @@ function lowerProgramDoc(doc, opts = {}) {
5230
5412
 
5231
5413
  // src/destinations.ts
5232
5414
  var CHANNEL_SPECS_VERIFIED = "2026-08-04";
5233
- var CHANNEL_SPECS_HASH = "7a3ad4301f96507ec471a35fa37f367dcaf4bd405b008f551d0ac4cd395d61f7";
5415
+ var CHANNEL_SPECS_HASH = "e48926c22737b5dfaabf5cb33916a50a5eccd4b93498c2059a1468bd963a0a86";
5234
5416
  var DESTINATIONS = [
5235
5417
  {
5236
5418
  id: "youtube-main-demo",
@@ -5248,6 +5430,13 @@ var DESTINATIONS = [
5248
5430
  format: "mp4",
5249
5431
  accepts: "mp4 (H.264+AAC)",
5250
5432
  fit: "contain",
5433
+ text: "allowed",
5434
+ safe: {
5435
+ x: 0.05,
5436
+ y: 0.08,
5437
+ w: 0.9,
5438
+ h: 0.84
5439
+ },
5251
5440
  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
5441
  },
5253
5442
  {
@@ -5267,6 +5456,13 @@ var DESTINATIONS = [
5267
5456
  format: "mp4",
5268
5457
  accepts: "mp4 (H.264+AAC ONLY \u2014 HEVC/VP9/AV1 rejected)",
5269
5458
  fit: "contain",
5459
+ text: "allowed",
5460
+ safe: {
5461
+ x: 0.05,
5462
+ y: 0.08,
5463
+ w: 0.9,
5464
+ h: 0.84
5465
+ },
5270
5466
  notes: "30\u201360s plays best; 140s/512MB is the free-tier ceiling. 1:1 also allowed. Native upload, never a link post."
5271
5467
  },
5272
5468
  {
@@ -5285,6 +5481,13 @@ var DESTINATIONS = [
5285
5481
  format: "mp4",
5286
5482
  accepts: "mp4 (H.264+AAC)",
5287
5483
  fit: "contain",
5484
+ text: "expected",
5485
+ safe: {
5486
+ x: 0.0833,
5487
+ y: 0.1979,
5488
+ w: 0.8333,
5489
+ h: 0.6042
5490
+ },
5288
5491
  notes: "Critical text inside a centered ~900\xD71160 safe zone \u2014 platform chrome covers the rest. Serves YouTube Shorts and LinkedIn native vertical."
5289
5492
  },
5290
5493
  {
@@ -5304,6 +5507,13 @@ var DESTINATIONS = [
5304
5507
  format: "mp4",
5305
5508
  accepts: "mp4 (H.264)",
5306
5509
  fit: "contain",
5510
+ text: "none",
5511
+ safe: {
5512
+ x: 0.05,
5513
+ y: 0.08,
5514
+ w: 0.9,
5515
+ h: 0.84
5516
+ },
5307
5517
  notes: "\u226410MB is the free-plan attachment ceiling; two-pass target bitrate from duration."
5308
5518
  },
5309
5519
  {
@@ -5322,6 +5532,14 @@ var DESTINATIONS = [
5322
5532
  format: "png",
5323
5533
  accepts: "png|jpg",
5324
5534
  fit: "cover",
5535
+ text: "expected",
5536
+ safe: {
5537
+ x: 0.05,
5538
+ y: 0.08,
5539
+ w: 0.9,
5540
+ h: 0.84
5541
+ },
5542
+ template: "split-cover",
5325
5543
  notes: ""
5326
5544
  },
5327
5545
  {
@@ -5343,6 +5561,13 @@ var DESTINATIONS = [
5343
5561
  format: "png",
5344
5562
  accepts: "png|jpg",
5345
5563
  fit: "cover",
5564
+ text: "none",
5565
+ safe: {
5566
+ x: 0,
5567
+ y: 0,
5568
+ w: 1,
5569
+ h: 1
5570
+ },
5346
5571
  notes: "Real UX only \u2014 misleading listing images are a removal-grade violation. Full bleed, square corners."
5347
5572
  },
5348
5573
  {
@@ -5360,6 +5585,14 @@ var DESTINATIONS = [
5360
5585
  format: "png",
5361
5586
  accepts: "png|jpg",
5362
5587
  fit: "cover",
5588
+ text: "none",
5589
+ safe: {
5590
+ x: 0,
5591
+ y: 0,
5592
+ w: 1,
5593
+ h: 1
5594
+ },
5595
+ template: "card-on-gradient",
5363
5596
  notes: "Listings without one rank lower. No text; fill the region; subject centered."
5364
5597
  },
5365
5598
  {
@@ -5377,6 +5610,14 @@ var DESTINATIONS = [
5377
5610
  format: "png",
5378
5611
  accepts: "png|jpg",
5379
5612
  fit: "cover",
5613
+ text: "allowed",
5614
+ safe: {
5615
+ x: 0.05,
5616
+ y: 0.08,
5617
+ w: 0.9,
5618
+ h: 0.84
5619
+ },
5620
+ template: "split-cover",
5380
5621
  notes: "Carousel eligibility."
5381
5622
  },
5382
5623
  {
@@ -5393,6 +5634,13 @@ var DESTINATIONS = [
5393
5634
  format: "png",
5394
5635
  accepts: "png",
5395
5636
  fit: "cover",
5637
+ text: "none",
5638
+ safe: {
5639
+ x: 0,
5640
+ y: 0,
5641
+ w: 1,
5642
+ h: 1
5643
+ },
5396
5644
  notes: "96\xD796 art + 16px transparent padding."
5397
5645
  },
5398
5646
  {
@@ -5411,6 +5659,14 @@ var DESTINATIONS = [
5411
5659
  format: "png",
5412
5660
  accepts: "gif|png",
5413
5661
  fit: "cover",
5662
+ text: "none",
5663
+ safe: {
5664
+ x: 0.06,
5665
+ y: 0.06,
5666
+ w: 0.88,
5667
+ h: 0.88
5668
+ },
5669
+ template: "card-on-gradient",
5414
5670
  notes: "GIF animates on hover only \u2014 the first frame must stand alone."
5415
5671
  },
5416
5672
  {
@@ -5432,6 +5688,13 @@ var DESTINATIONS = [
5432
5688
  format: "png",
5433
5689
  accepts: "png|jpg|gif",
5434
5690
  fit: "cover",
5691
+ text: "none",
5692
+ safe: {
5693
+ x: 0,
5694
+ y: 0,
5695
+ w: 1,
5696
+ h: 1
5697
+ },
5435
5698
  notes: "First image is the hero."
5436
5699
  },
5437
5700
  {
@@ -5449,6 +5712,14 @@ var DESTINATIONS = [
5449
5712
  format: "png",
5450
5713
  accepts: "png|jpg",
5451
5714
  fit: "cover",
5715
+ text: "allowed",
5716
+ safe: {
5717
+ x: 0.05,
5718
+ y: 0.08,
5719
+ w: 0.9,
5720
+ h: 0.84
5721
+ },
5722
+ template: "split-cover",
5452
5723
  notes: ""
5453
5724
  },
5454
5725
  {
@@ -5466,6 +5737,14 @@ var DESTINATIONS = [
5466
5737
  format: "png",
5467
5738
  accepts: "png|jpg",
5468
5739
  fit: "cover",
5740
+ text: "expected",
5741
+ safe: {
5742
+ x: 0.05,
5743
+ y: 0.08,
5744
+ w: 0.9,
5745
+ h: 0.84
5746
+ },
5747
+ template: "split-cover",
5469
5748
  notes: "1080\xD71350 vertical also performs in the mobile feed."
5470
5749
  },
5471
5750
  {
@@ -5484,6 +5763,14 @@ var DESTINATIONS = [
5484
5763
  format: "png",
5485
5764
  accepts: "png|jpg",
5486
5765
  fit: "cover",
5766
+ text: "expected",
5767
+ safe: {
5768
+ x: 0.05,
5769
+ y: 0.0238,
5770
+ w: 0.9,
5771
+ h: 0.9524
5772
+ },
5773
+ template: "split-cover",
5487
5774
  notes: "Text in the center ~1080\xD7600. Set twitter:card=summary_large_image explicitly \u2014 og:image alone gets the small card."
5488
5775
  },
5489
5776
  {
@@ -5502,6 +5789,14 @@ var DESTINATIONS = [
5502
5789
  format: "png",
5503
5790
  accepts: "png|jpg",
5504
5791
  fit: "cover",
5792
+ text: "expected",
5793
+ safe: {
5794
+ x: 0.0391,
5795
+ y: 0.0781,
5796
+ w: 0.9219,
5797
+ h: 0.8438
5798
+ },
5799
+ template: "card-on-gradient",
5505
5800
  notes: "Key text \u226550px from every edge."
5506
5801
  }
5507
5802
  ];
@@ -5556,6 +5851,131 @@ function studioAudioPlan(clips, duckEnv, duration, step = AUDIO_PLAN_STEP) {
5556
5851
  return { duration, step, tracks };
5557
5852
  }
5558
5853
 
5854
+ // src/look.ts
5855
+ var HOUSE_GRADIENT = "linear-gradient(135deg, #ff5148, #ffb03a)";
5856
+ var PLATE_GROUND = "#f0f2f4";
5857
+ var DARK_GROUND = "radial-gradient(ellipse at 82% 0%, #2a1d16, #07080b)";
5858
+ var LOOKS = {
5859
+ plate: {
5860
+ kind: "plate",
5861
+ ground: PLATE_GROUND,
5862
+ cardWidth: 0.84,
5863
+ headroom: 0.16,
5864
+ radius: 12,
5865
+ shadow: 0.22,
5866
+ shadowContact: 0.16,
5867
+ border: 0.08,
5868
+ borderColor: "#000000"
5869
+ },
5870
+ gradient: {
5871
+ kind: "gradient",
5872
+ ground: HOUSE_GRADIENT,
5873
+ cardWidth: 0.84,
5874
+ headroom: 0.16,
5875
+ radius: 12,
5876
+ shadow: 0.35,
5877
+ shadowContact: 0.2,
5878
+ border: 0
5879
+ },
5880
+ dark: {
5881
+ kind: "dark",
5882
+ ground: DARK_GROUND,
5883
+ cardWidth: 0.86,
5884
+ headroom: 0.14,
5885
+ radius: 12,
5886
+ shadow: 0.5,
5887
+ shadowContact: 0.3,
5888
+ shadowColor: "#000000",
5889
+ border: 0.12,
5890
+ borderColor: "#ffffff"
5891
+ }
5892
+ };
5893
+ var LOOK_KINDS = ["plate", "gradient", "dark"];
5894
+ function isLookKind(v) {
5895
+ return typeof v === "string" && LOOK_KINDS.includes(v);
5896
+ }
5897
+ function houseLook(kind) {
5898
+ return { ...LOOKS[kind] };
5899
+ }
5900
+ var HEX = /^#([0-9a-f]{6})$/i;
5901
+ function luma(hex) {
5902
+ const m = HEX.exec(hex.trim());
5903
+ if (!m) return null;
5904
+ const n = parseInt(m[1], 16);
5905
+ const r = n >> 16 & 255;
5906
+ const g = n >> 8 & 255;
5907
+ const b = n & 255;
5908
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
5909
+ }
5910
+ function lookKindForGround(bgA) {
5911
+ const l = bgA ? luma(bgA) : null;
5912
+ if (l === null) return "gradient";
5913
+ if (l >= 0.85) return "plate";
5914
+ if (l <= 0.2) return "dark";
5915
+ return "gradient";
5916
+ }
5917
+ function lookFromBrand(brand) {
5918
+ const b = brand ?? {};
5919
+ const kind = isLookKind(b.look) ? b.look : lookKindForGround(b.bgA);
5920
+ const look = houseLook(kind);
5921
+ if (kind === "plate" && b.bgB && HEX.test(b.bgB)) look.ground = b.bgB;
5922
+ if (kind === "gradient" && b.accent && b.bgC && HEX.test(b.accent)) {
5923
+ look.ground = `linear-gradient(135deg, ${b.accent}, ${b.bgC})`;
5924
+ }
5925
+ if (kind === "dark" && b.bgA && HEX.test(b.bgA)) {
5926
+ look.ground = `radial-gradient(ellipse at 82% 0%, ${mix(b.bgA, b.accent ?? "#ffffff", 0.18)}, ${b.bgA})`;
5927
+ }
5928
+ if (b.ground && b.ground.trim()) look.ground = b.ground.trim();
5929
+ return look;
5930
+ }
5931
+ function mix(a, b, t) {
5932
+ const pa = HEX.exec(a);
5933
+ const pb = HEX.exec(b);
5934
+ if (!pa || !pb) return a;
5935
+ const na = parseInt(pa[1], 16);
5936
+ const nb = parseInt(pb[1], 16);
5937
+ const ch = (sh) => {
5938
+ const x = na >> sh & 255;
5939
+ const y = nb >> sh & 255;
5940
+ return Math.round(x + (y - x) * t);
5941
+ };
5942
+ return `#${(ch(16) << 16 | ch(8) << 8 | ch(0)).toString(16).padStart(6, "0")}`;
5943
+ }
5944
+ function cardInset(look, frame, video, placement) {
5945
+ const R = frame.w / Math.max(1, frame.h);
5946
+ const V = video.w / Math.max(1, video.h);
5947
+ const cw = look.cardWidth;
5948
+ const ch = cw * R / V;
5949
+ const side = (1 - cw) / 2;
5950
+ if (placement === "card" && ch <= 1 - look.headroom) {
5951
+ const v = (1 - ch) / 2;
5952
+ return { left: side, right: side, top: v, bottom: v };
5953
+ }
5954
+ const top = look.headroom;
5955
+ const bottom = Math.min(-0.02, 1 - top - ch);
5956
+ return { left: side, right: side, top, bottom };
5957
+ }
5958
+ function applyLook(frame, look, size, video, placement) {
5959
+ const out = {
5960
+ ...frame,
5961
+ background: look.ground,
5962
+ fit: "contain",
5963
+ inset: cardInset(look, size, video, placement),
5964
+ radius: look.radius,
5965
+ shadow: look.shadow,
5966
+ shadowContact: look.shadowContact,
5967
+ border: look.border
5968
+ };
5969
+ delete out.focus;
5970
+ if (look.shadowColor) out.shadowColor = look.shadowColor;
5971
+ else delete out.shadowColor;
5972
+ if (look.border > 0) {
5973
+ out.borderWidth = 1;
5974
+ out.borderColor = look.borderColor ?? "#000000";
5975
+ }
5976
+ return out;
5977
+ }
5978
+
5559
5979
  // src/timeline/lanes.ts
5560
5980
  import {
5561
5981
  mapTime as mapTime2,
@@ -6831,6 +7251,7 @@ export {
6831
7251
  CURSOR_IDLE_FADE_IN,
6832
7252
  CURSOR_IDLE_FADE_OUT,
6833
7253
  CURSOR_IDLE_HOLD,
7254
+ DARK_GROUND,
6834
7255
  DEFAULT_BROWSER_BAR,
6835
7256
  DEFAULT_CAM_POSE,
6836
7257
  DEFAULT_CAM_STYLE,
@@ -6848,6 +7269,8 @@ export {
6848
7269
  DIGEST_VERSION,
6849
7270
  DOC_SCHEMA_VERSION,
6850
7271
  DRAG_FIT_LEVEL,
7272
+ END_CARD_SECONDS,
7273
+ ENTRANCE_SECONDS,
6851
7274
  EXPORT_RESOLUTION_OPTIONS,
6852
7275
  EXPORT_SHORT_EDGE,
6853
7276
  FOLLOW_RECENTER,
@@ -6855,6 +7278,8 @@ export {
6855
7278
  FRAME_BORDER_COLOR_DEFAULT,
6856
7279
  FRAME_BORDER_DEFAULT,
6857
7280
  FRAME_BORDER_WIDTH_DEFAULT,
7281
+ HOUSE_GRADIENT,
7282
+ LOOK_KINDS,
6858
7283
  MINIMAL_BAR_THEMES,
6859
7284
  MOTION_DELTA,
6860
7285
  MOTION_EASE,
@@ -6869,6 +7294,7 @@ export {
6869
7294
  OVERLAY_SIZE_MIN,
6870
7295
  OVERLAY_TRANSITION_DUR,
6871
7296
  OVERLAY_Z,
7297
+ PLATE_GROUND,
6872
7298
  PLAYBACK_ACTIVITY,
6873
7299
  PROGRAM_RETIME,
6874
7300
  REJECT_OVERLAP,
@@ -6912,6 +7338,7 @@ export {
6912
7338
  ZOOM_STYLE_OPTIONS,
6913
7339
  anchorKindOf,
6914
7340
  anchorSourceDuration,
7341
+ applyLook,
6915
7342
  aspectRatioValue,
6916
7343
  audioLane,
6917
7344
  backdropMedia,
@@ -6923,7 +7350,9 @@ export {
6923
7350
  camRectFromPose,
6924
7351
  camRestPose,
6925
7352
  camTrackFromDoc,
7353
+ cardInset,
6926
7354
  cardPointToScreen,
7355
+ cardPoseTrack,
6927
7356
  clampCamFrac,
6928
7357
  clampCamSize,
6929
7358
  clampFocus,
@@ -6947,8 +7376,10 @@ export {
6947
7376
  docToFullSpace,
6948
7377
  duckCurve,
6949
7378
  effectiveSegments,
7379
+ entranceSeconds,
6950
7380
  envelopeAt,
6951
7381
  envelopeValueAt,
7382
+ expandEndCard,
6952
7383
  expectedFrameSize,
6953
7384
  exportSizeFor,
6954
7385
  extractClicks,
@@ -6956,13 +7387,17 @@ export {
6956
7387
  followFocusEvents,
6957
7388
  frameGeometry,
6958
7389
  hexToRgbTriplet,
7390
+ houseLook,
6959
7391
  idleGaps,
7392
+ isLookKind,
6960
7393
  isMusicBed,
6961
7394
  isPlayback,
6962
7395
  isProgramDoc,
6963
7396
  isRecordingDoc,
6964
7397
  isRejected,
6965
7398
  levelForFocusFraction,
7399
+ lookFromBrand,
7400
+ lookKindForGround,
6966
7401
  lowerProgramDoc,
6967
7402
  lowerStudioDoc,
6968
7403
  lowerToComposition,
@@ -7024,6 +7459,7 @@ export {
7024
7459
  videoLane,
7025
7460
  voiceKey,
7026
7461
  withBackdrop,
7462
+ withHolds,
7027
7463
  withoutRejected,
7028
7464
  wrapProgramLength,
7029
7465
  zoomCoversRect,