File size: 5,688 Bytes
070c931
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import type { VideoScene } from "../../api/types";

/** A scene's layout and its props live in remotion_code: {"layout": ..., "layoutProps": {...}} on built-in
 *  templates, "layoutConfig" on custom ones. Font sizes and the assigned image are props there. */
export type SceneCode = { layout?: string; layoutProps?: Record<string, unknown>; layoutConfig?: Record<string, unknown>;
                          [key: string]: unknown };

export const FONT = {
  title: { min: 20, max: 200, fallback: 140 },
  desc: { min: 12, max: 80, fallback: 36 },
};

export type FontDefaults = { title: number; desc: number };

/** A layout's default font sizes, as blog2video's editor reads them: the template's meta.json layout_prop_schema
 *  (sent by GET .../layouts), the exact layout first, then its base without a "__v2" variant suffix; each value is
 *  a number or { landscape, portrait }. Falls back per field when the template has none (or it has not loaded). */
export function fontDefaults(schema: Record<string, { defaults?: Record<string, unknown> }> | null | undefined,
                             layout: string | null | undefined, aspect: string): FontDefaults {
  const entry = layout ? schema?.[layout] ?? schema?.[layout.replace(/__v\d+$/, "")] : undefined;
  const read = (v: unknown): number | null => {
    if (typeof v === "number" && Number.isFinite(v)) return v;
    if (v && typeof v === "object") {
      const n = (v as Record<string, unknown>)[aspect === "portrait" ? "portrait" : "landscape"];
      if (typeof n === "number" && Number.isFinite(n)) return n;
    }
    return null;
  };
  return {
    title: read(entry?.defaults?.titleFontSize) ?? FONT.title.fallback,
    desc: read(entry?.defaults?.descriptionFontSize) ?? FONT.desc.fallback,
  };
}

/** A layout's base id: "news_headline__v2" -> "news_headline" (variants are visual styles of their base). */
export const baseLayout = (id: string | null | undefined) => (id ?? "").split("__")[0];

/** remotion_code with only the layout swapped (a Scene style switch): every prop stays, as variants share them. */
export function withLayout(scene: VideoScene, layout: string): string {
  return JSON.stringify({ ...parseCode(scene), layout });
}

/** Props the editor handles itself (images, focus, fonts); everything else is shown under "Scene props". */
export const MANAGED_PROPS = new Set(["assignedImage", "assignedImages", "assignedVideo", "assignedVideos", "imageFocusX", "imageFocusY", "imageZoom",
  "titleFontSize", "descriptionFontSize", "videoStartSeconds"]);

export function parseCode(scene: VideoScene): SceneCode {
  try {
    return JSON.parse(scene.remotion_code || "{}") as SceneCode;
  } catch {
    return {};
  }
}

export const propsKey = (template: string) => (template.startsWith("custom_") ? "layoutConfig" : "layoutProps");

export function propsOf(code: SceneCode, template: string): Record<string, unknown> {
  return { ...((code[propsKey(template)] as Record<string, unknown> | undefined) ?? {}) };
}

/** remotion_code with the given props changed (undefined removes a prop). */
export function withProps(scene: VideoScene, template: string, patch: Record<string, unknown>): string {
  const code = parseCode(scene);
  const props = propsOf(code, template);
  for (const [k, v] of Object.entries(patch)) {
    if (v === undefined) delete props[k];
    else props[k] = v;
  }
  return JSON.stringify({ ...code, [propsKey(template)]: props });
}

/** remotion_code with one image or stock clip taken off the scene (stills live in assignedImage(s), clips in
 *  assignedVideo(s)). When that empties the scene's visual slot it is marked the way blog2video's own editor does
 *  (SceneEditModal handleRemoveStockFootage): `hideImage` so the player does not fill the slot with a generic image,
 *  and `visualClearedByUser` so a later regenerate does not put a spare clip back. Assigning media clears both. */
export function withoutMedia(scene: VideoScene, template: string, filename: string): string {
  const props = propsOf(parseCode(scene), template);
  const patch: Record<string, unknown> = {};
  for (const one of ["assignedImage", "assignedVideo"]) if (props[one] === filename) patch[one] = undefined;
  for (const many of ["assignedImages", "assignedVideos"]) {
    const list = props[many];
    if (Array.isArray(list)) patch[many] = list.filter((x) => x !== filename);
  }
  const left = { ...props, ...patch };
  const empty = !left.assignedImage && !left.assignedVideo
    && !(Array.isArray(left.assignedImages) && left.assignedImages.length)
    && !(Array.isArray(left.assignedVideos) && left.assignedVideos.length);
  if (empty) {
    patch.hideImage = true;
    patch.visualClearedByUser = true;
    // A clip's playback settings would otherwise apply to whatever lands in the slot next.
    if (props.assignedVideo === filename) {
      patch.videoMuted = undefined;
      patch.videoVolume = undefined;
      patch.videoStartSeconds = undefined;
    }
  }
  return withProps(scene, template, patch);
}

/** The template's own name for a layout (blog2video's layout_names, e.g. "News Headline — Broadsheet" for
 *  news_headline__v2), else the id title-cased: "cinematic_title" -> "Cinematic Title". */
export function layoutName(id: string | null | undefined, names?: Record<string, string> | null): string {
  if (id && names?.[id]) return names[id];
  return (id ?? "").split(/[_-]/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(" ") || "Auto";
}

export function wordsAndSeconds(text: string): string {
  const words = text.trim() ? text.trim().split(/\s+/).length : 0;
  return `${words} words · ~${Math.max(1, Math.round(words / 2.5))}s`;
}