@pixerate/editor 0.234.0 → 0.235.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.
@@ -153,6 +153,13 @@ import {
153
153
  import {
154
154
  namedWithSuggestion
155
155
  } from "../chunk-EUKY4KFX.js";
156
+ import {
157
+ effectLayerRole,
158
+ effectLayerSpan,
159
+ effectSequenceTiming,
160
+ effectTemplateTools,
161
+ fillEffectTemplate
162
+ } from "../chunk-G3RUKJ5C.js";
156
163
  import "../chunk-EHLOCVWB.js";
157
164
  import {
158
165
  CAMERA_MOVE_MAX_MS,
@@ -3454,6 +3461,106 @@ var VideoPreviewEngine = class {
3454
3461
  }
3455
3462
  };
3456
3463
 
3464
+ // src/video-editor/sequence.ts
3465
+ var svgUrl = (svg) => svg.trimStart().startsWith("<") ? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` : svg;
3466
+ var GROUPS = ["base", "background", "subject", "overlay", "text"];
3467
+ function videoEditsFromSequence(project, sequence, options = {}) {
3468
+ const at = options.at ?? videoProjectDuration(project);
3469
+ const W = project.width;
3470
+ const H = project.height;
3471
+ const sx = W / sequence.stage.width;
3472
+ const sy = H / sequence.stage.height;
3473
+ const timing = effectSequenceTiming(sequence);
3474
+ const provider = options.provider ? { provider: options.provider } : {};
3475
+ const notes = [];
3476
+ const usedClips = new Set(allVideoClipIds(project));
3477
+ const usedTracks = new Set(project.tracks.map((t) => t.id));
3478
+ const usedAssets = new Set(project.assets.map((a) => a.id));
3479
+ const fresh = (used, base) => {
3480
+ for (let i = 1; ; i++) if (!used.has(`${base}${i}`)) return used.add(`${base}${i}`), `${base}${i}`;
3481
+ };
3482
+ const edits = [];
3483
+ const assets = [];
3484
+ const placeholders = {};
3485
+ const items = [];
3486
+ const placement = (r) => {
3487
+ if (!r) return {};
3488
+ const x = (r.x + r.width / 2) * sx - W / 2;
3489
+ const y = (r.y + r.height / 2) * sy - H / 2;
3490
+ const scale = Math.min(r.width * sx / W, r.height * sy / H);
3491
+ return { transform: { x: Math.round(x * 100) / 100, y: Math.round(y * 100) / 100, scale: Math.round(scale * 1e3) / 1e3 } };
3492
+ };
3493
+ const clipOf = (layer, start, duration, shot) => {
3494
+ const id = fresh(usedClips, "tpl-c");
3495
+ const common = { id, start, duration, ...layer.opacity !== void 0 && layer.opacity < 1 ? { opacity: layer.opacity } : {}, ...layer.fade ? { fadeIn: layer.fade, fadeOut: layer.fade } : {}, ...placement(layer.rect) };
3496
+ const effects = layer.effect ? { effects: [{ id: "fx1", effect: layer.effect, ...provider, ...layer.values ? { values: layer.values } : {} }] } : {};
3497
+ const s = layer.subject;
3498
+ if (!s) return { ...common, type: "effect", effect: layer.effect, ...provider, ...layer.values ? { values: layer.values } : {} };
3499
+ if ("slot" in s) {
3500
+ (placeholders[s.slot] ??= []).push(id);
3501
+ if (layer.effect) notes.push(`Shot ${shot + 1}: the "${s.slot}" slot is a grey placeholder; when you swap in the picture, apply ${layer.effect} to it again (apply_effect).`);
3502
+ return { ...common, type: "color", color: "#475569", name: `Slot "${s.slot}"` };
3503
+ }
3504
+ if ("text" in s) return { ...common, type: "text", text: s.text, ...s.font ? { fontFamily: s.font } : {}, ...typeof s.weight === "number" ? { fontWeight: s.weight } : {}, ...effects };
3505
+ const asset = fresh(usedAssets, "tpl-a");
3506
+ assets.push(asset);
3507
+ if ("video" in s) {
3508
+ notes.push(`Shot ${shot + 1}: a video's length is not known here, so its asset is given the clip's length (${Math.round(duration)} ms); correct it with update_video_asset if it is longer.`);
3509
+ edits.push({ op: "add_asset", asset: { id: asset, kind: "video", src: s.video, duration } });
3510
+ } else edits.push({ op: "add_asset", asset: { id: asset, kind: "image", src: "image" in s ? s.image : svgUrl(s.svg) } });
3511
+ return { ...common, type: "media", asset, fit: "contain", ...effects };
3512
+ };
3513
+ sequence.shots.forEach((shot, i) => {
3514
+ const shotStart = timing.starts[i];
3515
+ const shotEnd = timing.starts[i + 1] ?? timing.total;
3516
+ let base;
3517
+ shot.layers.forEach((layer, j) => {
3518
+ const span = effectLayerSpan(layer, i, timing.starts, timing.total);
3519
+ if (span.end - span.start <= 0) return void notes.push(`Shot ${i + 1}, layer ${j + 1} lasts no time, so it was left out.`);
3520
+ const clip = clipOf(layer, at + span.start, span.end - span.start, i);
3521
+ const isBase = j === 0 && !base && span.start === shotStart && span.end === shotEnd && effectLayerRole(layer) === "background" && !layer.rect;
3522
+ const item = { group: isBase ? "base" : effectLayerRole(layer), clip };
3523
+ if (isBase) base = item;
3524
+ items.push(item);
3525
+ });
3526
+ if (!base) {
3527
+ base = { group: "base", clip: { id: fresh(usedClips, "tpl-c"), type: "color", color: "#000000", name: `Shot ${i + 1}`, start: at + shotStart, duration: shotEnd - shotStart } };
3528
+ items.push(base);
3529
+ }
3530
+ if (i > 0 && shot.transition) {
3531
+ base.clip.transition = { type: "effect", duration: shot.transition.ms ?? 800, effect: { effect: shot.transition.effect, ...provider, ...shot.transition.values ? { values: shot.transition.values } : {} } };
3532
+ if (shot.layers.length > 1) notes.push(`Shot ${i + 1}: its transition blends the clips on the base track; the layers above it cut.`);
3533
+ }
3534
+ const words = (text, y, size, name) => items.push({ group: "text", clip: { id: fresh(usedClips, "tpl-c"), type: "text", text, name, fontSize: Math.round(size), start: at + shotStart, duration: shotEnd - shotStart, transform: { x: 0, y: Math.round(y) } } });
3535
+ if (shot.title) words(shot.title, 0, H * 0.09, "Title");
3536
+ if (shot.caption) words(shot.caption, H * 0.35, H * 0.045, "Caption");
3537
+ if (shot.camera?.in !== void 0 || shot.camera?.during !== void 0 || shot.camera?.framing) notes.push(`Shot ${i + 1}: its camera framing and moves were not carried over; set them with set_clip_camera_move on its clips.`);
3538
+ });
3539
+ const lanes = [];
3540
+ for (const group of GROUPS) {
3541
+ const mine = [];
3542
+ for (const item of items.filter((x) => x.group === group).sort((a, b) => a.clip.start - b.clip.start)) {
3543
+ let lane = group === "base" ? mine[0] : mine.find((l) => l.ends.every((e) => e <= item.clip.start + 1e-6));
3544
+ if (!lane) mine.push(lane = { group, id: fresh(usedTracks, "tpl-v"), ends: [], clips: [] });
3545
+ lane.ends.push(item.clip.start + item.clip.duration);
3546
+ lane.clips.push(item);
3547
+ }
3548
+ lanes.push(...mine);
3549
+ }
3550
+ for (const lane of lanes) edits.push({ op: "add_track", track: { id: lane.id, kind: "video", name: `Template ${lane.group}` }, at: 0 });
3551
+ for (const lane of lanes) for (const item of lane.clips) edits.push({ op: "add_clip", track: lane.id, clip: item.clip });
3552
+ return {
3553
+ edits,
3554
+ clips: lanes.flatMap((l) => l.clips.map((c) => c.clip.id)),
3555
+ tracks: lanes.map((l) => l.id).reverse(),
3556
+ assets,
3557
+ start: at,
3558
+ end: at + timing.total,
3559
+ placeholders,
3560
+ notes
3561
+ };
3562
+ }
3563
+
3457
3564
  // src/video-editor/controller.ts
3458
3565
  var VideoEditorController = class _VideoEditorController {
3459
3566
  history;
@@ -3818,6 +3925,29 @@ var VideoEditorController = class _VideoEditorController {
3818
3925
  if (place) checkVideoEffectPlace(d, place);
3819
3926
  return d;
3820
3927
  }
3928
+ /**
3929
+ * Fills a provider sequence template and puts it on the timeline at `at` (default the end), on
3930
+ * new tracks above the others (see `videoEditsFromSequence`). Values are checked first and every
3931
+ * problem is refused at once. One undo step. Returns the new tracks, clips and assets, where it
3932
+ * starts and ends, the placeholder clips standing in for open slots, and notes.
3933
+ */
3934
+ async fillTemplate(template, values = {}, options = {}) {
3935
+ const reason = this.readOnlyReason();
3936
+ if (reason) return this.refused(reason);
3937
+ if (options.at !== void 0 && !(Number.isFinite(options.at) && options.at >= 0)) return this.refused(`"at" is a time in ms, 0 or more (got ${options.at}).`);
3938
+ let filled;
3939
+ try {
3940
+ filled = await fillEffectTemplate(this.effects.provider, template, values, options.name ? { name: options.name } : {});
3941
+ } catch (error) {
3942
+ if (isEffectError(error) || error instanceof Error) return this.refused(error.message);
3943
+ throw error;
3944
+ }
3945
+ const made = videoEditsFromSequence(this.getState().project, filled.sequence, { at: options.at, provider: this.effects.provider.id });
3946
+ const r = this.apply(made.edits, `Template ${filled.template.name}`);
3947
+ if (!r.ok) return r;
3948
+ const { edits: _edits, notes, ...rest } = made;
3949
+ return { ok: true, template, ...rest, openSlots: filled.openSlots, notes: [...filled.notes, ...notes] };
3950
+ }
3821
3951
  refused(message) {
3822
3952
  return this.refuse(message);
3823
3953
  }
@@ -5923,6 +6053,20 @@ function createVideoEffectsToolProvider(controller, options = {}) {
5923
6053
  subscribe: (notify) => controller.subscribe(() => notify()),
5924
6054
  extraTools: (ctx) => [
5925
6055
  ...videoCameraMoveTools(controller, ctx),
6056
+ ...effectTemplateTools(
6057
+ {
6058
+ provider: () => controller.effectsProvider,
6059
+ fillDetails: `It goes on the timeline at "at" (default the end of the project), on new tracks above the others: each shot's bottom layer on a base track (with the shot's transition), other layers as effect, picture and text clips on tracks by role, titles and captions as text clips; an open subject slot becomes a grey placeholder clip named after it. Camera moves are not carried over. Returns { template, tracks, clips, assets, start, end (ms), placeholders (clip ids by open slot), openSlots, notes }. To fill an open slot, add the picture with add_video_asset and put it on the placeholder's track at its start with add_video_clip in overwrite mode.`,
6060
+ fillOptions: { at: { type: "number", minimum: 0, description: "Where on the timeline it starts, ms (default the end of the project)." } },
6061
+ fill: async (template, values, options2) => {
6062
+ const r = await controller.fillTemplate(template, values, { name: options2.name, at: options2.at });
6063
+ if (!r.ok) throw new Error(r.error);
6064
+ const { ok: _ok, ...rest } = r;
6065
+ return rest;
6066
+ }
6067
+ },
6068
+ ctx
6069
+ ),
5926
6070
  {
5927
6071
  name: "add_video_effect_clip",
5928
6072
  title: "Add an effect clip",
@@ -6684,6 +6828,7 @@ export {
6684
6828
  videoEaseBezier,
6685
6829
  videoEaseProgress,
6686
6830
  videoEaseToAnimationEasing,
6831
+ videoEditsFromSequence,
6687
6832
  videoEffectFrameTime,
6688
6833
  videoEffectLayerKey,
6689
6834
  videoEffectValuesAt,
@@ -0,0 +1,38 @@
1
+ /**
2
+ * An effects provider's `EffectSequence` (what a sequence template fills to) as video edits, so a
3
+ * template lands on the timeline as one undo step:
4
+ *
5
+ * - Each shot's bottom layer, when it lasts exactly the shot, goes on a base track; otherwise the
6
+ * shot gets a black colour clip there. A shot's transition goes on its base clip (a provider
7
+ * `effect` transition from the base clip before), so it blends the base pictures.
8
+ * - Other layers go on tracks above by role (background, subject, overlay), packed so clips of a
9
+ * track never overlap: an effect with no subject is an effect clip; a picture or SVG is a media
10
+ * clip on a new image asset, carrying the layer's effect as a clip effect; words are a text clip;
11
+ * an open slot is a grey placeholder colour clip named after it.
12
+ * - A layer's `rect` becomes the clip's placement; `opacity` and `fade` carry over.
13
+ * - Titles and captions are text clips on the top track.
14
+ *
15
+ * Camera moves are not carried over (notes say so); set them with `set_clip_camera_move`.
16
+ */
17
+ import type { EffectSequence } from '../effects/types.cjs';
18
+ import type { VideoEdit } from './edit.cjs';
19
+ import type { VideoProject } from './types.cjs';
20
+ export interface VideoSequenceEdits {
21
+ edits: VideoEdit[];
22
+ /** The new clips' ids, base clips first. */
23
+ clips: string[];
24
+ /** The new tracks' ids, top to bottom. */
25
+ tracks: string[];
26
+ assets: string[];
27
+ /** Where the sequence starts and ends on the timeline (ms). */
28
+ start: number;
29
+ end: number;
30
+ /** Placeholder clips standing in for open subject slots, by slot. */
31
+ placeholders: Record<string, string[]>;
32
+ notes: string[];
33
+ }
34
+ /** The edits that put a sequence on a project's timeline at `at` (default its end), on new tracks above the others. */
35
+ export declare function videoEditsFromSequence(project: VideoProject, sequence: EffectSequence, options?: {
36
+ at?: number;
37
+ provider?: string;
38
+ }): VideoSequenceEdits;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * An effects provider's `EffectSequence` (what a sequence template fills to) as video edits, so a
3
+ * template lands on the timeline as one undo step:
4
+ *
5
+ * - Each shot's bottom layer, when it lasts exactly the shot, goes on a base track; otherwise the
6
+ * shot gets a black colour clip there. A shot's transition goes on its base clip (a provider
7
+ * `effect` transition from the base clip before), so it blends the base pictures.
8
+ * - Other layers go on tracks above by role (background, subject, overlay), packed so clips of a
9
+ * track never overlap: an effect with no subject is an effect clip; a picture or SVG is a media
10
+ * clip on a new image asset, carrying the layer's effect as a clip effect; words are a text clip;
11
+ * an open slot is a grey placeholder colour clip named after it.
12
+ * - A layer's `rect` becomes the clip's placement; `opacity` and `fade` carry over.
13
+ * - Titles and captions are text clips on the top track.
14
+ *
15
+ * Camera moves are not carried over (notes say so); set them with `set_clip_camera_move`.
16
+ */
17
+ import type { EffectSequence } from '../effects/types.js';
18
+ import type { VideoEdit } from './edit.js';
19
+ import type { VideoProject } from './types.js';
20
+ export interface VideoSequenceEdits {
21
+ edits: VideoEdit[];
22
+ /** The new clips' ids, base clips first. */
23
+ clips: string[];
24
+ /** The new tracks' ids, top to bottom. */
25
+ tracks: string[];
26
+ assets: string[];
27
+ /** Where the sequence starts and ends on the timeline (ms). */
28
+ start: number;
29
+ end: number;
30
+ /** Placeholder clips standing in for open subject slots, by slot. */
31
+ placeholders: Record<string, string[]>;
32
+ notes: string[];
33
+ }
34
+ /** The edits that put a sequence on a project's timeline at `at` (default its end), on new tracks above the others. */
35
+ export declare function videoEditsFromSequence(project: VideoProject, sequence: EffectSequence, options?: {
36
+ at?: number;
37
+ provider?: string;
38
+ }): VideoSequenceEdits;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor",
3
- "version": "0.234.0",
3
+ "version": "0.235.0",
4
4
  "description": "UI-agnostic rich text and media editing core library",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",