ossclip 0.1.33 → 0.1.34

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/src/produce.ts CHANGED
@@ -166,7 +166,12 @@ import {
166
166
  AGY_PRINT_TIMEOUT,
167
167
  type Production,
168
168
  ossclipOutputPathFor,
169
+ resolveOutputFrame,
170
+ RESOLUTION_CHOICES,
171
+ smallestSource,
172
+ ResolutionChoiceSchema,
169
173
  type ProviderName,
174
+ type ResolutionChoice,
170
175
  type Scene,
171
176
  type SceneComponentId,
172
177
  type Segment,
@@ -256,6 +261,14 @@ export const TranscriptKeySchema = z.object({
256
261
  * runs) means "no biasing".
257
262
  */
258
263
  dictionary: z.array(z.string()).optional(),
264
+ /**
265
+ * Whether whisper ran its TRANSLATE task (`-tr`, 2026-08-29). It changes
266
+ * the decoded TEXT — Urdu speech comes back as English words — so it
267
+ * re-keys the cache exactly like the language does, or a warm workdir
268
+ * serves the Urdu-script transcript to a translate run. Absent (old key
269
+ * files) means "no translation", the `dictionary` contract.
270
+ */
271
+ translate: z.boolean().optional(),
259
272
  });
260
273
  export type TranscriptKey = z.infer<typeof TranscriptKeySchema>;
261
274
 
@@ -280,6 +293,9 @@ export function transcriptCacheReusable(
280
293
  // "" and absent both mean whisper's en default — program.ts rejects an
281
294
  // empty code, but a key file predating that guard must not wedge.
282
295
  (effective.language ?? "") === (requested.language ?? "") &&
296
+ // Absent and false are the same "no translation", so pre-flag key
297
+ // files reuse under a non-translate request.
298
+ (effective.translate ?? false) === (requested.translate ?? false) &&
283
299
  // ORDER-SENSITIVE by choice: the dictionary becomes whisper's --prompt
284
300
  // text verbatim, so a reordered list genuinely is a different decoder
285
301
  // input — treating it as equal would serve a transcript biased by a
@@ -537,6 +553,13 @@ export interface ProduceOptions {
537
553
  * decodes garbage (Urdu field test 2026-08-05).
538
554
  */
539
555
  whisperLanguage?: string;
556
+ /**
557
+ * `--whisper-translate`: whisper's TRANSLATE task (`-tr`) — non-English
558
+ * speech decoded straight to ENGLISH text. Distinct from
559
+ * `whisperLanguage`, which says what is SPOKEN; the two are passed
560
+ * together (whisper decodes better knowing the source language).
561
+ */
562
+ whisperTranslate?: boolean;
540
563
  /**
541
564
  * Vocabulary terms for this run (`--dictionary`, F4 2026-08-16), already
542
565
  * split/trimmed by the action. Wholesale beats the config's `dictionary`
@@ -584,6 +607,16 @@ export interface ProduceOptions {
584
607
  * platform chrome to dodge and a landscape source needs no cropping at all.
585
608
  */
586
609
  aspect?: "9:16" | "16:9";
610
+ /**
611
+ * `--resolution <height>`: how big the output actually renders. `auto`
612
+ * keeps what the source has (capped at 2160); an explicit height scales the
613
+ * 1080-wide composition to it. Already validated by commander (or by
614
+ * `ResolutionChoiceSchema` when it comes from the config), so this is a
615
+ * choice, never a raw string. `resolveOutputFrame` owns the math and the
616
+ * why; the value reaches THREE stages that would otherwise each pin 1080p:
617
+ * the folder-concat target, the mezzanine, and the render's own scale.
618
+ */
619
+ resolution?: ResolutionChoice;
587
620
  /**
588
621
  * `--concurrency <n>`: how many browser tabs the render opens at once,
589
622
  * beating the config's `renderConcurrency` and the cpus-2 default
@@ -709,6 +742,32 @@ export function resolveWatermark(
709
742
  return flag ?? configValue === true;
710
743
  }
711
744
 
745
+ /**
746
+ * The effective `--resolution` — `resolveWatermark`'s precedence verbatim: a
747
+ * TYPED flag always wins, and only then does the config supply the default.
748
+ *
749
+ * The config side is ZOD-PARSED rather than trusted: `loadConfig` hands back
750
+ * whatever the hand-editable JSON held, and an unparsed `"4k"` would reach
751
+ * `Number("4k")` inside `resolveOutputFrame` as NaN and size the whole render
752
+ * off it. A malformed value earns one warning naming the key and falls back
753
+ * to 1080 — the value every existing run already produces, so a typo costs a
754
+ * message rather than a surprise 4K render (CLAUDE.md: parse, never coerce).
755
+ * Pure, so the flag × config matrix is testable without a config file.
756
+ */
757
+ export function resolveResolution(
758
+ flag: ResolutionChoice | undefined,
759
+ configValue: unknown,
760
+ ): ResolutionChoice {
761
+ if (flag !== undefined) return flag;
762
+ if (configValue === undefined) return "1080";
763
+ const parsed = ResolutionChoiceSchema.safeParse(configValue);
764
+ if (parsed.success) return parsed.data;
765
+ console.log(
766
+ `⚠ config resolution ignored — expected one of ${RESOLUTION_CHOICES.join(", ")}, using 1080`,
767
+ );
768
+ return "1080";
769
+ }
770
+
712
771
  /**
713
772
  * The effective `--cover-in-video` switch — resolveWatermark's semantics
714
773
  * verbatim: a TYPED flag always wins (so `--no-cover-in-video` beats a
@@ -2078,6 +2137,46 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2078
2137
 
2079
2138
  const tools = { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath };
2080
2139
 
2140
+ // `--resolution` (2026-08-27), resolved before ANY stage that sizes pixels:
2141
+ // the folder concat, the mezzanine and the render each used to pin 1080p
2142
+ // independently, so a 4K take lost three quarters of its pixels before
2143
+ // anything looked at it. `auto` needs the SOURCE's size, which for a folder
2144
+ // means probing the clips here — the concat target is chosen before the
2145
+ // concatenated file (and its probe) exists.
2146
+ const resolution = resolveResolution(opts.resolution, cfg.resolution);
2147
+ const autoSource = async (): Promise<{ width: number; height: number } | null> => {
2148
+ if (resolution !== "auto") return null;
2149
+ if (isFolder && folderListing) {
2150
+ // Metadata-only probes, and only under `auto`: the default path adds no
2151
+ // ffprobe calls at all (the 4m32s probe-storm lesson, concat.ts:272).
2152
+ const sizes: Array<{ width: number; height: number }> = [];
2153
+ for (const entry of folderListing.entries) {
2154
+ try {
2155
+ const p = await probe(tools, join(input, entry.name));
2156
+ sizes.push({ width: p.width, height: p.height });
2157
+ } catch {
2158
+ // A clip that will not probe is the concat guard's problem, not
2159
+ // this sizing pass's — skip it rather than fail the run here.
2160
+ }
2161
+ }
2162
+ return smallestSource(sizes);
2163
+ }
2164
+ try {
2165
+ const p = await probe(tools, input);
2166
+ return { width: p.width, height: p.height };
2167
+ } catch {
2168
+ return null;
2169
+ }
2170
+ };
2171
+ const output = resolveOutputFrame({
2172
+ frame,
2173
+ source: (await autoSource()) ?? { width: 0, height: 0 },
2174
+ resolution,
2175
+ });
2176
+ if (output.scale !== 1) {
2177
+ console.log(`▸ resolution: ${output.width}x${output.height} (${resolution})`);
2178
+ }
2179
+
2081
2180
  // Resolved ONCE for the whole run — whisper biasing, repair vouching and
2082
2181
  // caption casing must all see the same list, or the passes disagree about
2083
2182
  // what a term is spelled like. A typed --dictionary wholesale beats the
@@ -2182,9 +2281,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2182
2281
  if (isFolder && folderListing) {
2183
2282
  const sort = opts.sort ?? "name";
2184
2283
  const result = await phases.time("ffmpeg", () =>
2284
+ // The concat target carries the RESOLVED size, not the base frame:
2285
+ // letterboxing every take into 1080p here would throw the pixels away
2286
+ // before the mezzanine or the render ever saw them (`--resolution`).
2185
2287
  concatFolder(tools, input, folderListing!, work, sort, {
2186
- w: frame.width,
2187
- h: frame.height,
2288
+ w: output.width,
2289
+ h: output.height,
2188
2290
  }),
2189
2291
  );
2190
2292
  console.log(
@@ -2304,6 +2406,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2304
2406
  const requestedKey: TranscriptKey = {
2305
2407
  model: requestedModel,
2306
2408
  ...(whisperLang.language !== undefined ? { language: whisperLang.language } : {}),
2409
+ // Omitted when off, so a non-translate run's key stays byte-identical to
2410
+ // every pre-flag key file (the dictionary posture).
2411
+ ...(opts.whisperTranslate === true ? { translate: true } : {}),
2307
2412
  // Omitted when empty, not written as [] — pre-dictionary key files have
2308
2413
  // no `dictionary` at all, and transcriptCacheReusable reads absent and
2309
2414
  // empty as the same "no biasing", so old workdirs must not re-transcribe.
@@ -2374,6 +2479,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2374
2479
  language: requestedKey.language,
2375
2480
  // Vocabulary biasing (F4) — undefined for an empty dictionary, so
2376
2481
  // the spawned args stay byte-identical to every pre-dictionary run.
2482
+ // From the KEY, like the language: whatever re-keys the cache is
2483
+ // what actually ran, so the two can never disagree.
2484
+ ...(requestedKey.translate === true ? { translate: true } : {}),
2377
2485
  prompt: whisperPromptFor(dictionary),
2378
2486
  },
2379
2487
  audioPath,
@@ -3829,7 +3937,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3829
3937
  scenes: scenes.length > 0 ? scenes : undefined,
3830
3938
  producer: producerStamp,
3831
3939
  theme,
3832
- render: { ...frame, fps: 30 },
3940
+ // The EFFECTIVE output size, not the base frame (`--resolution`): this is
3941
+ // what the file on disk actually is, and it is read downstream by the
3942
+ // mezzanine's scale decision, the NLE exports and the editor — all of
3943
+ // which would otherwise describe a 1080p file that isn't there.
3944
+ render: { width: output.width, height: output.height, fps: 30 },
3833
3945
  };
3834
3946
  await writeFile(join(work, "production.json"), JSON.stringify(production, null, 2));
3835
3947
 
@@ -4383,7 +4495,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4383
4495
  // ~/.ossclip/config.json's theme the first time anyone touched a color.
4384
4496
  baseTheme: configBaseTheme,
4385
4497
  baseCaptionLines,
4386
- settings: production.render,
4498
+ // The COMPOSITION's size, which is the BASE frame — never
4499
+ // `production.render` (2026-08-27). `production.render` describes the
4500
+ // FILE, and under `--resolution` those differ by the render's `scale`:
4501
+ // Remotion enlarges this composition by that factor, so sizing the
4502
+ // composition from the scaled dims applies it TWICE (2160×3840 became
4503
+ // 4320×7680 frames, which h264_videotoolbox refuses outright, at stitch
4504
+ // time, after every frame had been paid for). The Player reads these too,
4505
+ // and previewing at the base size is what keeps the editor cheap.
4506
+ settings: { ...frame, fps: production.render.fps },
4387
4507
  outputDurationSec: map.outputDuration,
4388
4508
  // The aspect travels with the measurement because the crop math needs it:
4389
4509
  // `object-fit: cover` spills vertically for a portrait source and
@@ -4890,6 +5010,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4890
5010
  outPath: rawPath,
4891
5011
  browserExecutable: cfg.browserExecutable,
4892
5012
  concurrency: renderConcurrency.concurrency,
5013
+ // The composition stays 1080-wide and Remotion renders it larger
5014
+ // (`--resolution`) — rebuilding the stage at 2160 would keep
5015
+ // `captionFontSizeFor`'s absolute 64px and draw quarter-size captions.
5016
+ scale: output.scale,
4893
5017
  cancelSignal: renderCancel.cancelSignal,
4894
5018
  onPhase: (phase: RenderPhase) => {
4895
5019
  signalPhase = renderSignalPhaseOf(phase);
package/src/program.ts CHANGED
@@ -3,14 +3,20 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { Command, InvalidArgumentError } from "commander";
5
5
  import { z } from "zod/v4";
6
- import { CleanupLevelSchema, COVER_MAX_WORDS, SceneComponentIdSchema } from "@ossclip/core";
6
+ import {
7
+ CleanupLevelSchema,
8
+ COVER_MAX_WORDS,
9
+ RESOLUTION_CHOICES,
10
+ ResolutionChoiceSchema,
11
+ SceneComponentIdSchema,
12
+ } from "@ossclip/core";
7
13
  import { STUDIO_ENTRY } from "@ossclip/renderer";
8
14
  import { loadEnvFiles } from "./env";
9
15
  import { ExportFormatSchema, runAnalyze } from "./analyze";
10
16
  import { expandHome } from "./paths";
11
17
  import { phaseBucketProps } from "./phase-timing";
12
18
  import { dictionaryFlag, jumpCutsFlag, produce, reviewFlag } from "./produce";
13
- import { accountsFlag, atFlag, platformsFlag } from "./publish";
19
+ import { accountsFlag, atFlag, deliveryFlag, platformsFlag, youtubePrivacyFlag } from "./publish";
14
20
  // The one interactive import that is STATIC rather than `await import()`: the
15
21
  // `resetInputSource()` run boundary in `buildProgram` has to run synchronously
16
22
  // while the program is being built, and `buildProgram` cannot await. The graph
@@ -300,6 +306,22 @@ export function buildProgram(): Command {
300
306
  "output shape: 9:16 (vertical, default) or 16:9 (landscape, 1920x1080)",
301
307
  "9:16",
302
308
  )
309
+ .option(
310
+ "--resolution <height>",
311
+ "output height: 1080 (default), 1440, 2160, or auto (keep what the source has, " +
312
+ "capped at 2160). Config key: \"resolution\"",
313
+ (v: string) => {
314
+ // Parse, never coerce (CLAUDE.md): a typo'd `--resolution 2610` must
315
+ // not silently render 1080p, and `Number()` on "auto" is NaN.
316
+ const parsed = ResolutionChoiceSchema.safeParse(v.trim());
317
+ if (!parsed.success) {
318
+ throw new InvalidArgumentError(
319
+ `--resolution wants one of ${RESOLUTION_CHOICES.join(", ")}, got "${v}"`,
320
+ );
321
+ }
322
+ return parsed.data;
323
+ },
324
+ )
303
325
  .option("--produce", "run the LLM producer brain to plan title cards & graphics", false)
304
326
  .option(
305
327
  "--clip <seconds>",
@@ -357,6 +379,12 @@ export function buildProgram(): Command {
357
379
  "--whisper-language <code>",
358
380
  "transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
359
381
  )
382
+ .option(
383
+ "--whisper-translate",
384
+ "translate the speech to ENGLISH captions instead of transcribing it verbatim " +
385
+ "(whisper's -tr; pair with --whisper-language for the SOURCE language)",
386
+ false,
387
+ )
360
388
  // COMMA-SEPARATED in one value, not variadic: a variadic option swallows
361
389
  // the optional positional [input] whenever the flag precedes the path,
362
390
  // and commander offers no way to give the positional priority.
@@ -644,6 +672,7 @@ export function buildProgram(): Command {
644
672
  repair: opts.repair,
645
673
  whisperModel: opts.whisperModel,
646
674
  whisperLanguage,
675
+ whisperTranslate: opts.whisperTranslate === true,
647
676
  // Split/trim/drop-empties (dictionaryFlag) — undefined stays
648
677
  // undefined so the config's dictionary can supply the default.
649
678
  dictionary: dictionaryFlag(opts.dictionary),
@@ -687,6 +716,9 @@ export function buildProgram(): Command {
687
716
  // Validated by concurrencyFlag at parse time; undefined = "not
688
717
  // typed", which is what lets the config supply it.
689
718
  concurrency: opts.concurrency,
719
+ // Same contract: validated by the flag's own parser, and undefined
720
+ // means "not typed" so the config's `resolution` can supply it.
721
+ resolution: opts.resolution,
690
722
  });
691
723
  // Counts, buckets and names only — the duration crosses the wire as a
692
724
  // bucket, and nothing here can carry a path (assertSafeProps enforces
@@ -758,6 +790,12 @@ export function buildProgram(): Command {
758
790
  "--whisper-language <code>",
759
791
  "transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
760
792
  )
793
+ .option(
794
+ "--whisper-translate",
795
+ "translate the speech to ENGLISH captions instead of transcribing it verbatim " +
796
+ "(whisper's -tr; pair with --whisper-language for the SOURCE language)",
797
+ false,
798
+ )
761
799
  .action(async (input: string, opts) => {
762
800
  const cleanup = CleanupLevelSchema.parse(opts.cleanup);
763
801
  const result = await produce(input, {
@@ -769,6 +807,7 @@ export function buildProgram(): Command {
769
807
  noiseDb: opts.noiseDb,
770
808
  whisperModel: opts.whisperModel,
771
809
  // Same guard as produce's: empty must error, not become a bare `-l`.
810
+ whisperTranslate: opts.whisperTranslate === true,
772
811
  whisperLanguage:
773
812
  opts.whisperLanguage !== undefined
774
813
  ? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
@@ -1035,6 +1074,18 @@ export function buildProgram(): Command {
1035
1074
  .option("--dry-run", "print the targets and the exact payload; send nothing", false)
1036
1075
  .option("-y, --yes", "skip the confirmation prompt", false)
1037
1076
  .option("--force", "publish again even though this workdir already has a publish receipt", false)
1077
+ .option(
1078
+ "--youtube-privacy <level>",
1079
+ "YouTube visibility: private (default), unlisted or public",
1080
+ youtubePrivacyFlag,
1081
+ )
1082
+ .option(
1083
+ "--delivery <mode>",
1084
+ "what to upload — auto: a ≤1080p ~10 Mbps delivery encode, built once and " +
1085
+ "cached in the workdir; master: the untouched render",
1086
+ deliveryFlag,
1087
+ "auto",
1088
+ )
1038
1089
  .action(async (workdir: string | undefined, opts) => {
1039
1090
  const { runPublish } = await import("./publish");
1040
1091
  const target = await resolveWorkdirArgument(workdir ?? ".", "publish");
@@ -1046,6 +1097,10 @@ export function buildProgram(): Command {
1046
1097
  dryRun: opts.dryRun,
1047
1098
  yes: opts.yes,
1048
1099
  force: opts.force,
1100
+ // Validated by youtubePrivacyFlag at parse time; undefined means the
1101
+ // payload's own safe default (private).
1102
+ youtubePrivacy: opts.youtubePrivacy,
1103
+ delivery: opts.delivery,
1049
1104
  });
1050
1105
  telemetry.record("publish_run", {
1051
1106
  scheduled: opts.at !== undefined,