ossclip 0.1.24 → 0.1.26

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.
@@ -0,0 +1,167 @@
1
+ import { THUMBNAIL_MODEL_DEFAULT, thumbnailDecision } from "@ossclip/core";
2
+ import { resolvePortrait, type PortraitSource } from "./portrait-override";
3
+
4
+ /**
5
+ * The editor thumbnail panel's availability decision (2026-08-17), pure so
6
+ * the whole pins × config × env matrix is a table test with no server, no
7
+ * filesystem and no real ~/.ossclip. The I/O half lives in edit.ts's
8
+ * /api/thumbnail handler, which gathers command.json, loadConfig() and the
9
+ * env and hands them here.
10
+ *
11
+ * Resolution reads command.json's recorded args FIRST: produce pins the
12
+ * RESOLVED `--youtube`/`--no-youtube` and `--portrait` into every record
13
+ * (replay-argv.ts's determinism contract), so the pins are the replay truth
14
+ * — what the editor's Render would actually do — and the config is only the
15
+ * fallback for a workdir with no record (or a pre-pin legacy one).
16
+ */
17
+
18
+ /**
19
+ * The last value following any of `names` in a recorded argv — last wins,
20
+ * commander's own rule, so a typed flag plus a pinned one resolves the way
21
+ * the replay would. `undefined` when no occurrence carries a value.
22
+ */
23
+ export function lastFlagValue(
24
+ args: readonly string[],
25
+ names: readonly string[],
26
+ ): string | undefined {
27
+ for (let i = args.length - 2; i >= 0; i--) {
28
+ if (names.includes(args[i]!)) return args[i + 1];
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ /**
34
+ * The last of an on/off flag pair in a recorded argv, or `undefined` when
35
+ * neither appears (the config decides). Last wins, as above.
36
+ */
37
+ export function lastBoolFlag(
38
+ args: readonly string[],
39
+ on: string,
40
+ off: string,
41
+ ): boolean | undefined {
42
+ for (let i = args.length - 1; i >= 0; i--) {
43
+ if (args[i] === on) return true;
44
+ if (args[i] === off) return false;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ /** The panel's reason vocabulary — why the controls are absent (unavailable),
50
+ * why nothing shows yet (never-generated), or why the user opted out
51
+ * (skip-file). */
52
+ export type ThumbnailPanelReason =
53
+ | "no-youtube"
54
+ | "no-portrait"
55
+ | "no-key"
56
+ | "portrait-missing"
57
+ | "skip-file"
58
+ | "never-generated";
59
+
60
+ export interface ThumbnailPanelState {
61
+ /** ready = controls shown; skipped = user declined at approval (controls
62
+ * still shown — regenerating replaces the decision); unavailable = the
63
+ * preconditions for generating are missing, reason says which. */
64
+ status: "ready" | "skipped" | "unavailable";
65
+ reason?: ThumbnailPanelReason;
66
+ /** The resolved image model slug, reported so the panel can name it. */
67
+ model: string;
68
+ /** The resolved portrait path, when one resolved — the regenerate
69
+ * endpoint's likeness reference. */
70
+ portraitPath?: string;
71
+ /** Which portrait won (override > flag > config) — the panel's swap strip
72
+ * labels itself from this. Present exactly when portraitPath is. */
73
+ portraitSource?: PortraitSource;
74
+ }
75
+
76
+ export interface ThumbnailPanelInputs {
77
+ /** command.json's recorded args, or null when the workdir has none. */
78
+ commandArgs: readonly string[] | null;
79
+ /** loadConfig()'s relevant keys — hand-edited JSON, validated HERE (the
80
+ * `portrait` posture: `typeof`/`=== true`, never truthiness). */
81
+ cfg: { youtube?: unknown; portrait?: unknown; thumbnailModel?: unknown };
82
+ /** GEMINI_API_KEY present and non-empty — env-only, secrets never in
83
+ * config.json (env.ts rule). */
84
+ hasKey: boolean;
85
+ /** The approved file holds `{skip: true}` — the user declined this one. */
86
+ approvedSkip: boolean;
87
+ /** Whether ANY concept exists (approved or cached) — with hasImage below,
88
+ * distinguishes "ready" from "ready but nothing ever generated". */
89
+ hasConcept: boolean;
90
+ /** Whether a generated thumbnail image exists (dest or workdir cache). */
91
+ hasImage: boolean;
92
+ /** Injected existence check so the matrix needs no filesystem. */
93
+ portraitExists: (path: string) => boolean;
94
+ /** The workdir's portrait-override file when one exists (editor face swap,
95
+ * 2026-08-17) — found by the CALLER via portraitOverridePath, so this
96
+ * matrix stays filesystem-free. When set it outranks the pin AND the
97
+ * config, and it alone satisfies the no-portrait gate: a project whose
98
+ * only portrait is a swapped face must still be able to regenerate. */
99
+ overridePortraitPath?: string;
100
+ }
101
+
102
+ export function thumbnailPanelState(inputs: ThumbnailPanelInputs): ThumbnailPanelState {
103
+ const args = inputs.commandArgs ?? [];
104
+ // resolveYoutube's exact rule (produce.ts), restated rather than imported —
105
+ // edit.ts must not pull produce.ts's import graph (see paths.ts's
106
+ // artifactPath note): pin beats config, and the config side is `=== true`,
107
+ // never truthiness, because a typo'd `"youtube": "no"` must read as off.
108
+ //
109
+ // Existing artifacts beat flag archaeology (field case 2026-08-17): a
110
+ // produce run that CRASHED between the thumbnail step and the command.json
111
+ // write leaves a workdir with a concept + image but no pins — the very
112
+ // first wizard run did exactly that (tilde-path rename crash), and the
113
+ // panel answered "no-youtube" about a thumbnail sitting right there. A
114
+ // concept or image on disk is direct evidence the pack was on.
115
+ const youtube =
116
+ lastBoolFlag(args, "--youtube", "--no-youtube") ??
117
+ (inputs.cfg.youtube === true || inputs.hasConcept || inputs.hasImage);
118
+ // resolvePortrait is the ONE precedence rule (override > flag > config —
119
+ // portrait-override.ts has the why), shared with produce.ts so the panel
120
+ // can never disagree with what a replay would actually prompt with. It
121
+ // also carries produce.ts's expandHome treatment of the flag/config paths
122
+ // — a `~/Pictures/me.jpg` must resolve identically here.
123
+ const resolvedPortrait = resolvePortrait({
124
+ overridePath: inputs.overridePortraitPath ?? null,
125
+ flagPortrait: lastFlagValue(args, ["--portrait"]),
126
+ cfgPortrait: inputs.cfg.portrait,
127
+ });
128
+ const portraitPath = resolvedPortrait?.path;
129
+ const model =
130
+ typeof inputs.cfg.thumbnailModel === "string"
131
+ ? inputs.cfg.thumbnailModel
132
+ : THUMBNAIL_MODEL_DEFAULT;
133
+ const decision = thumbnailDecision(
134
+ youtube,
135
+ portraitPath,
136
+ inputs.hasKey,
137
+ portraitPath !== undefined && inputs.portraitExists(portraitPath),
138
+ );
139
+ const base = {
140
+ model,
141
+ ...(resolvedPortrait !== undefined
142
+ ? { portraitPath: resolvedPortrait.path, portraitSource: resolvedPortrait.source }
143
+ : {}),
144
+ };
145
+ if (decision !== "generate") {
146
+ const reason: ThumbnailPanelReason =
147
+ decision === "skip-no-youtube"
148
+ ? "no-youtube"
149
+ : decision === "skip-no-portrait"
150
+ ? "no-portrait"
151
+ : decision === "skip-no-key"
152
+ ? "no-key"
153
+ : "portrait-missing";
154
+ return { status: "unavailable", reason, ...base };
155
+ }
156
+ // The skip file ranks BELOW unavailability: a decision about a thumbnail
157
+ // that cannot generate anyway would show controls that cannot work.
158
+ if (inputs.approvedSkip) return { status: "skipped", reason: "skip-file", ...base };
159
+ return {
160
+ status: "ready",
161
+ // Ready with nothing on disk: the produce run never reached (or never
162
+ // finished) the thumbnail step. The panel shows empty fields and a
163
+ // placeholder instead of pretending an image exists.
164
+ ...(inputs.hasConcept || inputs.hasImage ? {} : { reason: "never-generated" as const }),
165
+ ...base,
166
+ };
167
+ }
@@ -424,6 +424,10 @@ export class RenderTimelineHUD {
424
424
  export function printProductionCompleteBanner(summary: {
425
425
  outPath: string;
426
426
  coverPath?: string;
427
+ /** The `<out>.youtube.md` SEO pack, when the run wrote one (Y2). */
428
+ youtubePath?: string;
429
+ /** The `<out>.thumbnail.png` AI thumbnail, when the run wrote one (Y3). */
430
+ thumbnailPath?: string;
427
431
  sourceDurationSec: number;
428
432
  outputDurationSec: number;
429
433
  sceneCount: number;
@@ -451,6 +455,12 @@ export function printProductionCompleteBanner(summary: {
451
455
  ...(summary.coverPath
452
456
  ? [`${ansi.bold}║${ansi.reset} ${ansi.brightMagenta}▸ Cover:${ansi.reset} ${ansi.white}${summary.coverPath}${ansi.reset}`]
453
457
  : []),
458
+ ...(summary.youtubePath
459
+ ? [`${ansi.bold}║${ansi.reset} ${ansi.brightRed}▸ YouTube:${ansi.reset} ${ansi.white}${summary.youtubePath}${ansi.reset}`]
460
+ : []),
461
+ ...(summary.thumbnailPath
462
+ ? [`${ansi.bold}║${ansi.reset} ${ansi.brightYellow}▸ Thumb:${ansi.reset} ${ansi.white}${summary.thumbnailPath}${ansi.reset}`]
463
+ : []),
454
464
  `${ansi.bold}║${ansi.reset} ${ansi.green}▸ Engine:${ansi.reset} ${ansi.bold}${ansi.brightCyan}${providerLabel}${ansi.reset} + ${ansi.brightWhite}Remotion${ansi.reset} + ${ansi.brightWhite}Whisper.cpp${ansi.reset}`,
455
465
  `${ansi.bold}║${ansi.reset} ${ansi.yellow}▸ Cut:${ansi.reset} ${ansi.bold}${summary.outputDurationSec.toFixed(1)}s${ansi.reset} (trimmed from ${summary.sourceDurationSec.toFixed(1)}s · ${ansi.green}-${cutPct}% dead air/flubs${ansi.reset})`,
456
466
  `${ansi.bold}║${ansi.reset} ${ansi.blue}▸ Scenes:${ansi.reset} ${ansi.bold}${summary.sceneCount}${ansi.reset} dynamic AI graphic overlay${summary.sceneCount === 1 ? "" : "s"} placed`,
@@ -1 +0,0 @@
1
- .ossclip-scroll-list{scrollbar-width:thin;scrollbar-color:#3a3a44 transparent}.ossclip-scroll-list::-webkit-scrollbar{width:8px}.ossclip-scroll-list::-webkit-scrollbar-track{background:transparent}.ossclip-scroll-list::-webkit-scrollbar-thumb{background:#3a3a44;border-radius:4px}.ossclip-picker-row{color:#c9c9d4;background:transparent;border:1px solid transparent}.ossclip-picker-row.is-workdir{color:#ededf2;background:#1a1a21;border-color:#2a2a33}.ossclip-picker-row:disabled{cursor:default;opacity:.5}.ossclip-picker-row:hover:not(:disabled){background:#1f1f28;border-color:#3a3a48}.ossclip-picker-row:focus{outline:none;background:#23232e;border-color:#8ab4f8}.ossclip-picker-row:active:not(:disabled){background:#2a2a36}