@scalemule/news-publication 0.0.74 → 0.0.76

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/README.md CHANGED
@@ -337,3 +337,33 @@ explicit commands; acceptance does not certify scan, image or audio completion.
337
337
  The platform contract and a complete example live in
338
338
  `docs/news-platform/ARTICLE-WORKFLOW-V1.md` in the ScaleMule meta-repo.
339
339
  Do not use direct SQL, S3 paths, base64 bodies or invented media readiness.
340
+
341
+ ### Replace an existing, prepared cover
342
+
343
+ For a legacy published article and an already public, scanned Photo asset,
344
+ use `client.replacePublishedCover(...)` or the included executable:
345
+
346
+ ```sh
347
+ scalemule-article inspect --publication "$PUBLICATION_ID" --article "$ARTICLE_ID"
348
+ scalemule-article replace-cover --publication "$PUBLICATION_ID" --article "$ARTICLE_ID" \
349
+ --file-id "$PHOTO_FILE_ID" --expected-version "$VERSION" --metadata photo.json
350
+ ```
351
+
352
+ Use normal `SCALEMULE_API_KEY` and `SCALEMULE_ACCESS_TOKEN` authorization.
353
+ `inspect` reports the public-projection version and whether collaboration is
354
+ enabled, without logging draft text or review capabilities. The metadata file
355
+ records caption, credit, factual alt text, `rightsConfirmed`, `aiGenerated` and
356
+ the internal provenance `label` (null for an ordinary photograph).
357
+
358
+ The replacement reads measured hero/social manifests and makes one version-fenced
359
+ media write. A missing social rendition gets one normal Photo transform request
360
+ and a fresh measured metadata lookup; pending work stops without polling forever.
361
+ It never invents dimensions or delivery URLs, uploads another copy,
362
+ restarts narration, republishes text or silently enrolls a story. Missing or
363
+ incomplete Photo renditions leave the existing cover unchanged; inspect and
364
+ resume with the same asset. Collaboration-managed stories retain their exact
365
+ revision approval/release requirements and the server rejects this legacy path.
366
+ This helper does not source or ingest a new photograph. Use managed asset intake
367
+ for that phase, preserving source rights and private capture information.
368
+ Reported elapsed time covers the API operation; verify the real public page and
369
+ homepage allocation separately before claiming the replacement is visible.
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from 'node:fs/promises';
3
+ import { parseArgs } from 'node:util';
4
+ import { PublicationArticleClient } from '../dist/article-workflow-client.js';
5
+
6
+ const help = `Usage:
7
+ scalemule-article inspect --publication ID --article ID
8
+ scalemule-article replace-cover --publication ID --article ID --file-id ID --expected-version N --metadata photo.json
9
+
10
+ Credentials: SCALEMULE_API_KEY and SCALEMULE_ACCESS_TOKEN (normal authorized member).
11
+ Platform admins may use their normal access token with --application-id ID.
12
+ Optional --api-url URL defaults to https://api.scalemule.com.
13
+
14
+ replace-cover selects an already public, scanned Photo asset. It reads measured
15
+ hero and social renditions, preserves the article text, and changes media with
16
+ the supplied version fence. It never uploads raw bytes, republishes the story,
17
+ starts narration, or silently enrolls a legacy story into collaboration.
18
+ Collaboration-managed articles must use their immutable review/release workflow.
19
+
20
+ photo.json: { "caption": "...", "credit": "...", "alt": "...",
21
+ "rightsConfirmed": true, "aiGenerated": false, "label": null }
22
+ Optional metadata: focalPoint, socialRenditionKey (a Photo metadata lookup key).
23
+ The label is internal provenance, subject to each publication's caption policy.
24
+ Pending scan/renditions stop without changing the existing cover. Inspect the
25
+ same asset instead of uploading again. Output records API timing, not a claim
26
+ that every website/CDN cache already displays the new image.
27
+ `;
28
+
29
+ try {
30
+ const { values, positionals } = parseArgs({
31
+ allowPositionals: true, strict: true,
32
+ options: Object.fromEntries(['publication', 'article', 'file-id', 'expected-version', 'metadata', 'application-id', 'api-url'].map(k => [k, { type: 'string' }]).concat([['help', { type: 'boolean' }]])),
33
+ });
34
+ if (values.help || !positionals.length) {
35
+ console.log(help);
36
+ } else {
37
+ const [command, ...extra] = positionals;
38
+ if (extra.length || !['inspect', 'replace-cover'].includes(command)) throw Error('Use inspect or replace-cover; see --help');
39
+ for (const key of ['publication', 'article']) if (!values[key]) throw Error(`Missing --${key}`);
40
+ if (!process.env.SCALEMULE_ACCESS_TOKEN) throw Error('SCALEMULE_ACCESS_TOKEN is required; use normal member or platform admin authorization');
41
+ if (!values['application-id'] && !process.env.SCALEMULE_API_KEY) throw Error('Member calls also require SCALEMULE_API_KEY');
42
+ const client = new PublicationArticleClient({
43
+ apiUrl: values['api-url'] ?? 'https://api.scalemule.com',
44
+ publicationId: values.publication,
45
+ applicationId: values['application-id'],
46
+ apiKey: process.env.SCALEMULE_API_KEY,
47
+ accessToken: process.env.SCALEMULE_ACCESS_TOKEN,
48
+ });
49
+ const started = performance.now();
50
+ if (command === 'inspect') {
51
+ const [options, processing, collaboration] = await Promise.all([
52
+ client.options(values.article), client.processing(values.article),
53
+ client.get(values.article).then(current => ({ enabled: true, generation: current.generation, status: current.status })).catch(error => {
54
+ if (error.status === 404 && error.message === 'Story collaboration is not enabled') return { enabled: false };
55
+ throw error;
56
+ }),
57
+ ]);
58
+ console.log(JSON.stringify({
59
+ article_id: processing.article_id, version: processing.version,
60
+ publication_status: processing.publication_status,
61
+ workflow_version: options.workflow_version, profile_version: options.profile_version,
62
+ capabilities: options.capabilities, narration: processing.narration,
63
+ assets: processing.assets, collaboration, elapsed_ms: Math.round(performance.now() - started),
64
+ }, null, 2));
65
+ } else {
66
+ for (const key of ['file-id', 'expected-version', 'metadata']) if (!values[key]) throw Error(`Missing --${key}`);
67
+ const metadata = JSON.parse(await readFile(values.metadata, 'utf8'));
68
+ for (const key of ['caption', 'credit', 'alt']) if (typeof metadata[key] !== 'string') throw Error(`photo.json requires a string ${key}`);
69
+ if (typeof metadata.aiGenerated !== 'boolean' || !(metadata.label === null || typeof metadata.label === 'string')) throw Error('photo.json must explicitly record aiGenerated and label');
70
+ const result = await client.replacePublishedCover({
71
+ articleId: values.article, expectedVersion: Number(values['expected-version']),
72
+ fileId: values['file-id'], caption: metadata.caption, credit: metadata.credit,
73
+ alt: metadata.alt, rightsConfirmed: metadata.rightsConfirmed,
74
+ aiGenerated: metadata.aiGenerated, label: metadata.label,
75
+ focalPoint: metadata.focalPoint, socialRenditionKey: metadata.socialRenditionKey,
76
+ });
77
+ console.log(JSON.stringify({ article_id: result.post_id, version: result.version, state: 'cover_updated', elapsed_ms: Math.round(performance.now() - started) }, null, 2));
78
+ }
79
+ }
80
+ } catch (error) {
81
+ console.error(JSON.stringify({ error: error.message, status: error.status }));
82
+ process.exitCode = 1;
83
+ }
@@ -77,6 +77,87 @@ var PublicationArticleClient = class {
77
77
  `/articles/${encodeURIComponent(articleId)}/processing`
78
78
  );
79
79
  }
80
+ /** Replace a legacy publication cover using ready, measured public Photo renditions.
81
+ * This never enrolls a story, publishes text, creates narration or retries a conflict.
82
+ * Collaboration-managed stories retain their server-enforced immutable revision workflow.
83
+ */
84
+ async replacePublishedCover(input) {
85
+ if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1)
86
+ throw new Error("Use the current public-projection version from processing()");
87
+ if (input.rightsConfirmed !== true || !input.alt.trim())
88
+ throw new Error("Confirm publication rights and supply factual image alt text");
89
+ if (input.aiGenerated && !input.label?.trim())
90
+ throw new Error("Generated art requires its internal provenance label");
91
+ const focal = input.focalPoint ?? { x: 0.5, y: 0.5 };
92
+ if (![focal.x, focal.y].every((v) => Number.isFinite(v) && v >= 0 && v <= 1))
93
+ throw new Error("Focal point must be within the image");
94
+ const photo = `/v1/photos/public/${encodeURIComponent(input.fileId)}/manifest`;
95
+ const socialKey = input.socialRenditionKey ?? "w1200_h630_cover_q85_jpeg";
96
+ const socialPath = `${photo}?rendition=${encodeURIComponent(socialKey)}`;
97
+ const [current, hero, initialSocial] = await Promise.all([
98
+ this.processing(input.articleId),
99
+ this.request(`${photo}?preset=hero`),
100
+ this.request(socialPath)
101
+ ]);
102
+ if (current.publication_status !== "published" || current.version !== input.expectedVersion)
103
+ throw new ArticleWorkflowError("Article is not published or its version changed; reload before updating its cover", 409, input.articleId);
104
+ let social = initialSocial;
105
+ if (hero.ready && [hero.file_id, hero.photo_id].includes(input.fileId) && !social.ready && social.file_id === hero.file_id) {
106
+ const spec = /^w(\d+)_h(\d+)_(cover|contain|fill|scaledown)_q(\d+)_(jpeg|webp|avif)$/.exec(socialKey);
107
+ if (spec && Number(spec[1]) > 0 && Number(spec[1]) <= 12e3 && Number(spec[2]) > 0 && Number(spec[2]) <= 12e3 && Number(spec[4]) > 0 && Number(spec[4]) <= 100) {
108
+ const query = new URLSearchParams({ width: spec[1], height: spec[2], fit: spec[3], quality: spec[4], format: spec[5] });
109
+ const url = new URL(this.config.apiUrl.replace(/\/$/, "") + `/v1/photos/public/${encodeURIComponent(input.fileId)}/transform?${query}`);
110
+ const response = await (this.config.fetch ?? fetch)(url, { method: "GET", redirect: "manual", cache: "no-store", signal: AbortSignal.timeout(3e4) });
111
+ const accepted = response.ok && response.headers.get("content-type")?.startsWith("image/") || [302, 303, 307, 308].includes(response.status);
112
+ await response.body?.cancel();
113
+ if (accepted) social = await this.request(socialPath);
114
+ }
115
+ }
116
+ const dimension = (v) => Number.isInteger(v) && Number(v) > 0 && Number(v) <= 12e3;
117
+ const publicUrl = (raw) => {
118
+ try {
119
+ const url = new URL(raw);
120
+ return url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash;
121
+ } catch {
122
+ return false;
123
+ }
124
+ };
125
+ const variants = Object.values(hero.variant_metadata ?? {});
126
+ if (!hero.ready || !social.ready || !hero.file_id || ![hero.file_id, hero.photo_id].includes(input.fileId) || social.file_id !== hero.file_id || !variants.length || variants.length > 20 || variants.some((v) => !dimension(v.width) || !dimension(v.height) || !publicUrl(v.url) || !["jpeg", "webp", "avif"].includes(v.format)) || !dimension(social.width) || !dimension(social.height) || !social.url || !publicUrl(social.url))
127
+ throw new ArticleWorkflowError("Public Photo renditions are not ready with measured dimensions; inspect Photo processing, keep the existing cover and resume with the same asset", 409, input.articleId);
128
+ const ordered = [...variants].sort((a, b) => a.width - b.width);
129
+ const master = [...ordered].reverse().find((v) => v.width <= 1600) ?? ordered[0];
130
+ return this.request(
131
+ `/articles/${encodeURIComponent(input.articleId)}/media`,
132
+ "PUT",
133
+ {
134
+ expected_version: input.expectedVersion,
135
+ cover_image_url: master.url,
136
+ og_image_url: social.url,
137
+ cover_image: {
138
+ file_id: hero.file_id,
139
+ master_url: master.url,
140
+ width: master.width,
141
+ height: master.height,
142
+ aspect_ratio: `${master.width}:${master.height}`,
143
+ alt: input.alt,
144
+ caption: input.caption,
145
+ attribution: input.credit,
146
+ ai_generated: input.aiGenerated,
147
+ label: input.label,
148
+ focal_point: focal,
149
+ variants
150
+ },
151
+ open_graph_image: {
152
+ file_id: hero.file_id,
153
+ url: social.url,
154
+ width: social.width,
155
+ height: social.height,
156
+ aspect_ratio: `${social.width}:${social.height}`
157
+ }
158
+ }
159
+ );
160
+ }
80
161
  /** Existing private narration path for a saved revision and a before-publication audio policy. */
81
162
  narrateRevision(articleId, expectedGeneration) {
82
163
  return this.request(
@@ -85,6 +166,14 @@ var PublicationArticleClient = class {
85
166
  { expected_generation: expectedGeneration }
86
167
  );
87
168
  }
169
+ /** Prioritize existing active Blog processing; never resets paid work or raises TTS limits. */
170
+ expediteNarration(articleId, expectedVersion) {
171
+ return this.request(
172
+ `/articles/${encodeURIComponent(articleId)}/processing`,
173
+ "POST",
174
+ { expected_version: expectedVersion, expedite: true }
175
+ );
176
+ }
88
177
  retryProcessing(articleId, expectedVersion, assetSourceFileId, narrationProfileVersion) {
89
178
  return this.request(
90
179
  `/articles/${encodeURIComponent(articleId)}/processing`,
@@ -33,6 +33,24 @@ type AssetMetadata = {
33
33
  rights_confirmed: boolean;
34
34
  private_preview_only?: boolean;
35
35
  };
36
+ type PublishedCoverReplacement = {
37
+ articleId: string;
38
+ expectedVersion: number;
39
+ /** A real, already public Photo/Storage ID, never an invented ID or URL. */
40
+ fileId: string;
41
+ rightsConfirmed: boolean;
42
+ caption: string;
43
+ credit: string;
44
+ alt: string;
45
+ aiGenerated: boolean;
46
+ label: string | null;
47
+ focalPoint?: {
48
+ x: number;
49
+ y: number;
50
+ };
51
+ /** Metadata lookup key only; no rendition URL or dimensions are inferred. */
52
+ socialRenditionKey?: string;
53
+ };
36
54
  type ArticleProcessing = {
37
55
  article_id: string;
38
56
  version: number;
@@ -43,6 +61,7 @@ type ArticleProcessing = {
43
61
  audio_id?: string | null;
44
62
  job_id?: string | null;
45
63
  profile_override_version?: number | null;
64
+ work_priority?: number | null;
46
65
  error_code?: string | null;
47
66
  last_progress_at?: string | null;
48
67
  };
@@ -84,6 +103,7 @@ type ArticleWorkflowOptions = {
84
103
  managed_private_asset_upload: boolean;
85
104
  inline_media_body?: boolean;
86
105
  automatic_public_narration?: boolean;
106
+ prioritized_narration_processing?: boolean;
87
107
  };
88
108
  };
89
109
  type ArticleOperation = {
@@ -143,12 +163,24 @@ declare class PublicationArticleClient {
143
163
  }>;
144
164
  get(articleId: string): Promise<Record>;
145
165
  processing(articleId: string): Promise<ArticleProcessing>;
166
+ /** Replace a legacy publication cover using ready, measured public Photo renditions.
167
+ * This never enrolls a story, publishes text, creates narration or retries a conflict.
168
+ * Collaboration-managed stories retain their server-enforced immutable revision workflow.
169
+ */
170
+ replacePublishedCover(input: PublishedCoverReplacement): Promise<{
171
+ post_id: string;
172
+ version: number;
173
+ cover_image_url: string;
174
+ og_image_url: string;
175
+ }>;
146
176
  /** Existing private narration path for a saved revision and a before-publication audio policy. */
147
177
  narrateRevision(articleId: string, expectedGeneration: number): Promise<{
148
178
  status: string;
149
179
  audio_id?: string | null;
150
180
  job_id?: string | null;
151
181
  }>;
182
+ /** Prioritize existing active Blog processing; never resets paid work or raises TTS limits. */
183
+ expediteNarration(articleId: string, expectedVersion: number): Promise<unknown>;
152
184
  retryProcessing(articleId: string, expectedVersion: number, assetSourceFileId?: string, narrationProfileVersion?: number): Promise<unknown>;
153
185
  create(key: string, authorProfileId: string, document: ArticleDocument, reviewPolicy?: string): Promise<ArticleOperation>;
154
186
  save(key: string, articleId: string, generation: number, document: ArticleDocument, reason?: string): Promise<ArticleOperation>;
@@ -181,4 +213,4 @@ declare class PublicationArticleClient {
181
213
  }): Promise<ArticleOperation>;
182
214
  }
183
215
 
184
- export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, PublicationArticleClient };
216
+ export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, PublicationArticleClient, type PublishedCoverReplacement };
@@ -33,6 +33,24 @@ type AssetMetadata = {
33
33
  rights_confirmed: boolean;
34
34
  private_preview_only?: boolean;
35
35
  };
36
+ type PublishedCoverReplacement = {
37
+ articleId: string;
38
+ expectedVersion: number;
39
+ /** A real, already public Photo/Storage ID, never an invented ID or URL. */
40
+ fileId: string;
41
+ rightsConfirmed: boolean;
42
+ caption: string;
43
+ credit: string;
44
+ alt: string;
45
+ aiGenerated: boolean;
46
+ label: string | null;
47
+ focalPoint?: {
48
+ x: number;
49
+ y: number;
50
+ };
51
+ /** Metadata lookup key only; no rendition URL or dimensions are inferred. */
52
+ socialRenditionKey?: string;
53
+ };
36
54
  type ArticleProcessing = {
37
55
  article_id: string;
38
56
  version: number;
@@ -43,6 +61,7 @@ type ArticleProcessing = {
43
61
  audio_id?: string | null;
44
62
  job_id?: string | null;
45
63
  profile_override_version?: number | null;
64
+ work_priority?: number | null;
46
65
  error_code?: string | null;
47
66
  last_progress_at?: string | null;
48
67
  };
@@ -84,6 +103,7 @@ type ArticleWorkflowOptions = {
84
103
  managed_private_asset_upload: boolean;
85
104
  inline_media_body?: boolean;
86
105
  automatic_public_narration?: boolean;
106
+ prioritized_narration_processing?: boolean;
87
107
  };
88
108
  };
89
109
  type ArticleOperation = {
@@ -143,12 +163,24 @@ declare class PublicationArticleClient {
143
163
  }>;
144
164
  get(articleId: string): Promise<Record>;
145
165
  processing(articleId: string): Promise<ArticleProcessing>;
166
+ /** Replace a legacy publication cover using ready, measured public Photo renditions.
167
+ * This never enrolls a story, publishes text, creates narration or retries a conflict.
168
+ * Collaboration-managed stories retain their server-enforced immutable revision workflow.
169
+ */
170
+ replacePublishedCover(input: PublishedCoverReplacement): Promise<{
171
+ post_id: string;
172
+ version: number;
173
+ cover_image_url: string;
174
+ og_image_url: string;
175
+ }>;
146
176
  /** Existing private narration path for a saved revision and a before-publication audio policy. */
147
177
  narrateRevision(articleId: string, expectedGeneration: number): Promise<{
148
178
  status: string;
149
179
  audio_id?: string | null;
150
180
  job_id?: string | null;
151
181
  }>;
182
+ /** Prioritize existing active Blog processing; never resets paid work or raises TTS limits. */
183
+ expediteNarration(articleId: string, expectedVersion: number): Promise<unknown>;
152
184
  retryProcessing(articleId: string, expectedVersion: number, assetSourceFileId?: string, narrationProfileVersion?: number): Promise<unknown>;
153
185
  create(key: string, authorProfileId: string, document: ArticleDocument, reviewPolicy?: string): Promise<ArticleOperation>;
154
186
  save(key: string, articleId: string, generation: number, document: ArticleDocument, reason?: string): Promise<ArticleOperation>;
@@ -181,4 +213,4 @@ declare class PublicationArticleClient {
181
213
  }): Promise<ArticleOperation>;
182
214
  }
183
215
 
184
- export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, PublicationArticleClient };
216
+ export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, PublicationArticleClient, type PublishedCoverReplacement };
@@ -75,6 +75,87 @@ var PublicationArticleClient = class {
75
75
  `/articles/${encodeURIComponent(articleId)}/processing`
76
76
  );
77
77
  }
78
+ /** Replace a legacy publication cover using ready, measured public Photo renditions.
79
+ * This never enrolls a story, publishes text, creates narration or retries a conflict.
80
+ * Collaboration-managed stories retain their server-enforced immutable revision workflow.
81
+ */
82
+ async replacePublishedCover(input) {
83
+ if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1)
84
+ throw new Error("Use the current public-projection version from processing()");
85
+ if (input.rightsConfirmed !== true || !input.alt.trim())
86
+ throw new Error("Confirm publication rights and supply factual image alt text");
87
+ if (input.aiGenerated && !input.label?.trim())
88
+ throw new Error("Generated art requires its internal provenance label");
89
+ const focal = input.focalPoint ?? { x: 0.5, y: 0.5 };
90
+ if (![focal.x, focal.y].every((v) => Number.isFinite(v) && v >= 0 && v <= 1))
91
+ throw new Error("Focal point must be within the image");
92
+ const photo = `/v1/photos/public/${encodeURIComponent(input.fileId)}/manifest`;
93
+ const socialKey = input.socialRenditionKey ?? "w1200_h630_cover_q85_jpeg";
94
+ const socialPath = `${photo}?rendition=${encodeURIComponent(socialKey)}`;
95
+ const [current, hero, initialSocial] = await Promise.all([
96
+ this.processing(input.articleId),
97
+ this.request(`${photo}?preset=hero`),
98
+ this.request(socialPath)
99
+ ]);
100
+ if (current.publication_status !== "published" || current.version !== input.expectedVersion)
101
+ throw new ArticleWorkflowError("Article is not published or its version changed; reload before updating its cover", 409, input.articleId);
102
+ let social = initialSocial;
103
+ if (hero.ready && [hero.file_id, hero.photo_id].includes(input.fileId) && !social.ready && social.file_id === hero.file_id) {
104
+ const spec = /^w(\d+)_h(\d+)_(cover|contain|fill|scaledown)_q(\d+)_(jpeg|webp|avif)$/.exec(socialKey);
105
+ if (spec && Number(spec[1]) > 0 && Number(spec[1]) <= 12e3 && Number(spec[2]) > 0 && Number(spec[2]) <= 12e3 && Number(spec[4]) > 0 && Number(spec[4]) <= 100) {
106
+ const query = new URLSearchParams({ width: spec[1], height: spec[2], fit: spec[3], quality: spec[4], format: spec[5] });
107
+ const url = new URL(this.config.apiUrl.replace(/\/$/, "") + `/v1/photos/public/${encodeURIComponent(input.fileId)}/transform?${query}`);
108
+ const response = await (this.config.fetch ?? fetch)(url, { method: "GET", redirect: "manual", cache: "no-store", signal: AbortSignal.timeout(3e4) });
109
+ const accepted = response.ok && response.headers.get("content-type")?.startsWith("image/") || [302, 303, 307, 308].includes(response.status);
110
+ await response.body?.cancel();
111
+ if (accepted) social = await this.request(socialPath);
112
+ }
113
+ }
114
+ const dimension = (v) => Number.isInteger(v) && Number(v) > 0 && Number(v) <= 12e3;
115
+ const publicUrl = (raw) => {
116
+ try {
117
+ const url = new URL(raw);
118
+ return url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash;
119
+ } catch {
120
+ return false;
121
+ }
122
+ };
123
+ const variants = Object.values(hero.variant_metadata ?? {});
124
+ if (!hero.ready || !social.ready || !hero.file_id || ![hero.file_id, hero.photo_id].includes(input.fileId) || social.file_id !== hero.file_id || !variants.length || variants.length > 20 || variants.some((v) => !dimension(v.width) || !dimension(v.height) || !publicUrl(v.url) || !["jpeg", "webp", "avif"].includes(v.format)) || !dimension(social.width) || !dimension(social.height) || !social.url || !publicUrl(social.url))
125
+ throw new ArticleWorkflowError("Public Photo renditions are not ready with measured dimensions; inspect Photo processing, keep the existing cover and resume with the same asset", 409, input.articleId);
126
+ const ordered = [...variants].sort((a, b) => a.width - b.width);
127
+ const master = [...ordered].reverse().find((v) => v.width <= 1600) ?? ordered[0];
128
+ return this.request(
129
+ `/articles/${encodeURIComponent(input.articleId)}/media`,
130
+ "PUT",
131
+ {
132
+ expected_version: input.expectedVersion,
133
+ cover_image_url: master.url,
134
+ og_image_url: social.url,
135
+ cover_image: {
136
+ file_id: hero.file_id,
137
+ master_url: master.url,
138
+ width: master.width,
139
+ height: master.height,
140
+ aspect_ratio: `${master.width}:${master.height}`,
141
+ alt: input.alt,
142
+ caption: input.caption,
143
+ attribution: input.credit,
144
+ ai_generated: input.aiGenerated,
145
+ label: input.label,
146
+ focal_point: focal,
147
+ variants
148
+ },
149
+ open_graph_image: {
150
+ file_id: hero.file_id,
151
+ url: social.url,
152
+ width: social.width,
153
+ height: social.height,
154
+ aspect_ratio: `${social.width}:${social.height}`
155
+ }
156
+ }
157
+ );
158
+ }
78
159
  /** Existing private narration path for a saved revision and a before-publication audio policy. */
79
160
  narrateRevision(articleId, expectedGeneration) {
80
161
  return this.request(
@@ -83,6 +164,14 @@ var PublicationArticleClient = class {
83
164
  { expected_generation: expectedGeneration }
84
165
  );
85
166
  }
167
+ /** Prioritize existing active Blog processing; never resets paid work or raises TTS limits. */
168
+ expediteNarration(articleId, expectedVersion) {
169
+ return this.request(
170
+ `/articles/${encodeURIComponent(articleId)}/processing`,
171
+ "POST",
172
+ { expected_version: expectedVersion, expedite: true }
173
+ );
174
+ }
86
175
  retryProcessing(articleId, expectedVersion, assetSourceFileId, narrationProfileVersion) {
87
176
  return this.request(
88
177
  `/articles/${encodeURIComponent(articleId)}/processing`,
@@ -18,7 +18,7 @@ declare function MeetupDetail({ meetup, joined }: {
18
18
  joined: boolean;
19
19
  }): React.JSX.Element;
20
20
  declare function activityChoices(): {
21
- value: "walk" | "other" | "games" | "coffee" | "hike" | "pickleball" | "tennis" | "soccer" | "bike" | "dogs" | "books" | "kids" | "volunteer";
21
+ value: "other" | "walk" | "games" | "coffee" | "hike" | "pickleball" | "tennis" | "soccer" | "bike" | "dogs" | "books" | "kids" | "volunteer";
22
22
  label: string;
23
23
  }[];
24
24
 
@@ -18,7 +18,7 @@ declare function MeetupDetail({ meetup, joined }: {
18
18
  joined: boolean;
19
19
  }): React.JSX.Element;
20
20
  declare function activityChoices(): {
21
- value: "walk" | "other" | "games" | "coffee" | "hike" | "pickleball" | "tennis" | "soccer" | "bike" | "dogs" | "books" | "kids" | "volunteer";
21
+ value: "other" | "walk" | "games" | "coffee" | "hike" | "pickleball" | "tennis" | "soccer" | "bike" | "dogs" | "books" | "kids" | "volunteer";
22
22
  label: string;
23
23
  }[];
24
24
 
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@scalemule/news-publication",
3
- "version": "0.0.74",
3
+ "version": "0.0.76",
4
4
  "description": "Shared server framework for ScaleMule local news publications. Walnut Creek Times is the broadsheet default; each paper supplies its own identity and feature flags.",
5
5
  "type": "module",
6
+ "bin": { "scalemule-article": "./bin/scalemule-article.mjs" },
6
7
  "main": "./dist/index.cjs",
7
8
  "module": "./dist/index.js",
8
9
  "types": "./dist/index.d.ts",
@@ -197,6 +198,7 @@
197
198
  }
198
199
  },
199
200
  "files": [
201
+ "bin",
200
202
  "dist",
201
203
  "tokens",
202
204
  "marketplace.css",