easycaptionkit 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +175 -2
- package/package.json +42 -4
- package/skills/easycaptionkit/SKILL.md +58 -0
- package/skills/easycaptionkit/scripts/easycaptionkit.js +386 -0
- package/skills/easycaptionkit/scripts/lib/apng.js +136 -0
- package/skills/easycaptionkit/scripts/lib/caption.js +1395 -0
- package/skills/easycaptionkit/scripts/lib/chrome.js +424 -0
- package/skills/easycaptionkit/scripts/lib/cues.js +317 -0
- package/skills/easycaptionkit/scripts/lib/draw.js +288 -0
- package/skills/easycaptionkit/scripts/lib/ffmpeg.js +1032 -0
- package/skills/easycaptionkit/scripts/lib/files.js +249 -0
- package/skills/easycaptionkit/scripts/lib/fontfile.js +355 -0
- package/skills/easycaptionkit/scripts/lib/look.js +571 -0
- package/skills/easycaptionkit/scripts/lib/places.js +337 -0
- package/skills/easycaptionkit/scripts/lib/render.js +374 -0
- package/skills/easycaptionkit/scripts/lib/report.js +604 -0
- package/skills/easycaptionkit/scripts/lib/running.js +62 -0
- package/skills/easycaptionkit/scripts/lib/subtitles.js +489 -0
- package/skills/easycaptionkit/scripts/lib/text.js +115 -0
- package/skills/easycaptionkit/scripts/lib/version.js +5 -0
- package/skills/easycaptionkit/scripts/lib/words.js +1090 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Where each app draws its buttons and text over a video, and where the
|
|
4
|
+
// captions go so they stay clear of them.
|
|
5
|
+
//
|
|
6
|
+
// The vertical apps fill a 9:16 phone screen with the video and lay their
|
|
7
|
+
// buttons over it: margins there are in pixels of a 1080x1920 screen, and a
|
|
8
|
+
// video of another shape is fitted inside that screen. The feeds lay a few
|
|
9
|
+
// controls over the video itself: margins there are in pixels of a frame of
|
|
10
|
+
// that shape. Only the ad specs of TikTok, Meta and Google publish such
|
|
11
|
+
// margins; every other figure is an estimate and says so. Apps change, so
|
|
12
|
+
// every entry carries where its numbers come from and when they were read.
|
|
13
|
+
|
|
14
|
+
const CHECKED = "2026-11-07";
|
|
15
|
+
|
|
16
|
+
const PLACES = {
|
|
17
|
+
tiktok: {
|
|
18
|
+
name: "TikTok",
|
|
19
|
+
screen: { width: 1080, height: 1920 },
|
|
20
|
+
margins: { top: 240, bottom: 660, left: 120, right: 120 },
|
|
21
|
+
// The like, comment, save and share buttons down the right side.
|
|
22
|
+
column: { from: 840, width: 180 },
|
|
23
|
+
ui: "tiktok",
|
|
24
|
+
source: "TikTok's safe zone file for In-Feed ads, 720x1280 scaled to 1080x1920, as Socialync reads it; the button column's width is Socialync's measurement",
|
|
25
|
+
url: "https://www.socialync.io/tools/safe-zones/tiktok",
|
|
26
|
+
read: "2026-10-11",
|
|
27
|
+
},
|
|
28
|
+
reels: {
|
|
29
|
+
name: "Instagram Reels",
|
|
30
|
+
screen: { width: 1080, height: 1920 },
|
|
31
|
+
margins: { top: 269, bottom: 672, left: 65, right: 65 },
|
|
32
|
+
// The like, comment, share and audio buttons down the right side.
|
|
33
|
+
column: { from: 1152, width: 227 },
|
|
34
|
+
ui: "reels",
|
|
35
|
+
source: "Meta's ad specs for Instagram Reels: 14% at the top, 35% at the bottom and 6% on each side, as Socialync quotes them",
|
|
36
|
+
note: "Meta gives no figure for the buttons on the right; the button column, 227 px wide from 1152 px down, is adkit.so's (adkit.so/tools/safe-zones/instagram, read 2026-11-07).",
|
|
37
|
+
url: "https://www.socialync.io/tools/safe-zones/instagram-reels",
|
|
38
|
+
read: "2026-10-11",
|
|
39
|
+
},
|
|
40
|
+
shorts: {
|
|
41
|
+
name: "YouTube Shorts",
|
|
42
|
+
screen: { width: 1080, height: 1920 },
|
|
43
|
+
margins: { top: 288, bottom: 672, left: 48, right: 192 },
|
|
44
|
+
ui: "shorts",
|
|
45
|
+
source: "Google's Universal Video Ad Safe Zones for YouTube, vertical",
|
|
46
|
+
url: "https://services.google.com/fh/files/misc/universalsafezones-youtube.pdf",
|
|
47
|
+
read: CHECKED,
|
|
48
|
+
},
|
|
49
|
+
youtube: {
|
|
50
|
+
name: "YouTube",
|
|
51
|
+
shapes: {
|
|
52
|
+
landscape: {
|
|
53
|
+
frame: { width: 1920, height: 1080 },
|
|
54
|
+
margins: { top: 166, bottom: 143, left: 152, right: 152 },
|
|
55
|
+
ui: "youtube",
|
|
56
|
+
source: "Google's Universal Video Ad Safe Zones for YouTube, horizontal: its top left corner, its bottom middle and its right side, on both sides",
|
|
57
|
+
note: "Google's layout for ads also keeps the bottom left (856x337) and the bottom right (463x309) clear for ad buttons, which a normal upload does not have.",
|
|
58
|
+
url: "https://services.google.com/fh/files/misc/universalsafezones-youtube.pdf",
|
|
59
|
+
read: CHECKED,
|
|
60
|
+
},
|
|
61
|
+
square: {
|
|
62
|
+
frame: { width: 1080, height: 1080 },
|
|
63
|
+
margins: { top: 105, bottom: 390, left: 48, right: 192 },
|
|
64
|
+
ui: "youtube",
|
|
65
|
+
source: "Google's Universal Video Ad Safe Zones for YouTube, square",
|
|
66
|
+
url: "https://services.google.com/fh/files/misc/universalsafezones-youtube.pdf",
|
|
67
|
+
read: CHECKED,
|
|
68
|
+
},
|
|
69
|
+
vertical: "shorts",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
x: {
|
|
73
|
+
name: "X",
|
|
74
|
+
shapes: {
|
|
75
|
+
landscape: {
|
|
76
|
+
frame: { width: 1080, height: 608 },
|
|
77
|
+
margins: { top: 43, bottom: 73, left: 54, right: 54 },
|
|
78
|
+
ui: "x",
|
|
79
|
+
source: "an estimate for X's feed player, which puts the time at the bottom left and the sound button at the bottom right",
|
|
80
|
+
note: "X publishes no safe zone; adkit.so's spacing for it is 40 px at the top and bottom and 45 px at the sides of 1080x608.",
|
|
81
|
+
url: "https://adkit.so/tools/safe-zones/x",
|
|
82
|
+
read: CHECKED,
|
|
83
|
+
},
|
|
84
|
+
square: {
|
|
85
|
+
frame: { width: 1080, height: 1080 },
|
|
86
|
+
margins: { top: 80, bottom: 80, left: 80, right: 80 },
|
|
87
|
+
ui: "x-square",
|
|
88
|
+
source: "adkit.so's spacing for square posts on X, its own estimate",
|
|
89
|
+
url: "https://adkit.so/tools/safe-zones/x",
|
|
90
|
+
read: CHECKED,
|
|
91
|
+
},
|
|
92
|
+
vertical: {
|
|
93
|
+
frame: { width: 1080, height: 1920 },
|
|
94
|
+
margins: { top: 80, bottom: 450, left: 80, right: 80 },
|
|
95
|
+
ui: "x-vertical",
|
|
96
|
+
source: "adkit.so's spacing for vertical video on X, its own estimate",
|
|
97
|
+
url: "https://adkit.so/tools/safe-zones/x",
|
|
98
|
+
read: CHECKED,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
linkedin: {
|
|
103
|
+
name: "LinkedIn",
|
|
104
|
+
shapes: {
|
|
105
|
+
landscape: {
|
|
106
|
+
frame: { width: 1080, height: 608 },
|
|
107
|
+
margins: { top: 43, bottom: 73, left: 54, right: 54 },
|
|
108
|
+
ui: "linkedin",
|
|
109
|
+
source: "an estimate for LinkedIn's player, which puts its play bar and buttons along the bottom",
|
|
110
|
+
note: "LinkedIn publishes no safe zone for the feed; adkit.so's spacing for it is 40 px at the top and bottom and 45 px at the sides of 1080x608.",
|
|
111
|
+
url: "https://adkit.so/tools/safe-zones/linkedin",
|
|
112
|
+
read: CHECKED,
|
|
113
|
+
},
|
|
114
|
+
square: {
|
|
115
|
+
frame: { width: 1080, height: 1080 },
|
|
116
|
+
margins: { top: 80, bottom: 80, left: 80, right: 80 },
|
|
117
|
+
ui: "linkedin-square",
|
|
118
|
+
source: "adkit.so's spacing for square and 4:5 posts on LinkedIn, its own estimate",
|
|
119
|
+
url: "https://adkit.so/tools/safe-zones/linkedin",
|
|
120
|
+
read: CHECKED,
|
|
121
|
+
},
|
|
122
|
+
vertical: {
|
|
123
|
+
frame: { width: 1080, height: 1920 },
|
|
124
|
+
margins: { top: 260, bottom: 560, left: 112, right: 210 },
|
|
125
|
+
ui: "linkedin-vertical",
|
|
126
|
+
source: "LinkedIn's guide for vertical video, as adkit.so reads it",
|
|
127
|
+
url: "https://adkit.so/tools/safe-zones/linkedin",
|
|
128
|
+
read: CHECKED,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
square: {
|
|
133
|
+
name: "a square or 4:5 feed post",
|
|
134
|
+
shapes: {
|
|
135
|
+
square: {
|
|
136
|
+
frame: { width: 1080, height: 1080 },
|
|
137
|
+
margins: { top: 80, bottom: 80, left: 80, right: 80 },
|
|
138
|
+
ui: "square",
|
|
139
|
+
source: "adkit.so's spacing for square posts on X and LinkedIn, its own estimate",
|
|
140
|
+
note: "Instagram and Facebook put their sound button in the bottom right corner of a feed video.",
|
|
141
|
+
url: "https://adkit.so/tools/safe-zones/x",
|
|
142
|
+
read: CHECKED,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const PLACE_NAMES = Object.keys(PLACES);
|
|
149
|
+
|
|
150
|
+
// A video's shape: vertical (9:16, 2:3), square (1:1 up to 4:5) or
|
|
151
|
+
// landscape (4:3, 16:9 and wider).
|
|
152
|
+
function shapeOf(width, height) {
|
|
153
|
+
const r = width / height;
|
|
154
|
+
if (r < 0.72) return "vertical";
|
|
155
|
+
if (r <= 1.2) return "square";
|
|
156
|
+
return "landscape";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The places a video goes to when none are given: the vertical apps for a
|
|
160
|
+
// vertical video, the feeds for the others.
|
|
161
|
+
function defaultPlaces(width, height) {
|
|
162
|
+
const shape = shapeOf(width, height);
|
|
163
|
+
if (shape === "vertical") return ["tiktok", "reels", "shorts"];
|
|
164
|
+
if (shape === "square") return ["square"];
|
|
165
|
+
return ["youtube", "x", "linkedin"];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// One place's zone for a video of this size: { name, ui, source, url, read,
|
|
169
|
+
// shape, margins (in the video's pixels), column, screen and video rect for
|
|
170
|
+
// drawing, scale (screen pixels per video pixel) }.
|
|
171
|
+
function zone(key, width, height) {
|
|
172
|
+
const place = PLACES[key];
|
|
173
|
+
if (place.screen) {
|
|
174
|
+
// The app's screen with the video fitted inside it.
|
|
175
|
+
const { width: SW, height: SH } = place.screen;
|
|
176
|
+
const s = Math.min(SW / width, SH / height);
|
|
177
|
+
const vw = width * s;
|
|
178
|
+
const vh = height * s;
|
|
179
|
+
const x0 = (SW - vw) / 2;
|
|
180
|
+
const y0 = (SH - vh) / 2;
|
|
181
|
+
const m = place.margins;
|
|
182
|
+
const toVideo = (screenPx) => screenPx / s;
|
|
183
|
+
const margins = {
|
|
184
|
+
top: Math.max(0, toVideo(m.top - y0)),
|
|
185
|
+
bottom: Math.max(0, toVideo(m.bottom - y0)),
|
|
186
|
+
left: Math.max(0, toVideo(m.left - x0)),
|
|
187
|
+
right: Math.max(0, toVideo(m.right - x0)),
|
|
188
|
+
};
|
|
189
|
+
const column = place.column ? { from: toVideo(place.column.from - y0), width: Math.max(0, toVideo(place.column.width - x0)) } : null;
|
|
190
|
+
return {
|
|
191
|
+
key,
|
|
192
|
+
name: place.name,
|
|
193
|
+
ui: place.ui,
|
|
194
|
+
source: place.source,
|
|
195
|
+
note: place.note || "",
|
|
196
|
+
url: place.url,
|
|
197
|
+
read: place.read,
|
|
198
|
+
shape: shapeOf(width, height),
|
|
199
|
+
reference: { ...place.screen, margins: place.margins, column: place.column || null },
|
|
200
|
+
margins,
|
|
201
|
+
column,
|
|
202
|
+
screen: { width: SW, height: SH, video: { x: x0, y: y0, width: vw, height: vh } },
|
|
203
|
+
// A phone's screen is about 1080 px wide however big the video is.
|
|
204
|
+
textBase: 1080 / s,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const shape = shapeOf(width, height);
|
|
208
|
+
let entry = place.shapes[shape] || place.shapes.square || place.shapes.landscape;
|
|
209
|
+
if (typeof entry === "string") return zone(entry, width, height);
|
|
210
|
+
const { frame, margins: m } = entry;
|
|
211
|
+
const fx = width / frame.width;
|
|
212
|
+
const fy = height / frame.height;
|
|
213
|
+
return {
|
|
214
|
+
key,
|
|
215
|
+
name: place.name,
|
|
216
|
+
ui: entry.ui,
|
|
217
|
+
source: entry.source,
|
|
218
|
+
note: entry.note || "",
|
|
219
|
+
url: entry.url,
|
|
220
|
+
read: entry.read,
|
|
221
|
+
shape,
|
|
222
|
+
reference: { ...frame, margins: m, column: null },
|
|
223
|
+
margins: { top: m.top * fy, bottom: m.bottom * fy, left: m.left * fx, right: m.right * fx },
|
|
224
|
+
column: null,
|
|
225
|
+
screen: null,
|
|
226
|
+
textBase: Math.min(width, height),
|
|
227
|
+
mismatch: !place.shapes[shape] ? `${place.name[0].toUpperCase()}${place.name.slice(1)} has no figures for a ${shape} video, so its ${Object.keys(place.shapes).find((k) => typeof place.shapes[k] !== "string")} ones are used.` : "",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// How far the look reaches past the letters, at a letter size: the black
|
|
232
|
+
// outline and its soft shadow, or the dark box around each line. The page
|
|
233
|
+
// that draws the captions uses the same figures.
|
|
234
|
+
function inkOf(fontPx, { style = "karaoke", box = false } = {}) {
|
|
235
|
+
const stroke = Math.max(2, Math.round(fontPx * (style === "word" ? 0.1 : 0.09)));
|
|
236
|
+
const shadow = { y: Math.round(fontPx * 0.05), blur: Math.round(fontPx * 0.12) };
|
|
237
|
+
return { stroke, shadow, reach: box ? Math.ceil(fontPx * 0.3) : stroke + shadow.blur };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Where the captions go in a video of this size for all the places at
|
|
241
|
+
// once: inside every place's zone, centered, at the bottom, the middle or
|
|
242
|
+
// the top of what is left. Nothing reaches past the safe space at the
|
|
243
|
+
// sides, the outline and its shadow included: letters too big for four of
|
|
244
|
+
// them to fit across are made smaller. The block is taller than its lines
|
|
245
|
+
// by half a letter, so that what reaches above and below them (accents,
|
|
246
|
+
// descenders, the outline and its shadow) fits in it: the page that draws
|
|
247
|
+
// the captions, and the ASS file, keep all of it inside.
|
|
248
|
+
//
|
|
249
|
+
// options: { style, size (percent of the short side of the screen the
|
|
250
|
+
// video plays on), position, lines, lineHeight, box }
|
|
251
|
+
function captionLayout(width, height, keys, { style, size, position = "bottom", lines = 2, lineHeight = 1.18, box = false }) {
|
|
252
|
+
// Each place once: YouTube on a vertical video is YouTube Shorts.
|
|
253
|
+
const zones = [];
|
|
254
|
+
for (const key of keys) {
|
|
255
|
+
const z = zone(key, width, height);
|
|
256
|
+
if (!zones.some((other) => other.key === z.key)) zones.push(z);
|
|
257
|
+
}
|
|
258
|
+
const safe = { top: 0, bottom: 0, left: 0, right: 0 };
|
|
259
|
+
for (const z of zones) for (const side of Object.keys(safe)) safe[side] = Math.max(safe[side], z.margins[side]);
|
|
260
|
+
// Text big enough on the smallest screen it plays on.
|
|
261
|
+
const asked = Math.round(Math.max(...zones.map((z) => (size / 100) * z.textBase)));
|
|
262
|
+
const shown = style === "word" ? 1 : lines;
|
|
263
|
+
const room = height - safe.top - safe.bottom;
|
|
264
|
+
const notes = [];
|
|
265
|
+
// The block for a letter size: its lines and the outline or box around
|
|
266
|
+
// them, where it sits, and how much of the width it has. The button
|
|
267
|
+
// column narrows the line where the captions reach it; the text stays
|
|
268
|
+
// centered, so the same space is kept on the other side.
|
|
269
|
+
const place = (fontPx) => {
|
|
270
|
+
const blockMax = Math.ceil(shown * lineHeight * fontPx + 0.5 * fontPx);
|
|
271
|
+
let top;
|
|
272
|
+
let bottom;
|
|
273
|
+
if (room < blockMax) {
|
|
274
|
+
const middle = safe.top + room / 2;
|
|
275
|
+
top = middle - blockMax / 2;
|
|
276
|
+
bottom = middle + blockMax / 2;
|
|
277
|
+
} else if (position === "top") {
|
|
278
|
+
top = safe.top;
|
|
279
|
+
bottom = top + blockMax;
|
|
280
|
+
} else if (position === "middle") {
|
|
281
|
+
// The middle of the safe space, as the top and the bottom are its own.
|
|
282
|
+
const middle = safe.top + room / 2;
|
|
283
|
+
top = middle - blockMax / 2;
|
|
284
|
+
bottom = middle + blockMax / 2;
|
|
285
|
+
} else {
|
|
286
|
+
bottom = height - safe.bottom;
|
|
287
|
+
top = bottom - blockMax;
|
|
288
|
+
}
|
|
289
|
+
top = Math.max(0, top);
|
|
290
|
+
bottom = Math.min(height, bottom);
|
|
291
|
+
let side = Math.max(safe.left, safe.right);
|
|
292
|
+
for (const z of zones) if (z.column && bottom > z.column.from) side = Math.max(side, z.column.width);
|
|
293
|
+
return { fontPx, blockMax, top, bottom, side, textWidth: Math.max(0, width - 2 * side) };
|
|
294
|
+
};
|
|
295
|
+
// Letters under a pixel draw nothing, and leave nothing to cut lines by.
|
|
296
|
+
if (asked < 1) throw new Error(`The video is ${width}x${height}, too small for captions: at --size ${size} the letters would be less than a pixel tall`);
|
|
297
|
+
let at = place(asked);
|
|
298
|
+
const fourFit = (p) => p.fontPx * 4 + 2 * inkOf(p.fontPx, { style, box }).reach <= p.textWidth;
|
|
299
|
+
if (!fourFit(at)) {
|
|
300
|
+
// The size where four letters and the outline fit across, found from
|
|
301
|
+
// the top down; a smaller block can leave the button column below it.
|
|
302
|
+
let fontPx = at.fontPx;
|
|
303
|
+
while (fontPx > 8 && !fourFit(place(fontPx))) fontPx--;
|
|
304
|
+
at = place(fontPx);
|
|
305
|
+
notes.push(`At ${size}% the letters would be ${asked} px, and four of them do not fit across the ${Math.round(at.textWidth)} px of safe space, so they are ${at.fontPx} px.`);
|
|
306
|
+
}
|
|
307
|
+
if (room < at.blockMax) notes.push(`The space left between the places' top and bottom margins (${Math.round(room)} px) is smaller than a caption (${at.blockMax} px); the captions sit in the middle of it and reach into the margins.`);
|
|
308
|
+
for (const z of zones) if (z.mismatch) notes.push(z.mismatch);
|
|
309
|
+
const ink = inkOf(at.fontPx, { style, box });
|
|
310
|
+
return {
|
|
311
|
+
frame: { width, height },
|
|
312
|
+
zones,
|
|
313
|
+
safe,
|
|
314
|
+
side: at.side,
|
|
315
|
+
// The safe width, and the width the letters themselves can take in it.
|
|
316
|
+
textWidth: at.textWidth,
|
|
317
|
+
glyphWidth: Math.max(0, at.textWidth - 2 * ink.reach),
|
|
318
|
+
ink,
|
|
319
|
+
fontPx: at.fontPx,
|
|
320
|
+
askedPx: asked,
|
|
321
|
+
lineHeight,
|
|
322
|
+
position,
|
|
323
|
+
blockTop: at.top,
|
|
324
|
+
blockBottom: at.bottom,
|
|
325
|
+
blockMax: at.blockMax,
|
|
326
|
+
notes,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Characters a line can take at this width and size: a bold sans averages
|
|
331
|
+
// about 0.6 of its size per character. 8 where no width or size says.
|
|
332
|
+
function charsThatFit(textWidth, fontPx) {
|
|
333
|
+
const n = Math.floor(textWidth / (0.6 * fontPx));
|
|
334
|
+
return Number.isFinite(n) ? Math.max(8, Math.min(42, n)) : 8;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = { PLACES, PLACE_NAMES, CHECKED, shapeOf, defaultPlaces, zone, captionLayout, charsThatFit, inkOf };
|