@gentbajko/slopify 3.4.2 → 3.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.
Files changed (34) hide show
  1. package/dist/adapters/image/fal.js +2 -2
  2. package/dist/adapters/image/openai.js +2 -0
  3. package/dist/assets/models.yaml +7 -0
  4. package/dist/catalog/schema.js +1 -1
  5. package/dist/edge/http/studio.js +3 -0
  6. package/dist/extension/slopify-studio-chrome.zip +0 -0
  7. package/dist/extension/slopify-studio-firefox.zip +0 -0
  8. package/dist/kernel/db/migrations/0049-square-format.sql +10 -0
  9. package/dist/kernel/pipeline.js +6 -1
  10. package/dist/kernel/ports/host-cli.js +1 -1
  11. package/dist/patch-notes/3.5.0.md +14 -0
  12. package/dist/patch-notes/3.5.1.md +12 -0
  13. package/dist/patch-notes/index.json +12 -0
  14. package/dist/slices/rebuild/recipe-describe.js +1 -1
  15. package/dist/slices/rebuild/recipe-input-schema.js +1 -1
  16. package/dist/slices/rebuild/recipe-visual.js +2 -1
  17. package/dist/slices/rebuild/runtime-figure-card.js +2 -2
  18. package/dist/slices/rebuild/runtime-subtitles.js +2 -3
  19. package/dist/slices/storage/schema.js +1 -1
  20. package/dist/slices/studio/performance.js +83 -0
  21. package/dist/slices/studio/videos.js +9 -3
  22. package/dist/slices/style-preview/render.js +1 -0
  23. package/dist/slices/subtitles/layout.js +5 -1
  24. package/dist/slices/thumbnail/by-llm.js +2 -1
  25. package/dist/slices/thumbnail/run.js +2 -1
  26. package/dist/slices/video/plan.js +1 -0
  27. package/dist/slices/voices/portraits.js +2 -1
  28. package/dist/web/assets/index-BGnKvlNS.css +1 -0
  29. package/dist/web/assets/index-ePjxsfy3.js +310 -0
  30. package/dist/web/assets/{pdf-Dsbuwxiz.js → pdf-C7lfRlHt.js} +1 -1
  31. package/dist/web/index.html +2 -2
  32. package/package.json +1 -1
  33. package/dist/web/assets/index-B4HZ24q0.js +0 -310
  34. package/dist/web/assets/index-GnlzADIB.css +0 -1
@@ -30,8 +30,8 @@ const aspectFields = ["image_size", "aspect_ratio"];
30
30
  // orientation, so the portrait one is "portrait_16_9" and is 9:16; `aspect_ratio` takes the
31
31
  // run's own words.
32
32
  const sizes = {
33
- image_size: { "16:9": "landscape_16_9", "9:16": "portrait_16_9" },
34
- aspect_ratio: { "16:9": "16:9", "9:16": "9:16" },
33
+ image_size: { "16:9": "landscape_16_9", "9:16": "portrait_16_9", "1:1": "square_hd" },
34
+ aspect_ratio: { "16:9": "16:9", "9:16": "9:16", "1:1": "1:1" },
35
35
  };
36
36
  const modelAspects = {
37
37
  "fal-ai/flux-2": "image_size",
@@ -24,6 +24,7 @@ export const openAiImageModels = [
24
24
  const standard = {
25
25
  "16:9": "1536x1024",
26
26
  "9:16": "1024x1536",
27
+ "1:1": "1024x1024",
27
28
  };
28
29
  // gpt-image-2 takes an arbitrary WIDTH×HEIGHT whose sides divide by 16, between 1:3 and
29
30
  // 3:1, so on that model the closest supported size is the aspect exactly and the render
@@ -31,6 +32,7 @@ const standard = {
31
32
  const exact = {
32
33
  "16:9": "1536x864",
33
34
  "9:16": "864x1536",
35
+ "1:1": "1024x1024",
34
36
  };
35
37
  const arbitrarySizes = /^gpt-image-2/;
36
38
  // A wire payload is narrowed, never cast.
@@ -151,6 +151,7 @@ image:
151
151
  aspectRatios: &a5
152
152
  - 16:9
153
153
  - 9:16
154
+ - "1:1"
154
155
  - provider: openai-image
155
156
  id: gpt-image-2.5-flare
156
157
  name: GPT Image 2.5 Flare
@@ -261,6 +262,7 @@ image:
261
262
  aspectRatios:
262
263
  - 16:9
263
264
  - 9:16
265
+ - "1:1"
264
266
  - provider: fal
265
267
  id: fal-ai/wan-25-preview/image-to-video
266
268
  name: Wan 2.5 (image to video, 720p)
@@ -274,6 +276,7 @@ image:
274
276
  aspectRatios:
275
277
  - 16:9
276
278
  - 9:16
279
+ - "1:1"
277
280
  - provider: fal
278
281
  id: fal-ai/bytedance/seedance/v1/pro/fast/image-to-video
279
282
  name: Seedance 1.0 Pro Fast (image to video, 720p)
@@ -288,6 +291,7 @@ image:
288
291
  aspectRatios:
289
292
  - 16:9
290
293
  - 9:16
294
+ - "1:1"
291
295
  - provider: replicate
292
296
  id: kwaivgi/kling-v2.5-turbo-pro
293
297
  name: Kling 2.5 Turbo Pro (image to video)
@@ -301,6 +305,7 @@ image:
301
305
  aspectRatios:
302
306
  - 16:9
303
307
  - 9:16
308
+ - "1:1"
304
309
  - provider: replicate
305
310
  id: wan-video/wan-2.5-i2v
306
311
  name: Wan 2.5 (image to video, 720p)
@@ -314,6 +319,7 @@ image:
314
319
  aspectRatios:
315
320
  - 16:9
316
321
  - 9:16
322
+ - "1:1"
317
323
  - provider: replicate
318
324
  id: bytedance/seedance-1-pro-fast
319
325
  name: Seedance 1 Pro Fast (image to video, 720p)
@@ -327,6 +333,7 @@ image:
327
333
  aspectRatios:
328
334
  - 16:9
329
335
  - 9:16
336
+ - "1:1"
330
337
  tts:
331
338
  - provider: elevenlabs
332
339
  id: eleven_v4
@@ -48,7 +48,7 @@ export const imageModelSchema = base
48
48
  .extend({
49
49
  image: z
50
50
  .object({
51
- aspectRatios: z.array(z.enum(["16:9", "9:16"])).min(1),
51
+ aspectRatios: z.array(z.enum(["16:9", "9:16", "1:1"])).min(1),
52
52
  resolution: z.string().max(100).optional(),
53
53
  })
54
54
  .strict(),
@@ -13,6 +13,7 @@ import { backfillVideos } from "../../slices/studio/backfill.js";
13
13
  import { releaseCalendar } from "../../slices/studio/calendar.js";
14
14
  import { studioPlaylistMax, studioUploadUrl, } from "../../slices/studio/model.js";
15
15
  import { packItem, uploadPack } from "../../slices/studio/pack.js";
16
+ import { channelPerformance } from "../../slices/studio/performance.js";
16
17
  import { writeUploadPick } from "../../slices/studio/pick.js";
17
18
  import { postingPlanSchema, readPlan, writePlan } from "../../slices/studio/plan.js";
18
19
  import { leadHoursMax, seriesOf } from "../../slices/studio/plan-model.js";
@@ -380,6 +381,8 @@ export function studioRoutes(deps) {
380
381
  // Studio's numbers for a project's videos, and every finished A/B test (Library).
381
382
  .get("/stats/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ stats: projectStats(deps.db, c.req.valid("param").projectId) }))
382
383
  .get("/ab-results", (c) => c.json({ results: abResults(deps.db) }))
384
+ // Channels → YouTube: the channel's videos on YouTube with Studio's numbers and totals.
385
+ .get("/channels/:channelId/performance", zValidator("param", z.object({ channelId: id }), onInvalid), (c) => c.json(channelPerformance(deps, c.req.valid("param").channelId)))
383
386
  // The YouTube videos a project's uploads became, and their A/B tests.
384
387
  .get("/videos/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ videos: projectVideos(deps.db, c.req.valid("param").projectId) }))
385
388
  // A video's link pasted in Slopify, for an upload made without the extension; an empty
@@ -0,0 +1,10 @@
1
+ -- foreign-keys: off
2
+ -- Square projects (3.5.0): `projects.format` takes '1:1' beside '16:9' and '9:16'. SQLite
3
+ -- cannot widen a CHECK constraint in place, so the table is rebuilt; every table that points at
4
+ -- projects keeps its rows (migrate.ts turns enforcement off for this file and checks every
5
+ -- reference before it commits).
6
+ CREATE TABLE projects_square_upgrade (id TEXT PRIMARY KEY, title TEXT NOT NULL CHECK(length(title) <= 200), format TEXT NOT NULL CHECK(format IN ('16:9','9:16','1:1')), config TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
7
+ INSERT INTO projects_square_upgrade (id,title,format,config,created_at,updated_at)
8
+ SELECT id,title,format,config,created_at,updated_at FROM projects;
9
+ DROP TABLE projects;
10
+ ALTER TABLE projects_square_upgrade RENAME TO projects;
@@ -2,7 +2,12 @@
2
2
  // implement a stage and the edge that reports one all name the same finite sets, and the
3
3
  // runner may not import a slice to learn them.
4
4
  // The frame a run is made in, and the aspect an image is asked for: one set, named once.
5
- export const formats = ["16:9", "9:16"];
5
+ export const formats = ["16:9", "9:16", "1:1"];
6
+ // The shape a project's thumbnail is drawn in: YouTube shows thumbnails 16:9, so a square
7
+ // project's is landscape; the others follow the project.
8
+ export function thumbnailAspect(format) {
9
+ return format === "1:1" ? "16:9" : format;
10
+ }
6
11
  // Document is last so every screen that walks this list in order shows the six older
7
12
  // stages where they always were.
8
13
  export const stageKinds = [
@@ -66,7 +66,7 @@ export const hostImageSchema = z
66
66
  // "codex-imagegen" is the Codex default; any other is one of the Codex CLI's models.
67
67
  model: short.refine((v) => !v.startsWith("-") && !/\s/.test(v)),
68
68
  prompt: z.string().min(1).max(bridgeLimits.text),
69
- aspect: z.enum(["16:9", "9:16"]),
69
+ aspect: z.enum(["16:9", "9:16", "1:1"]),
70
70
  thinking: z.enum(thinkingModes).optional(),
71
71
  // The establishing image, as base64 (at most `bridgeLimits.reference` bytes decoded).
72
72
  reference: z
@@ -0,0 +1,14 @@
1
+ # Slopify 3.5.0
2
+
3
+ Released 2 October 2026.
4
+
5
+ ## Square projects
6
+
7
+ - **A third frame format: 1:1.** Pick **Square** beside Landscape (16:9) and Portrait (9:16) in Play or Edit project. Images are drawn square, and a project that makes a video renders it 1080×1080, captions placed for it.
8
+ - **Shorts stay 9:16, and the thumbnail stays 16:9** for YouTube, whatever the project's format.
9
+ - Every image model Slopify offers draws square pictures, and the image-to-video clip models take them too.
10
+
11
+ ## YouTube Studio extension (1.1.2)
12
+
13
+ - **Read Studio numbers no longer lands on "Oops, something went wrong".** Studio rewrote the address the extension opens; the extension now keeps its task aside before Studio's page loads, so each video's Analytics opens as it should. The same goes for the pages it opens for A/B tests, the finishing touches and the checks.
14
+ - **Download the extension again** (Settings → YouTube Studio) and reload it in `chrome://extensions`. It is version 1.1.2.
@@ -0,0 +1,12 @@
1
+ # Slopify 3.5.1
2
+
3
+ Released 2 October 2026.
4
+
5
+ ## Channels → YouTube numbers
6
+
7
+ - **A channel's YouTube numbers in one place.** A new **YouTube numbers** tab on each channel shows its long videos' and its shorts' totals (views, impressions, click-through rate, average view duration, watch hours), then every video with its shorts under it, ordered newest first, by views, by click-through rate or by average view.
8
+ - The numbers are what Studio showed when the Slopify Studio extension last read them: daily while Chrome is open, or at once with **Read Studio numbers now** in its popup.
9
+
10
+ ## Calendar → Releases
11
+
12
+ - **"Checks clear" for real.** Studio shows a clear video as "No notices · reaching viewers and earning"; Slopify now reads that as clear instead of as a problem.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.5.1",
4
+ "title": "Slopify 3.5.1",
5
+ "version": "3.5.1",
6
+ "date": "2026-10-02"
7
+ },
8
+ {
9
+ "id": "3.5.0",
10
+ "title": "Slopify 3.5.0",
11
+ "version": "3.5.0",
12
+ "date": "2026-10-02"
13
+ },
2
14
  {
3
15
  "id": "3.4.2",
4
16
  "title": "Slopify 3.4.2",
@@ -73,7 +73,7 @@ export function describedText(context, markdown, dependsOn, offset = 0) {
73
73
  // upright too when a 16:9 project makes Shorts. Regenerated with its own token.
74
74
  function cardRecipe(context, block, index, dependsOn) {
75
75
  const { config } = context;
76
- const formats = config.format === "16:9" && usesShorts(config) ? ["16:9", "9:16"] : [config.format];
76
+ const formats = config.format !== "9:16" && usesShorts(config) ? [config.format, "9:16"] : [config.format];
77
77
  return recipe(context, `figure:card:${String(index)}`, "images", {
78
78
  kind: "local",
79
79
  version: 1,
@@ -97,7 +97,7 @@ export const recipeInputSchema = z.discriminatedUnion("kind", [
97
97
  provider: z.string(),
98
98
  model: z.string(),
99
99
  prompt: z.string(),
100
- aspect: z.enum(["16:9", "9:16"]),
100
+ aspect: z.enum(["16:9", "9:16", "1:1"]),
101
101
  animate: z.object({ image: z.string(), seconds: z.number() }).strict().optional(),
102
102
  thinking: z.enum(thinkingModes).optional(),
103
103
  reference: z
@@ -1,3 +1,4 @@
1
+ import { thumbnailAspect } from "../../kernel/pipeline.js";
1
2
  import { subjectOf, thumbnailCountOf, thumbnailKey } from "../admission/model.js";
2
3
  import { usesShortMode } from "../admission/short-mode.js";
3
4
  import { render } from "../admission/substitute.js";
@@ -262,7 +263,7 @@ appearance) {
262
263
  kind: "image",
263
264
  version: 1,
264
265
  ...imageChoice(config),
265
- aspect: config.format,
266
+ aspect: thumbnailAspect(config.format),
266
267
  prompt: thumbnailVariantPrompt(prompt, variant),
267
268
  ...(reference === undefined ? {} : { reference: reference.input }),
268
269
  // The thumbnail stands for the whole video, like the establishing image.
@@ -17,12 +17,12 @@ const values = z.object({
17
17
  source: z.string(),
18
18
  section: z.string().nullable(),
19
19
  image: z.string().nullable(),
20
- formats: z.array(z.enum(["16:9", "9:16"])).min(1),
20
+ formats: z.array(z.enum(["16:9", "9:16", "1:1"])).min(1),
21
21
  fontId: z.string(),
22
22
  color: z.string().nullable(),
23
23
  language: z.string(),
24
24
  });
25
- const frames = { "16:9": [1920, 1080], "9:16": [1080, 1920] };
25
+ const frames = { "16:9": [1920, 1080], "9:16": [1080, 1920], "1:1": [1080, 1080] };
26
26
  export async function executeFigureCard(deps, context, piece) {
27
27
  if (piece.input.kind !== "local")
28
28
  throw new Error("Slopify hit an internal error (a figure card step was set up wrongly). Try again; if it happens again, use Download diagnostics in Settings and report it.");
@@ -10,6 +10,7 @@ import { validateCues } from "../revisions/rules.js";
10
10
  import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
11
11
  import { outputPath } from "../storage/layout.js";
12
12
  import { captionCues, serializeAss, serializeSrt, serializeVtt } from "../subtitles/captions.js";
13
+ import { subtitleFrame } from "../subtitles/layout.js";
13
14
  import { spoken } from "../video/plan.js";
14
15
  import { usesVoices } from "../voices/model.js";
15
16
  import { speakerColour } from "../voices/palette.js";
@@ -186,9 +187,7 @@ async function files(deps, context, piece, snapshot) {
186
187
  return "held";
187
188
  const prepared = [];
188
189
  try {
189
- const frame = view.revision.config.format === "16:9"
190
- ? { width: 1920, height: 1080 }
191
- : { width: 1080, height: 1920 };
190
+ const frame = subtitleFrame(view.revision.config.format);
192
191
  const voices = captionVoices(view.revision.config);
193
192
  const portraits = panelPortraits(deps.db, voices);
194
193
  const overlay = voices !== undefined && usesSpeakerPanel(voices.format)
@@ -10,7 +10,7 @@ export const metaSchema = z.object({
10
10
  prompt: z.string().optional(),
11
11
  index: z.number().optional(),
12
12
  short: z.number().optional(),
13
- format: z.enum(["16:9", "9:16"]).optional(),
13
+ format: z.enum(["16:9", "9:16", "1:1"]).optional(),
14
14
  sentences: z.tuple([z.number(), z.number()]).optional(),
15
15
  warnings: z.array(z.string()).optional(),
16
16
  provider: z.string().optional(),
@@ -0,0 +1,83 @@
1
+ import { listProjects } from "../admission/repo.js";
2
+ import { defaultChannelId } from "../channels/model.js";
3
+ import { projectChannels } from "../channels/repo.js";
4
+ import { uploadPack } from "./pack.js";
5
+ import { releasesOf } from "./releases.js";
6
+ import { projectStats } from "./stats.js";
7
+ import { projectVideos } from "./videos.js";
8
+ function totals(rows) {
9
+ const read = rows.flatMap((row) => (row.stats === null ? [] : [row.stats]));
10
+ const sum = (pick) => read.reduce((total, stats) => total + (pick(stats) ?? 0), 0);
11
+ const impressions = sum((stats) => stats.impressions);
12
+ const clicks = read.reduce((total, stats) => total +
13
+ (stats.impressions !== null && stats.ctr !== null
14
+ ? (stats.impressions * stats.ctr) / 100
15
+ : 0), 0);
16
+ const viewed = read.filter((stats) => stats.views !== null && stats.averageViewSeconds !== null);
17
+ const viewWeight = viewed.reduce((total, stats) => total + (stats.views ?? 0), 0);
18
+ return {
19
+ videos: rows.length,
20
+ views: sum((stats) => stats.views),
21
+ impressions: read.some((stats) => stats.impressions !== null) ? impressions : null,
22
+ ctr: impressions > 0 ? Math.round((clicks / impressions) * 1000) / 10 : null,
23
+ averageViewSeconds: viewWeight > 0
24
+ ? Math.round(viewed.reduce((total, stats) => total + (stats.views ?? 0) * (stats.averageViewSeconds ?? 0), 0) / viewWeight)
25
+ : null,
26
+ watchHours: Math.round(sum((stats) => stats.watchHours) * 10) / 10,
27
+ };
28
+ }
29
+ export function channelPerformance(deps, channelId) {
30
+ const channels = projectChannels(deps.db);
31
+ const projects = [];
32
+ for (const project of listProjects(deps.db)) {
33
+ if ((channels.get(project.id) ?? defaultChannelId) !== channelId)
34
+ continue;
35
+ const videos = projectVideos(deps.db, project.id).filter((one) => one.uploadState === "done");
36
+ const stats = projectStats(deps.db, project.id);
37
+ if (videos.length === 0 && stats.length === 0)
38
+ continue;
39
+ const releases = releasesOf(deps.db, project.id);
40
+ const pack = uploadPack(deps, project.id);
41
+ const items = pack.ok ? pack.pack.items : [];
42
+ const row = (short) => {
43
+ const video = videos.find((one) => one.short === short);
44
+ const numbers = stats.find((one) => one.short === short) ?? null;
45
+ if (video === undefined && numbers === null)
46
+ return null;
47
+ const release = releases.find((one) => one.short === (short ?? 0))?.at;
48
+ const item = items.find((one) => (one.short ?? null) === short);
49
+ return {
50
+ short,
51
+ title: item?.title ?? (short === null ? project.title : `Short ${String(short)}`),
52
+ videoId: video?.videoId ?? numbers?.videoId ?? null,
53
+ releasedAt: release !== undefined && release !== "" ? release : null,
54
+ stats: numbers,
55
+ };
56
+ };
57
+ const shortNumbers = [
58
+ ...new Set([
59
+ ...videos.flatMap((one) => (one.short === null ? [] : [one.short])),
60
+ ...stats.flatMap((one) => (one.short === null ? [] : [one.short])),
61
+ ]),
62
+ ].toSorted((left, right) => left - right);
63
+ projects.push({
64
+ projectId: project.id,
65
+ title: project.title,
66
+ long: row(null),
67
+ shorts: shortNumbers.flatMap((short) => {
68
+ const one = row(short);
69
+ return one === null ? [] : [one];
70
+ }),
71
+ });
72
+ }
73
+ const longs = projects.flatMap((one) => (one.long === null ? [] : [one.long]));
74
+ const shorts = projects.flatMap((one) => one.shorts);
75
+ const reads = [...longs, ...shorts].flatMap((one) => one.stats === null ? [] : [one.stats.readAt]);
76
+ return {
77
+ projects: projects.toSorted((left, right) => Date.parse(right.long?.releasedAt ?? right.shorts[0]?.releasedAt ?? "") -
78
+ Date.parse(left.long?.releasedAt ?? left.shorts[0]?.releasedAt ?? "") || 0),
79
+ long: totals(longs),
80
+ shorts: totals(shorts),
81
+ readAt: reads.toSorted().at(-1) ?? null,
82
+ };
83
+ }
@@ -26,7 +26,7 @@ function rowOf(row) {
26
26
  commentMessage: typeof row.comment_message === "string" ? row.comment_message : null,
27
27
  abMessage: typeof row.ab_message === "string" ? row.ab_message : null,
28
28
  abAt: typeof row.ab_at === "string" ? row.ab_at : null,
29
- checks: typeof row.checks === "string" ? row.checks : null,
29
+ checks: typeof row.checks === "string" ? checksWord(row.checks) : null,
30
30
  };
31
31
  }
32
32
  // Records (or replaces) the video an upload became: "filled" from the upload dialog, "done"
@@ -93,7 +93,13 @@ export function previousLongVideo(db, projectId) {
93
93
  .get(projectId, own?.recorded_at ?? null, own?.recorded_at ?? null);
94
94
  return typeof row?.video_id === "string" ? row.video_id : undefined;
95
95
  }
96
- // Studio's word on a known video's checks, read from its Content list row.
96
+ // Studio's word on a known video's checks, read from its Content list row. Studio shows a clear
97
+ // video's Restrictions as "None", or as "—" with "No notices… reaching viewers and earning";
98
+ // both are kept as "ok", anything else as Studio's words (a claim, a limit).
99
+ export function checksWord(text) {
100
+ const plain = text.replace(/\s+/g, " ").trim();
101
+ return /^(none|[—–-]?\s*no notices.*)$/i.test(plain) || /^[—–-]$/.test(plain) ? "ok" : plain;
102
+ }
97
103
  export function setChecks(db, videoId, checks) {
98
- db.prepare("UPDATE youtube_videos SET checks=? WHERE video_id=?").run(checks, videoId);
104
+ db.prepare("UPDATE youtube_videos SET checks=? WHERE video_id=?").run(checksWord(checks), videoId);
99
105
  }
@@ -23,6 +23,7 @@ export class StylePreviewError extends Error {
23
23
  const previewFrame = {
24
24
  "16:9": { width: 480, height: 270 },
25
25
  "9:16": { width: 270, height: 480 },
26
+ "1:1": { width: 360, height: 360 },
26
27
  };
27
28
  const shotSeconds = stylePreviewSeconds / 3;
28
29
  const chapterTitle = "Chapter one";
@@ -1,5 +1,9 @@
1
1
  export function subtitleFrame(format) {
2
- return format === "9:16" ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 };
2
+ return format === "9:16"
3
+ ? { width: 1080, height: 1920 }
4
+ : format === "1:1"
5
+ ? { width: 1080, height: 1080 }
6
+ : { width: 1920, height: 1080 };
3
7
  }
4
8
  // ASS and the browser preview share anchors. Edge positions keep the existing
5
9
  // 60-pixel safe margin; interior positions center both single and multiline cues.
@@ -1,3 +1,4 @@
1
+ import { thumbnailAspect } from "../../kernel/pipeline.js";
1
2
  export function thumbnailMessages(brief) {
2
3
  const values = Object.entries(brief.values);
3
4
  return [
@@ -14,7 +15,7 @@ export function thumbnailMessages(brief) {
14
15
  ? "(none)"
15
16
  : values.map(([name, value]) => `${name}: ${value}`).join("\n"),
16
17
  "",
17
- `Aspect ratio of the thumbnail: ${brief.format}`,
18
+ `Aspect ratio of the thumbnail: ${thumbnailAspect(brief.format)}`,
18
19
  "",
19
20
  "The article this video narrates:",
20
21
  "",
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { z } from "zod";
4
+ import { thumbnailAspect } from "../../kernel/pipeline.js";
4
5
  import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
5
6
  import { projectById } from "../admission/repo.js";
6
7
  import { outputFileName, outputPath } from "../storage/layout.js";
@@ -167,7 +168,7 @@ async function make(deps, context, providers, project, choice, prompt) {
167
168
  model: choice.model,
168
169
  ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
169
170
  prompt,
170
- aspect: project.format,
171
+ aspect: thumbnailAspect(project.format),
171
172
  });
172
173
  if (!result.ok)
173
174
  return "held";
@@ -9,6 +9,7 @@ export const fps = 30;
9
9
  const frames = {
10
10
  "16:9": { width: 1920, height: 1080 },
11
11
  "9:16": { width: 1080, height: 1920 },
12
+ "1:1": { width: 1080, height: 1080 },
12
13
  };
13
14
  // A zoom shot zooms linearly and centred between 100% and 100% + the project's zoom
14
15
  // percent; a pan holds 100% + its percent while it travels. The ends are formatted into an ffmpeg
@@ -2,6 +2,7 @@ import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { sniffImage } from "../channels/images.js";
4
4
  import { imageBlob } from "../channels/repo.js";
5
+ import { subtitleFrame } from "../subtitles/layout.js";
5
6
  import { usesVoices } from "./model.js";
6
7
  import { panelTiles, usesSpeakerPanel } from "./panel.js";
7
8
  export function panelPortraits(db, voices) {
@@ -38,7 +39,7 @@ export function writePortraits(db, config, directory) {
38
39
  if (voices === undefined || portraits.every((one) => one === undefined))
39
40
  return undefined;
40
41
  // The caption file's frame (`rebuild/runtime-subtitles.ts`), so a portrait lands in its tile.
41
- const tiles = panelTiles(voices.speakers.length, config.format === "16:9" ? { width: 1920, height: 1080 } : { width: 1080, height: 1920 });
42
+ const tiles = panelTiles(voices.speakers.length, subtitleFrame(config.format));
42
43
  const overlays = [];
43
44
  const recorded = [];
44
45
  portraits.forEach((portrait, index) => {