@tday-com/mcp 0.3.1 → 0.4.2

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/dist/server.js DELETED
@@ -1,43 +0,0 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { loadConfig } from "./config.js";
4
- import { packageVersion } from "./package-info.js";
5
- import { registerPrompts } from "./prompts.js";
6
- import { tools, formatToolResultText, responseFormatFromArgs, toStructuredContent, } from "./tools.js";
7
- export async function runServer() {
8
- const config = await loadConfig();
9
- const server = new McpServer({ name: "tday-mcp-server", version: packageVersion() });
10
- registerTools(server, tools, config);
11
- registerPrompts(server);
12
- const transport = new StdioServerTransport();
13
- await server.connect(transport);
14
- }
15
- export function registerTools(server, list, config) {
16
- const registerTool = server.registerTool.bind(server);
17
- for (const tool of list) {
18
- registerTool(tool.name, {
19
- title: tool.title,
20
- description: tool.description,
21
- inputSchema: tool.inputSchema,
22
- outputSchema: tool.outputSchema,
23
- annotations: {
24
- readOnlyHint: tool.annotations.readOnlyHint,
25
- destructiveHint: tool.annotations.destructiveHint,
26
- idempotentHint: tool.annotations.idempotentHint,
27
- openWorldHint: tool.annotations.openWorldHint,
28
- },
29
- }, async (args) => {
30
- const rawArgs = args ?? {};
31
- const result = await tool.handler(rawArgs, config);
32
- return {
33
- content: [
34
- {
35
- type: "text",
36
- text: formatToolResultText(tool.name, result, responseFormatFromArgs(rawArgs), tool.markdown),
37
- },
38
- ],
39
- structuredContent: toStructuredContent(result),
40
- };
41
- });
42
- }
43
- }
@@ -1,175 +0,0 @@
1
- import { z } from "zod";
2
- export const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
3
- const responseFormatSchema = z
4
- .enum(["json", "markdown"])
5
- .default("json")
6
- .describe("Text response format. Defaults to json for backward compatibility; structuredContent is always returned when the MCP client supports it.");
7
- export const safeIdSchema = z
8
- .string()
9
- .trim()
10
- .min(1)
11
- .regex(SAFE_ID, "must contain only letters, numbers, underscores, and hyphens");
12
- export function argsObject(shape) {
13
- return z
14
- .object({
15
- ...shape,
16
- response_format: responseFormatSchema,
17
- })
18
- .strict();
19
- }
20
- export const emptyArgsSchema = argsObject({});
21
- export const listArgsSchema = argsObject({
22
- limit: z
23
- .number()
24
- .int()
25
- .min(1)
26
- .max(100)
27
- .default(50)
28
- .describe("Maximum items to return. Defaults to 50."),
29
- offset: z
30
- .number()
31
- .int()
32
- .min(0)
33
- .default(0)
34
- .describe("Number of items to skip for pagination. Defaults to 0."),
35
- });
36
- export function safePath(template, params) {
37
- return template.replace(/:(\w+)/g, (_, key) => {
38
- const val = String(params[key] ?? "");
39
- if (!SAFE_ID.test(val)) {
40
- throw new Error(`Invalid ${key}: must contain only letters, numbers, underscores, and hyphens (got "${val}")`);
41
- }
42
- return encodeURIComponent(val);
43
- });
44
- }
45
- export function stripPresentationArgs(args) {
46
- const { response_format: _responseFormat, ...rest } = args;
47
- return rest;
48
- }
49
- export function responseFormatFromArgs(args) {
50
- return args.response_format === "markdown" ? "markdown" : "json";
51
- }
52
- const anyRecord = z.record(z.string(), z.unknown());
53
- const paginationSchema = z
54
- .object({
55
- total_count: z.number().optional(),
56
- total: z.number().optional(),
57
- count: z.number(),
58
- offset: z.number(),
59
- limit: z.number(),
60
- has_more: z.boolean().optional(),
61
- hasMore: z.boolean().optional(),
62
- next_offset: z.number().optional(),
63
- nextOffset: z.number().optional(),
64
- })
65
- .passthrough();
66
- export const outputSchemas = {
67
- whoami: z
68
- .object({
69
- account: z.unknown().optional(),
70
- organizations: z.array(anyRecord).optional(),
71
- defaultOrganizationId: z.string().nullable().optional(),
72
- features: z.unknown().optional(),
73
- links: z.unknown().optional(),
74
- notes: z.array(z.string()).optional(),
75
- })
76
- .passthrough(),
77
- creditPrices: z
78
- .object({
79
- priceVersion: z.number().optional(),
80
- actions: z.array(anyRecord).optional(),
81
- launchVideos: z.array(anyRecord).optional(),
82
- launchVideoRevisions: z.array(anyRecord).optional(),
83
- monthlyCredits: z.unknown().optional(),
84
- notes: z.array(z.string()).optional(),
85
- })
86
- .passthrough(),
87
- usage: z
88
- .object({
89
- organization: anyRecord.optional(),
90
- balance: z.unknown().optional(),
91
- usage: z.unknown().optional(),
92
- links: z.unknown().optional(),
93
- })
94
- .passthrough(),
95
- listBrands: z
96
- .object({
97
- brands: z.array(anyRecord),
98
- pagination: paginationSchema.optional(),
99
- })
100
- .passthrough(),
101
- getBrand: z
102
- .object({
103
- brand: anyRecord.optional(),
104
- role: z.string().optional(),
105
- })
106
- .passthrough(),
107
- listDesigns: z
108
- .object({
109
- designs: z.array(anyRecord),
110
- archivedCount: z.number().optional(),
111
- pagination: paginationSchema.optional(),
112
- })
113
- .passthrough(),
114
- getDesign: z
115
- .object({
116
- design: anyRecord.optional(),
117
- })
118
- .passthrough(),
119
- createBrand: z
120
- .object({
121
- brand: anyRecord.optional(),
122
- reviewStatus: z.string().optional(),
123
- })
124
- .passthrough(),
125
- generate: z
126
- .object({
127
- designId: z.string(),
128
- generationId: z.string(),
129
- status: z.string(),
130
- editorUrl: z.string(),
131
- statusUrl: z.string(),
132
- format: z.string().optional(),
133
- formatNote: z.string().optional(),
134
- credits: z.unknown().optional(),
135
- })
136
- .passthrough(),
137
- poll: z
138
- .object({
139
- designId: z.string(),
140
- generationId: z.string(),
141
- status: z.string(),
142
- editorUrl: z.string(),
143
- imageUrl: z.string().optional(),
144
- completedAt: z.string().nullable().optional(),
145
- platformBadge: z.boolean().optional(),
146
- badgeNote: z.string().optional(),
147
- failureMessage: z.string().optional(),
148
- })
149
- .passthrough(),
150
- download: z
151
- .object({
152
- saved: z.boolean(),
153
- path: z.string(),
154
- size: z.number(),
155
- })
156
- .passthrough(),
157
- };
158
- export const readOnly = {
159
- readOnlyHint: true,
160
- destructiveHint: false,
161
- idempotentHint: true,
162
- openWorldHint: true,
163
- };
164
- export const writeRemote = {
165
- readOnlyHint: false,
166
- destructiveHint: false,
167
- idempotentHint: false,
168
- openWorldHint: true,
169
- };
170
- export const writeLocal = {
171
- readOnlyHint: false,
172
- destructiveHint: true,
173
- idempotentHint: false,
174
- openWorldHint: true,
175
- };
@@ -1,30 +0,0 @@
1
- import { z } from "zod";
2
- import { api, query } from "./api-client.js";
3
- import { argsObject, outputSchemas, readOnly, safeIdSchema } from "./tool-schemas.js";
4
- export const accountTools = [
5
- {
6
- name: "tday_get_credit_prices",
7
- title: "Credit prices",
8
- description: "What each thing costs in credits at today's price list: an image design, an edit, design videos, launch videos (every length, with or without music and voiceover, already added up), a launch video revision, scans, and each plan's monthly credits. Use it to tell the person what something will cost BEFORE starting it, next to the balance from tday_whoami.",
9
- inputSchema: argsObject({}),
10
- outputSchema: outputSchemas.creditPrices,
11
- annotations: readOnly,
12
- handler: (_args, config) => api(config, "/api/v1/credits/prices"),
13
- },
14
- {
15
- name: "tday_get_usage",
16
- title: "Credit usage",
17
- description: "One organisation's credit balance and what it has spent this grant period, by action, by brand and by person, plus a link to its usage page. Pass organizationId from tday_whoami; omitted, the organisation the person owns.",
18
- inputSchema: argsObject({
19
- organizationId: safeIdSchema.optional().describe("Organisation ID from tday_whoami"),
20
- }),
21
- outputSchema: outputSchemas.usage,
22
- annotations: readOnly,
23
- handler: (args, config) => api(config, `/api/v1/usage${query({ organizationId: typeof args.organizationId === "string" ? args.organizationId : undefined })}`),
24
- },
25
- ];
26
- export const IMAGE_FORMATS = ["square", "ig_post", "story", "fb_post", "linkedin_post", "desktop", "a4_page"];
27
- export const imageFormatSchema = z
28
- .enum(IMAGE_FORMATS)
29
- .optional()
30
- .describe("Preset size: square 1080x1080, ig_post 1080x1350 (4:5 portrait), story 1080x1920 (9:16), fb_post 1200x630, linkedin_post 1200x627, desktop 1920x1080, a4_page. Portrait, square and story are on the paid plans where the plan gates sizes (tday_whoami output.allSizes); asking for one that is not included is refused with an upgrade link.");
@@ -1,39 +0,0 @@
1
- import { z } from "zod";
2
- import { api, query } from "./api-client.js";
3
- import { argsObject, readOnly, safeIdSchema } from "./tool-schemas.js";
4
- const MAX_FEATURES = 50;
5
- export const LIST_FEATURES_DESCRIPTION = "List a brand's features (its Launch page catalog), newest added first, with the videos each already has: how many launch videos are finished or being made, and the latest one with its link. " +
6
- "Use it for the newest feature, for the features that still have no launch video (without: launch_video), or to find a feature's id for tday_create_launch_video. " +
7
- "brandId may be left out when the account has one brand; otherwise pass one from tday_list_brands. `more` counts the features not listed. Spends no credits.";
8
- const featureListSchema = z
9
- .object({
10
- brand: z.object({ id: z.string(), name: z.string() }).passthrough(),
11
- counts: z.record(z.string(), z.unknown()),
12
- features: z.array(z.record(z.string(), z.unknown())),
13
- more: z.number(),
14
- })
15
- .passthrough();
16
- export const featureTools = [
17
- {
18
- name: "tday_list_features",
19
- title: "List features",
20
- description: LIST_FEATURES_DESCRIPTION,
21
- inputSchema: argsObject({
22
- brandId: safeIdSchema.optional().describe("Brand ID from tday_list_brands. Optional when the account has one brand."),
23
- without: z
24
- .enum(["launch_video"])
25
- .optional()
26
- .describe("launch_video: only features with no launch video finished or being made."),
27
- search: z.string().trim().min(1).max(160).optional().describe("Only features whose title contains this, ignoring case."),
28
- limit: z.number().int().min(1).max(MAX_FEATURES).default(20).describe(`How many to list, newest first (at most ${MAX_FEATURES}). Defaults to 20.`),
29
- }),
30
- outputSchema: featureListSchema,
31
- annotations: readOnly,
32
- handler: (args, config) => api(config, `/api/v1/features${query({
33
- brandId: typeof args.brandId === "string" ? args.brandId : undefined,
34
- without: typeof args.without === "string" ? args.without : undefined,
35
- q: typeof args.search === "string" ? args.search : undefined,
36
- limit: typeof args.limit === "number" ? args.limit : undefined,
37
- })}`),
38
- },
39
- ];
@@ -1,358 +0,0 @@
1
- import { constants } from "node:fs";
2
- import { open, rm } from "node:fs/promises";
3
- import { z } from "zod";
4
- import { api } from "./api-client.js";
5
- import { prepareOutputFilePath, validateImageUrl } from "./download-safety.js";
6
- import { argsObject, readOnly, safeIdSchema, safePath, writeLocal, writeRemote, } from "./tool-schemas.js";
7
- const MAX_TITLE_CHARS = 160;
8
- const MAX_DESCRIPTION_CHARS = 4000;
9
- const MAX_DIRECTION_CHARS = 600;
10
- export const MAX_VIDEO_BYTES = 250 * 1024 * 1024;
11
- const MUSIC_MOODS = ["Cinematic", "Upbeat", "Minimal", "Warm", "Driving"];
12
- const NARRATION_STYLES = ["talk", "thread", "story", "duo", "team", "launch", "doc"];
13
- const STYLES = "talk (one person talking to one viewer who has the problem, the default); thread (says how many updates there are, then goes through each by name, the default for a product update); " +
14
- "story (a day in the life of one person in your audience); duo (two voices: the viewer's inner voice and a narrator); team (from the team who built it); launch (quick and bright); doc (a calm, third-person documentary)";
15
- const VOICE_NAMES = ["Reece", "Ivy", "Janet", "Sarah", "Craig"];
16
- const VOICES = "Reece, warm conversational narrator; Ivy, bright friendly presenter; Janet, warm expressive explainer; " +
17
- "Sarah, upbeat lively presenter; Craig, deep polished broadcast voice";
18
- const POLL_GUIDANCE = "Poll tday_poll_launch_video with this videoId every 20-30 seconds until it is ready.";
19
- const VOICEOVER_NOT_INCLUDED = "Voiceover is not included on this organisation's plan, so the film is silent and no voice was charged.";
20
- const videoIdSchema = safeIdSchema.describe("videoId returned by tday_create_launch_video");
21
- const outputSchemas = {
22
- create: z
23
- .object({ videoId: z.string(), status: z.string(), narration: z.string(), reviewUrl: z.string() })
24
- .passthrough(),
25
- poll: z
26
- .object({ videoId: z.string(), status: z.string(), state: z.string(), terminal: z.boolean(), reviewUrl: z.string() })
27
- .passthrough(),
28
- mp4: z
29
- .object({
30
- videoId: z.string(),
31
- versionId: z.string(),
32
- fps: z.number(),
33
- state: z.enum(["ready", "rendering", "failed"]),
34
- mp4Url: z.string().optional(),
35
- progress: z.number().nullable().optional(),
36
- saved: z.boolean().optional(),
37
- path: z.string().optional(),
38
- size: z.number().optional(),
39
- })
40
- .passthrough(),
41
- revise: z.object({ videoId: z.string(), status: z.string(), reviewUrl: z.string() }).passthrough(),
42
- };
43
- const FIRST_EXPORT = "The first request for a cut at a frame rate renders its file, which usually takes a minute or two";
44
- export const launchTools = [
45
- {
46
- name: "tday_create_launch_video",
47
- title: "Create launch video",
48
- description: "Make a launch video: a short animated film announcing a feature or product, in the brand's own look. tday's pipeline writes the brief, storyboard, motion, music and narration from the title and description; describe the subject well and pick the options. " +
49
- "For a feature tday already has (tday_list_features), pass its featureId instead of brandId, title and description: the film is made of that feature, with what tday knows about it. " +
50
- "It spends credits from the brand's organisation: a standard (20s), scored, silent film is 40 credits, and a voiceover or another length changes that (tday_get_credit_prices has the full list). " +
51
- "A 402 means the balance cannot cover it: nothing was started, so do not retry. Returns a videoId and a reviewUrl at once; the film takes about 2-5 minutes, so poll tday_poll_launch_video.",
52
- inputSchema: argsObject({
53
- featureId: safeIdSchema
54
- .optional()
55
- .describe("A feature's id from tday_list_features: the film is made of it, and brandId, title and description are not needed."),
56
- brandId: safeIdSchema
57
- .optional()
58
- .describe("Brand ID from tday_list_brands, for a new subject (without featureId). The film uses this brand's colours, fonts and logo."),
59
- title: z
60
- .string()
61
- .trim()
62
- .min(1)
63
- .max(MAX_TITLE_CHARS)
64
- .optional()
65
- .describe("The feature or product the film launches, as a short name (without featureId)."),
66
- description: z
67
- .string()
68
- .trim()
69
- .min(1)
70
- .max(MAX_DESCRIPTION_CHARS)
71
- .optional()
72
- .describe("What the feature or product does and who it is for (without featureId). The film's brief is written from this, so be specific."),
73
- length: z
74
- .enum(["short", "standard", "long"])
75
- .default("standard")
76
- .describe("short (about 10s), standard (about 20s, the default) or long (about 50s)."),
77
- narration: z
78
- .enum(["silent", "voiceover"])
79
- .default("silent")
80
- .describe("silent (music and motion carry it, the default) or voiceover (narrated, with captions; costs more)."),
81
- music: z.enum(["score", "none"]).default("score").describe("score (a bed written to the film's length, the default) or none."),
82
- musicMood: z.enum(MUSIC_MOODS).optional().describe("A mood for the score. Omit to let the planner choose."),
83
- voice: z.enum(VOICE_NAMES).optional().describe(`The narrator of a voiceover film. Omit for the default. One of: ${VOICES}.`),
84
- narrationStyle: z.enum(NARRATION_STYLES).optional().describe(`How a voiceover film tells its story. Omit for the default. One of: ${STYLES}.`),
85
- direction: z
86
- .string()
87
- .trim()
88
- .min(1)
89
- .max(MAX_DIRECTION_CHARS)
90
- .optional()
91
- .describe("Optional creative notes in plain words, for example 'lead on the scrubbing, end on the call to action'."),
92
- }),
93
- outputSchema: outputSchemas.create,
94
- annotations: writeRemote,
95
- handler: async (args, config) => {
96
- const existing = typeof args.featureId === "string" ? args.featureId : null;
97
- const featureId = existing ?? (await saveSubject(args, config));
98
- const created = await api(config, safePath("/api/showcase/features/:id/create-launch-video", { id: featureId }), { method: "POST", body: JSON.stringify(launchAxes(args)) }).catch((error) => {
99
- if (existing)
100
- throw error;
101
- throw new Error(`${error instanceof Error ? error.message : String(error)} ${subjectSaved(args.title)}`);
102
- });
103
- const videoId = created?.runFeatureId;
104
- if (typeof videoId !== "string")
105
- throw new Error("tday could not start that film. Try again in a moment.");
106
- const narration = created?.narration === "voiceover" || created?.narration === "silent" ? created.narration : args.narration;
107
- return {
108
- videoId,
109
- status: "queued",
110
- length: args.length,
111
- narration,
112
- music: args.music,
113
- reviewUrl: reviewUrl(config, videoId),
114
- ...(args.narration === "voiceover" && narration === "silent" ? { note: VOICEOVER_NOT_INCLUDED } : {}),
115
- next: `A film takes about 2-5 minutes. ${POLL_GUIDANCE}`,
116
- };
117
- },
118
- },
119
- {
120
- name: "tday_poll_launch_video",
121
- title: "Poll launch video",
122
- description: "Check a launch video made with tday_create_launch_video or revised with tday_revise_launch_video. state is in_progress, ready or failed; terminal is true once it is ready or failed. " +
123
- "Poll every 20-30 seconds while it is in progress. When it is ready, watch it at reviewUrl or call tday_get_launch_video_mp4 for the MP4 file. Spends no credits.",
124
- inputSchema: argsObject({ videoId: videoIdSchema }),
125
- outputSchema: outputSchemas.poll,
126
- annotations: readOnly,
127
- handler: async (args, config) => {
128
- const videoId = String(args.videoId);
129
- const payload = await api(config, safePath("/api/showcase/videos/:videoId", args));
130
- return summarizeLaunchVideo(videoId, payload ?? {}, reviewUrl(config, videoId));
131
- },
132
- },
133
- {
134
- name: "tday_get_launch_video_mp4",
135
- title: "Get launch video MP4",
136
- description: "Get a ready launch video as an MP4 file URL. A launch video is ready before its file: " + FIRST_EXPORT + ", so this answers state rendering " +
137
- "(with progress) until the file exists - call it again every 20-30 seconds until state is ready. Later requests return the stored file at once. Spends no credits. " +
138
- "If state is failed, call it again with retry: true. versionId defaults to the newest ready cut (readyVersionId from tday_poll_launch_video). " +
139
- `Pass download: true to also save it inside the current working directory once it is ready (at most ${MAX_VIDEO_BYTES / 1024 / 1024}MB).`,
140
- inputSchema: argsObject({
141
- videoId: videoIdSchema,
142
- versionId: safeIdSchema.optional().describe("A cut's versionId from tday_poll_launch_video. Omit for the newest ready cut."),
143
- fps: z.union([z.literal(60), z.literal(30)]).default(60).describe("The file's frame rate: 60 (the default, smoothest) or 30 (a smaller file). Older cuts that cannot be rendered again offer only 60 and answer 409 for 30."),
144
- retry: z.boolean().default(false).describe("Ask again after state failed. Defaults to false."),
145
- download: z.boolean().default(false).describe("Also save the MP4 into the current working directory. Defaults to false."),
146
- filePath: z
147
- .string()
148
- .trim()
149
- .min(1)
150
- .max(500)
151
- .optional()
152
- .describe("Where to save it, relative to cwd. Defaults to ./tday-launch-videos/<videoId>-<versionId>.mp4 (<videoId>-<versionId>-30fps.mp4 at 30 fps, so both rates can sit side by side). Only used with download."),
153
- overwrite: z.boolean().default(false).describe("Whether to overwrite an existing regular file. Defaults to false for safety."),
154
- }),
155
- outputSchema: outputSchemas.mp4,
156
- annotations: writeLocal,
157
- handler: async (args, config) => {
158
- const videoId = String(args.videoId);
159
- const versionId = typeof args.versionId === "string" ? args.versionId : await newestReadyVersion(config, videoId);
160
- const fps = args.fps === 30 ? 30 : 60;
161
- const answer = await api(config, safePath("/api/showcase/videos/:videoId/render", args), {
162
- method: "POST",
163
- body: JSON.stringify({ versionId, ...(fps === 30 ? { fps } : {}), ...(args.retry ? { retry: true } : {}) }),
164
- });
165
- const waiting = exportWaiting(answer, { videoId, versionId, fps, reviewUrl: reviewUrl(config, videoId) });
166
- if (waiting)
167
- return waiting;
168
- const mp4Url = answer?.mp4Url;
169
- if (typeof mp4Url !== "string")
170
- throw new Error("tday could not prepare the video. Try again in a moment.");
171
- const film = { videoId, versionId, fps, state: "ready", mp4Url, reviewUrl: reviewUrl(config, videoId) };
172
- if (!args.download)
173
- return film;
174
- const filePath = typeof args.filePath === "string" ? args.filePath : `./tday-launch-videos/${videoId}-${versionId}${fps === 30 ? "-30fps" : ""}.mp4`;
175
- return { ...film, saved: true, ...(await saveVideo(mp4Url, filePath, Boolean(args.overwrite))) };
176
- },
177
- },
178
- {
179
- name: "tday_revise_launch_video",
180
- title: "Revise launch video",
181
- description: "Make a new cut of a ready launch video from notes, for example 'slow the ending down' or 'lead with the search box'. The kept film is edited to the notes, so the rest stays as it was. " +
182
- "It spends tweak credits from the brand's organisation (13 credits for a standard film; tday_get_credit_prices has the full list), and a 402 means the balance cannot cover it. " +
183
- "A 409 means a cut is already being made: poll until it finishes. The previous cut stays available while the new one is made. " + POLL_GUIDANCE,
184
- inputSchema: argsObject({
185
- videoId: videoIdSchema,
186
- notes: z.string().trim().min(1).max(MAX_DIRECTION_CHARS).describe("What to change, in plain words."),
187
- }),
188
- outputSchema: outputSchemas.revise,
189
- annotations: writeRemote,
190
- handler: async (args, config) => {
191
- const videoId = String(args.videoId);
192
- const cut = await api(config, safePath("/api/showcase/videos/:videoId/regenerate", args), {
193
- method: "POST",
194
- body: JSON.stringify({ instructions: args.notes }),
195
- });
196
- return {
197
- videoId,
198
- ...(typeof cut?.versionId === "string" ? { versionId: cut.versionId } : {}),
199
- status: "queued",
200
- reviewUrl: reviewUrl(config, videoId),
201
- next: `A new cut takes a few minutes. ${POLL_GUIDANCE}`,
202
- };
203
- },
204
- },
205
- ];
206
- async function saveSubject(args, config) {
207
- if (typeof args.brandId !== "string" || typeof args.title !== "string" || typeof args.description !== "string") {
208
- throw new Error("Pass featureId (from tday_list_features), or brandId with a title and description for a new subject.");
209
- }
210
- const saved = await api(config, "/api/showcase/features", {
211
- method: "POST",
212
- body: JSON.stringify({ brandId: args.brandId, title: args.title, description: args.description, typeId: null }),
213
- });
214
- const featureId = saved?.feature?.id;
215
- if (typeof featureId !== "string")
216
- throw new Error("tday could not start that film. Try again in a moment.");
217
- return featureId;
218
- }
219
- function subjectSaved(title) {
220
- return `The subject "${String(title)}" was saved on the brand's Launch page, where a film can be made from it later.`;
221
- }
222
- function launchAxes(args) {
223
- return {
224
- length: args.length,
225
- narration: args.narration,
226
- music: args.music,
227
- ...(args.musicMood ? { musicMood: args.musicMood } : {}),
228
- ...(args.voice ? { voiceId: args.voice } : {}),
229
- ...(args.narrationStyle ? { narrationStyle: args.narrationStyle } : {}),
230
- ...(args.direction ? { direction: args.direction } : {}),
231
- };
232
- }
233
- function reviewUrl(config, videoId) {
234
- return `${config.webBaseUrl}/showcase/review/${encodeURIComponent(videoId)}`;
235
- }
236
- async function newestReadyVersion(config, videoId) {
237
- const payload = await api(config, safePath("/api/showcase/videos/:videoId", { videoId }));
238
- const versionId = payload?.video?.versionId;
239
- if (typeof versionId !== "string") {
240
- throw new Error("This launch video has no finished cut yet. Poll tday_poll_launch_video until it is ready, then try again.");
241
- }
242
- return versionId;
243
- }
244
- function summarizeLaunchVideo(videoId, payload, url) {
245
- const status = stringOr(payload.feature?.status, "unknown");
246
- const first = Array.isArray(payload.versions) ? payload.versions[0] : undefined;
247
- const latest = typeof first === "object" && first !== null ? first : null;
248
- const latestStatus = stringOr(latest?.status, null);
249
- const readyVersionId = stringOr(payload.video?.versionId, null);
250
- const state = launchState(status, latestStatus);
251
- const failureMessage = state === "failed" ? stringOr(latest?.failureMessage, null) : null;
252
- const durationMs = typeof payload.video?.durationMs === "number" ? payload.video.durationMs : null;
253
- return {
254
- videoId,
255
- ...(typeof payload.feature?.title === "string" ? { title: payload.feature.title } : {}),
256
- status,
257
- state,
258
- terminal: state !== "in_progress",
259
- latestVersion: latest
260
- ? { versionId: stringOr(latest.versionId, null), versionNumber: latest.versionNumber ?? null, status: latestStatus }
261
- : null,
262
- ...(readyVersionId ? { readyVersionId } : {}),
263
- ...(durationMs !== null ? { durationMs } : {}),
264
- ...(failureMessage ? { failureMessage } : {}),
265
- reviewUrl: url,
266
- next: nextStep(state, readyVersionId),
267
- };
268
- }
269
- function exportWaiting(answer, film) {
270
- if (answer?.status === "rendering" || answer?.status === "none") {
271
- const progress = typeof answer.progress === "number" ? answer.progress : null;
272
- return {
273
- ...film,
274
- state: "rendering",
275
- progress,
276
- next: `The file is being rendered${progress === null ? "" : ` (${Math.round(progress * 100)}% done)`}. ${FIRST_EXPORT}. Call tday_get_launch_video_mp4 again in 20-30 seconds.`,
277
- };
278
- }
279
- if (answer?.status === "failed") {
280
- return {
281
- ...film,
282
- state: "failed",
283
- error: typeof answer.error === "string" ? answer.error : "The file could not be rendered.",
284
- next: "The file could not be rendered and spent no credits. Call tday_get_launch_video_mp4 again with retry: true to try again.",
285
- };
286
- }
287
- return null;
288
- }
289
- function launchState(featureStatus, latestStatus) {
290
- if (latestStatus === "ready")
291
- return "ready";
292
- if (latestStatus === "failed" || featureStatus === "failed")
293
- return "failed";
294
- if (!latestStatus && (featureStatus === "review" || featureStatus === "published"))
295
- return "ready";
296
- return "in_progress";
297
- }
298
- function nextStep(state, readyVersionId) {
299
- if (state === "ready") {
300
- return "Ready. Watch it at reviewUrl, call tday_get_launch_video_mp4 for the MP4 file (its first request renders the file, usually a minute or two), or tday_revise_launch_video to change it.";
301
- }
302
- if (state === "in_progress")
303
- return "Still being made. Poll again in 20-30 seconds.";
304
- return readyVersionId
305
- ? "This cut failed and was not charged. The previous cut is still available: tday_get_launch_video_mp4 returns it, and tday_revise_launch_video can try the change again."
306
- : "This film failed and was not charged. Start a new one with tday_create_launch_video.";
307
- }
308
- function stringOr(value, fallback) {
309
- return typeof value === "string" && value ? value : fallback;
310
- }
311
- async function saveVideo(mp4Url, rawPath, overwrite) {
312
- const url = validateImageUrl(mp4Url);
313
- const filePath = await prepareOutputFilePath(rawPath, { overwrite });
314
- const res = await fetch(url.href);
315
- if (res.url)
316
- validateImageUrl(res.url);
317
- if (!res.ok)
318
- throw new Error(`Failed to download the video: ${res.status} ${res.statusText}`);
319
- const declared = Number(res.headers.get("content-length") ?? 0);
320
- if (Number.isFinite(declared) && declared > MAX_VIDEO_BYTES) {
321
- throw new Error(`The video exceeds the ${MAX_VIDEO_BYTES / 1024 / 1024}MB download limit. Use mp4Url instead.`);
322
- }
323
- const contentType = res.headers.get("content-type")?.toLowerCase();
324
- if (contentType && !/^(video\/|application\/octet-stream|binary\/octet-stream)/.test(contentType)) {
325
- throw new Error(`The download is not a video (content-type: ${contentType}).`);
326
- }
327
- const reader = res.body?.getReader();
328
- if (!reader)
329
- throw new Error("No response body from the video URL.");
330
- const flags = constants.O_WRONLY |
331
- constants.O_CREAT |
332
- (overwrite ? constants.O_TRUNC : constants.O_EXCL) |
333
- (constants.O_NOFOLLOW ?? 0);
334
- const handle = await open(filePath, flags, 0o600);
335
- let size = 0;
336
- try {
337
- for (;;) {
338
- const { done, value } = await reader.read();
339
- if (done)
340
- break;
341
- size += value.byteLength;
342
- if (size > MAX_VIDEO_BYTES) {
343
- throw new Error(`The video exceeds the ${MAX_VIDEO_BYTES / 1024 / 1024}MB download limit. Use mp4Url instead.`);
344
- }
345
- await handle.write(value);
346
- }
347
- }
348
- catch (error) {
349
- await handle.close();
350
- await rm(filePath, { force: true });
351
- throw error;
352
- }
353
- finally {
354
- reader.releaseLock();
355
- }
356
- await handle.close();
357
- return { path: filePath, size };
358
- }