@appeeky/spec 0.1.0 → 0.2.0

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 (46) hide show
  1. package/README.md +158 -5
  2. package/agents/appeeky-ads.md +50 -0
  3. package/agents/appeeky-aso.md +58 -0
  4. package/agents/appeeky-releases.md +47 -0
  5. package/agents/appeeky-reviews.md +53 -0
  6. package/agents/appeeky-social.md +54 -0
  7. package/agents/appeeky-ugc.md +56 -0
  8. package/agents/cofounder.md +36 -0
  9. package/dist/ads.d.ts +5 -2
  10. package/dist/ads.js +8 -4
  11. package/dist/agents.d.ts +47 -0
  12. package/dist/agents.js +88 -0
  13. package/dist/cli.js +582 -9
  14. package/dist/dataforseo.d.ts +139 -0
  15. package/dist/dataforseo.js +354 -0
  16. package/dist/experiments.d.ts +4 -4
  17. package/dist/metrics.d.ts +20 -0
  18. package/dist/metrics.js +4 -0
  19. package/dist/report.d.ts +5 -0
  20. package/dist/report.js +42 -0
  21. package/dist/reviews.d.ts +2 -2
  22. package/dist/schema.d.ts +95 -2
  23. package/dist/schema.js +35 -0
  24. package/dist/search-ads.d.ts +137 -0
  25. package/dist/search-ads.js +354 -0
  26. package/dist/seo.d.ts +87 -0
  27. package/dist/seo.js +391 -0
  28. package/dist/setup.js +39 -6
  29. package/dist/social.d.ts +129 -0
  30. package/dist/social.js +189 -0
  31. package/dist/transport.d.ts +1 -0
  32. package/dist/transport.js +1 -1
  33. package/dist/ugc-core.js +1 -0
  34. package/dist/ugc-local.d.ts +116 -0
  35. package/dist/ugc-local.js +174 -0
  36. package/dist/ugc.d.ts +149 -0
  37. package/dist/ugc.js +111 -0
  38. package/dist/visibility-core.js +96 -0
  39. package/dist/visibility-local.d.ts +159 -0
  40. package/dist/visibility-local.js +246 -0
  41. package/dist/visibility.d.ts +3 -0
  42. package/dist/visibility.js +1 -1
  43. package/package.json +7 -3
  44. package/skills/appeeky-spec/SKILL.md +14 -18
  45. package/skills/appeeky-spec-experiments/SKILL.md +12 -2
  46. package/skills/appeeky-spec-visibility/SKILL.md +33 -6
package/dist/social.js ADDED
@@ -0,0 +1,189 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import YAML from "yaml";
5
+ import { z } from "zod";
6
+ import { SPEC_DIR, SpecError } from "./files.js";
7
+ /**
8
+ * Organic social media in the repo, through the accounts connected in Appeeky (APPEEKY_API_KEY,
9
+ * Startup plan or above):
10
+ *
11
+ * appeeky/social/accounts.yaml the connected accounts (platform, name, id)
12
+ * appeeky/social/posts.jsonl recent posts, one per line, with each platform's result
13
+ * appeeky/social/summary.md the last 30 days per platform, and the posts that did best
14
+ * appeeky/social/drafts/<name>.yaml a post: drafted, reviewed in a pull request, then sent
15
+ *
16
+ * Sending creates the post in Appeeky, which publishes it right away, or at `when`.
17
+ */
18
+ export const METRICS = ["views", "impressions", "reach", "likes", "comments", "shares", "reposts", "saves", "clicks"];
19
+ export const socialDir = (root) => join(root, SPEC_DIR, "social");
20
+ export const draftsDir = (root) => join(socialDir(root), "drafts");
21
+ const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== ""));
22
+ export function toAccount(r) {
23
+ return { id: r.id, platform: r.platform, name: r.displayName || r.platformUserId || r.platform, active: r.isActive !== false && !r.needsReconnect };
24
+ }
25
+ export function toPost(r, analytics = []) {
26
+ return compact({
27
+ id: r.id,
28
+ status: r.status,
29
+ text: r.content,
30
+ createdAt: r.created_at,
31
+ scheduledFor: r.scheduled_for ?? undefined,
32
+ platforms: (r.social_media_platform_posts ?? []).map((p) => {
33
+ const a = analytics.find((x) => x.platform === p.platform)?.analytics ?? {};
34
+ const metrics = Object.fromEntries(METRICS.filter((k) => typeof a[k] === "number" && a[k] > 0).map((k) => [k, a[k]]));
35
+ return compact({
36
+ platform: p.platform,
37
+ status: p.status,
38
+ url: p.platform_post_url ?? undefined,
39
+ publishedAt: p.published_at ?? undefined,
40
+ error: p.error_message ?? undefined,
41
+ metrics: Object.keys(metrics).length ? metrics : undefined,
42
+ });
43
+ }),
44
+ });
45
+ }
46
+ /** Connected accounts and the latest posts, with analytics for the ones that went out. */
47
+ export async function fetchSocial(api, options = {}) {
48
+ const accounts = ((await api.get("/connect/social/accounts")) ?? []).map(toAccount);
49
+ const rows = (await api.get("/social/posts", { limit: String(options.limit ?? 100) })) ?? [];
50
+ const posts = [];
51
+ for (const row of rows) {
52
+ const published = (row.social_media_platform_posts ?? []).some((p) => p.published_at);
53
+ const analytics = published ? ((await api.get(`/social/posts/${encodeURIComponent(row.id)}/analytics`).catch(() => null))?.analytics ?? []) : [];
54
+ posts.push(toPost(row, analytics));
55
+ }
56
+ return { accounts, posts };
57
+ }
58
+ const total = (p, k) => p.metrics?.[k] ?? 0;
59
+ /** The reach of a post on one platform: views when the platform reports them, else impressions or reach. */
60
+ const seen = (p) => total(p, "views") || total(p, "impressions") || total(p, "reach");
61
+ export function summaryMarkdown(accounts, posts, now = new Date()) {
62
+ const since = new Date(now.getTime() - 30 * 86_400_000).toISOString();
63
+ const recent = posts.filter((p) => (p.platforms.find((x) => x.publishedAt)?.publishedAt ?? p.createdAt) >= since);
64
+ const lines = [`# Social media`, "", `Updated ${now.toISOString().slice(0, 10)}. The last 30 days, from the accounts connected in Appeeky.`, ""];
65
+ lines.push("## Accounts", "");
66
+ if (!accounts.length)
67
+ lines.push("None connected. Connect them in Appeeky (Social).");
68
+ for (const a of accounts)
69
+ lines.push(`- ${a.platform}: ${a.name}${a.active ? "" : " (needs reconnecting in Appeeky)"}`);
70
+ lines.push("", "## By platform", "");
71
+ const platforms = [...new Set(recent.flatMap((p) => p.platforms.map((x) => x.platform)))].sort();
72
+ if (!platforms.length)
73
+ lines.push("No posts in the last 30 days.");
74
+ else {
75
+ lines.push("| Platform | Posts | Seen | Likes | Comments | Shares | Failed |", "| --- | --- | --- | --- | --- | --- | --- |");
76
+ for (const platform of platforms) {
77
+ const results = recent.flatMap((p) => p.platforms.filter((x) => x.platform === platform));
78
+ const sum = (f) => results.reduce((n, x) => n + f(x), 0);
79
+ lines.push(`| ${platform} | ${results.filter((x) => x.publishedAt).length} | ${sum(seen)} | ${sum((x) => total(x, "likes"))} | ${sum((x) => total(x, "comments"))} | ${sum((x) => total(x, "shares") + total(x, "reposts"))} | ${results.filter((x) => x.error).length} |`);
80
+ }
81
+ }
82
+ const best = recent
83
+ .flatMap((p) => p.platforms.filter((x) => x.publishedAt).map((x) => ({ post: p, result: x })))
84
+ .sort((a, b) => seen(b.result) + total(b.result, "likes") * 10 - (seen(a.result) + total(a.result, "likes") * 10))
85
+ .slice(0, 5);
86
+ if (best.length) {
87
+ lines.push("", "## Did best", "");
88
+ for (const { post, result } of best) {
89
+ const text = post.text.replace(/\s+/g, " ").slice(0, 90);
90
+ lines.push(`- ${result.platform}, ${result.publishedAt.slice(0, 10)}: ${seen(result)} seen, ${total(result, "likes")} likes. "${text}${post.text.length > 90 ? "…" : ""}"${result.url ? ` ${result.url}` : ""}`);
91
+ }
92
+ }
93
+ const waiting = posts.filter((p) => p.status === "scheduled" || p.status === "pending");
94
+ if (waiting.length) {
95
+ lines.push("", "## Waiting to go out", "");
96
+ for (const p of waiting)
97
+ lines.push(`- ${p.scheduledFor?.slice(0, 16).replace("T", " ") ?? "now"} on ${p.platforms.map((x) => x.platform).join(", ")}: "${p.text.replace(/\s+/g, " ").slice(0, 80)}"`);
98
+ }
99
+ return `${lines.join("\n")}\n`;
100
+ }
101
+ export async function saveSocial(root, data, now = new Date()) {
102
+ await mkdir(socialDir(root), { recursive: true });
103
+ await writeFile(join(socialDir(root), "accounts.yaml"), YAML.stringify({ accounts: data.accounts }, { lineWidth: 0 }));
104
+ await writeFile(join(socialDir(root), "posts.jsonl"), data.posts.map((p) => JSON.stringify(p)).join("\n") + (data.posts.length ? "\n" : ""));
105
+ await writeFile(join(socialDir(root), "summary.md"), summaryMarkdown(data.accounts, data.posts, now));
106
+ }
107
+ export async function readAccounts(root) {
108
+ const file = join(socialDir(root), "accounts.yaml");
109
+ if (!existsSync(file))
110
+ return [];
111
+ return (YAML.parse(await readFile(file, "utf8"))?.accounts ?? []);
112
+ }
113
+ // ── Drafts ──────────────────────────────────────────────────────────────────
114
+ export const draftSchema = z
115
+ .object({
116
+ /** Platforms (twitter, instagram, tiktok, …) or account ids from accounts.yaml. */
117
+ accounts: z.array(z.string().min(1)).min(1),
118
+ text: z.string().min(1),
119
+ /** ISO time to schedule it; without it, the post goes out when it is sent. */
120
+ when: z.string().optional(),
121
+ /** Public image or video URLs. Instagram, TikTok and Pinterest need one. */
122
+ media: z.array(z.string().url()).optional(),
123
+ /** Why this post, for whoever reviews the pull request. */
124
+ why: z.string().optional(),
125
+ status: z.enum(["draft", "sent"]).default("draft"),
126
+ postId: z.string().optional(),
127
+ sentAt: z.string().optional(),
128
+ })
129
+ .strict();
130
+ /** Character limits per platform, for `check`-style warnings before sending. */
131
+ export const TEXT_LIMIT = { twitter: 280, x: 280, threads: 500, bluesky: 300, linkedin: 3000, instagram: 2200, tiktok: 2200, facebook: 63206, pinterest: 500, reddit: 40000 };
132
+ const NEEDS_MEDIA = new Set(["instagram", "tiktok", "pinterest"]);
133
+ export async function readDrafts(root) {
134
+ const dir = draftsDir(root);
135
+ if (!existsSync(dir))
136
+ return [];
137
+ const out = [];
138
+ for (const f of (await readdir(dir)).filter((f) => f.endsWith(".yaml")).sort()) {
139
+ const parsed = draftSchema.safeParse(YAML.parse(await readFile(join(dir, f), "utf8")));
140
+ if (!parsed.success)
141
+ throw new SpecError(parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), join(dir, f));
142
+ out.push({ ...parsed.data, file: join(dir, f) });
143
+ }
144
+ return out;
145
+ }
146
+ /** The connected accounts a draft goes to: platform names match every active account on that platform. */
147
+ export function resolveAccounts(draft, accounts) {
148
+ const found = [];
149
+ const missing = [];
150
+ for (const ref of draft.accounts) {
151
+ const key = ref.toLowerCase() === "x" ? "twitter" : ref.toLowerCase();
152
+ const match = accounts.filter((a) => a.active && (a.id === ref || a.platform.toLowerCase() === key));
153
+ if (match.length)
154
+ found.push(...match.filter((a) => !found.includes(a)));
155
+ else
156
+ missing.push(ref);
157
+ }
158
+ return { accounts: found, missing };
159
+ }
160
+ export function draftIssues(draft, accounts, now = new Date()) {
161
+ const issues = [];
162
+ const { accounts: to, missing } = resolveAccounts(draft, accounts);
163
+ if (missing.length)
164
+ issues.push(`no connected account for ${missing.join(", ")}`);
165
+ for (const a of to) {
166
+ const limit = TEXT_LIMIT[a.platform.toLowerCase()];
167
+ if (limit && draft.text.length > limit)
168
+ issues.push(`${a.platform}: ${draft.text.length}/${limit} characters`);
169
+ if (NEEDS_MEDIA.has(a.platform.toLowerCase()) && !draft.media?.length)
170
+ issues.push(`${a.platform} needs an image or video (media:)`);
171
+ }
172
+ if (draft.when && !(new Date(draft.when) > now))
173
+ issues.push(`when ${draft.when} is not in the future`);
174
+ return issues;
175
+ }
176
+ /** Create the post in Appeeky (it publishes now, or at `when`) and mark the draft sent. */
177
+ export async function sendDraft(api, draft, accounts, now = new Date()) {
178
+ const { accounts: to } = resolveAccounts(draft, accounts);
179
+ const created = await api.post("/social/posts", {
180
+ content: draft.text,
181
+ account_ids: to.map((a) => a.id),
182
+ ...(draft.when ? { scheduled_for: new Date(draft.when).toISOString() } : {}),
183
+ ...(draft.media?.length ? { media_urls: draft.media } : {}),
184
+ });
185
+ const { file, ...rest } = draft;
186
+ await writeFile(file, YAML.stringify({ ...rest, status: "sent", postId: created.id, sentAt: now.toISOString() }, { lineWidth: 0 }));
187
+ return created.id;
188
+ }
189
+ //# sourceMappingURL=social.js.map
@@ -33,6 +33,7 @@ export type Runner = (command: string, args: string[], env?: Record<string, stri
33
33
  stdout: string;
34
34
  stderr: string;
35
35
  }>;
36
+ export declare const defaultRunner: Runner;
36
37
  export interface LocalCliOptions {
37
38
  /** Named account (`appstore --account`) or profile (`gps --profile`). */
38
39
  account?: string;
package/dist/transport.js CHANGED
@@ -40,7 +40,7 @@ export function onPath(bin, env = process.env) {
40
40
  }
41
41
  return false;
42
42
  }
43
- const defaultRunner = (command, args, env) => new Promise((resolve, reject) => {
43
+ export const defaultRunner = (command, args, env) => new Promise((resolve, reject) => {
44
44
  const child = spawn(command, args, {
45
45
  stdio: ["ignore", "pipe", "pipe"],
46
46
  shell: process.platform === "win32",
@@ -0,0 +1 @@
1
+ var r=class extends Error{constructor(o,n,i){super(o);this.code=n;this.status=i;this.name="MediaJobError"}code;status};var C=280;var v=e=>({prompt:e.prompt,duration:`${e.durationSec}s`,resolution:e.resolution,aspect_ratio:e.aspectRatio,generate_audio:e.audio,...e.negativePrompt?{negative_prompt:e.negativePrompt}:{},...e.seed!==void 0?{seed:e.seed}:{}}),p=(e,t,o=1)=>Array.from({length:Math.floor((t-e)/o)+1},(n,i)=>e+i*o),s=e=>({silent:e,audio:e}),l=e=>({prompt:e.prompt,image_url:e.imageUrl,duration:String(e.durationSec)}),y=(e,t=!1)=>({prompt:e.prompt,...t?{image_urls:e.imageUrls??[]}:{},num_images:e.count,aspect_ratio:e.aspectRatio,output_format:"png",...e.seed!==void 0?{seed:e.seed}:{}}),Y={"9:16":"portrait_16_9","16:9":"landscape_16_9","1:1":"square_hd","4:5":"portrait_4_3"},V=(e,t=!1)=>({prompt:e.prompt,...t?{image_urls:e.imageUrls??[]}:{},num_images:e.count,image_size:Y[e.aspectRatio],...e.seed!==void 0?{seed:e.seed}:{}}),Q=[{kind:"video",id:"veo-3.1-lite",family:"Veo",nativeAudio:!0,label:"Veo 3.1 Lite",provider:"fal",endpoint:"fal-ai/veo3.1/lite/image-to-video",mode:"image-to-video",description:"Google's cheapest Veo: animates a first frame with native speech and sound.",durations:[4,6,8],resolutions:["720p","1080p"],defaultResolution:"720p",pricePerSecond:{silent:.03,audio:.05},pricePerSecondByResolution:{"1080p":{silent:.05,audio:.08}},mapInput:e=>({...v(e),image_url:e.imageUrl})},{kind:"video",id:"veo-3.1-fast",family:"Veo",nativeAudio:!0,label:"Veo 3.1 Fast",provider:"fal",endpoint:"fal-ai/veo3.1/fast/image-to-video",mode:"image-to-video",description:"Animates a first frame; native speech + lip-sync. Cheapest talking-head option.",durations:[4,6,8],resolutions:["720p","1080p"],defaultResolution:"720p",pricePerSecond:{silent:.1,audio:.15},mapInput:e=>({...v(e),image_url:e.imageUrl})},{kind:"video",id:"veo-3.1",family:"Veo",nativeAudio:!0,label:"Veo 3.1",provider:"fal",endpoint:"fal-ai/veo3.1/image-to-video",mode:"image-to-video",description:"Highest quality image-to-video with native dialogue.",durations:[4,6,8],resolutions:["720p","1080p"],defaultResolution:"720p",pricePerSecond:{silent:.2,audio:.4},mapInput:e=>({...v(e),image_url:e.imageUrl})},{kind:"video",id:"veo-3.1-reference",family:"Veo",nativeAudio:!0,label:"Veo 3.1 Reference",provider:"fal",endpoint:"fal-ai/veo3.1/reference-to-video",mode:"reference-to-video",description:"Keeps a persona consistent from reference images (face, outfit, product).",durations:[8],resolutions:["720p","1080p"],defaultResolution:"720p",pricePerSecond:{silent:.2,audio:.4},mapInput:e=>({...v(e),image_urls:e.referenceImageUrls??[]})},{kind:"video",id:"seedance-2.0",family:"Seedance",nativeAudio:!0,label:"Seedance 2.0",provider:"fal",endpoint:"bytedance/seedance-2.0/image-to-video",mode:"image-to-video",description:"ByteDance's flagship: cinematic motion with native audio, up to 15s.",durations:p(4,15),resolutions:["480p","720p","1080p"],defaultResolution:"720p",pricePerSecond:s(.3034),pricePerSecondByResolution:{"480p":s(.135),"1080p":s(.682)},mapInput:e=>({...l(e),resolution:e.resolution,aspect_ratio:e.aspectRatio,generate_audio:e.audio})},{kind:"video",id:"seedance-2.0-fast",family:"Seedance",nativeAudio:!0,label:"Seedance 2.0 Fast",provider:"fal",endpoint:"bytedance/seedance-2.0/fast/image-to-video",mode:"image-to-video",description:"Faster, cheaper Seedance 2.0 with native audio.",durations:p(4,15),resolutions:["480p","720p"],defaultResolution:"720p",pricePerSecond:s(.2419),pricePerSecondByResolution:{"480p":s(.108)},mapInput:e=>({...l(e),resolution:e.resolution,aspect_ratio:e.aspectRatio,generate_audio:e.audio})},{kind:"video",id:"seedance-2.0-mini",family:"Seedance",nativeAudio:!0,label:"Seedance 2.0 Mini",provider:"fal",endpoint:"bytedance/seedance-2.0/mini/image-to-video",mode:"image-to-video",description:"The budget Seedance 2.0, still with native audio.",durations:p(4,15),resolutions:["480p","720p"],defaultResolution:"720p",pricePerSecond:s(.1547),pricePerSecondByResolution:{"480p":s(.0721)},mapInput:e=>({...l(e),resolution:e.resolution,aspect_ratio:e.aspectRatio,generate_audio:e.audio})},{kind:"video",id:"seedance-2.5",family:"Seedance",nativeAudio:!0,label:"Seedance 2.5",provider:"fal",endpoint:"bytedance/seedance-2.5/image-to-video",mode:"image-to-video",description:"Newest Seedance: highest fidelity and up to 30s. Follows the first frame's shape.",durations:p(4,30),resolutions:["480p","720p","1080p"],defaultResolution:"720p",pricePerSecond:s(.473),pricePerSecondByResolution:{"480p":s(.2205),"1080p":s(1.164)},mapInput:e=>({...l(e),resolution:e.resolution,generate_audio:e.audio})},{kind:"video",id:"seedance-1.5-pro",family:"Seedance",nativeAudio:!0,label:"Seedance 1.5 Pro",provider:"fal",endpoint:"fal-ai/bytedance/seedance/v1.5/pro/image-to-video",mode:"image-to-video",description:"Cheap Seedance with native audio; good for quick drafts.",durations:p(4,12),resolutions:["480p","720p","1080p"],defaultResolution:"720p",pricePerSecond:{silent:.026,audio:.0519},pricePerSecondByResolution:{"480p":{silent:.0116,audio:.0231},"1080p":{silent:.0584,audio:.1167}},mapInput:e=>({...l(e),resolution:e.resolution,aspect_ratio:e.aspectRatio,generate_audio:e.audio,...e.seed!==void 0?{seed:e.seed}:{}})},{kind:"video",id:"seedance-1.0-pro-fast",family:"Seedance",nativeAudio:!1,label:"Seedance 1.0 Pro Fast",provider:"fal",endpoint:"fal-ai/bytedance/seedance/v1/pro/fast/image-to-video",mode:"image-to-video",description:"Very cheap silent motion \u2014 b-roll, product shots, ElevenLabs lip-sync bases.",durations:p(2,12),resolutions:["480p","720p","1080p"],defaultResolution:"720p",pricePerSecond:s(.0216),pricePerSecondByResolution:{"480p":s(.0096),"1080p":s(.0486)},mapInput:e=>({...l(e),resolution:e.resolution,aspect_ratio:e.aspectRatio,...e.seed!==void 0?{seed:e.seed}:{}})},{kind:"video",id:"kling-v3-pro",family:"Kling",nativeAudio:!0,label:"Kling 3.0 Pro",provider:"fal",endpoint:"fal-ai/kling-video/v3/pro/image-to-video",mode:"image-to-video",description:"Kling's best: strong motion and faces, native audio, 3\u201315s. Follows the first frame's shape.",durations:p(3,15),resolutions:["1080p"],defaultResolution:"1080p",pricePerSecond:{silent:.112,audio:.168},mapInput:e=>({prompt:e.prompt,start_image_url:e.imageUrl,duration:String(e.durationSec),generate_audio:e.audio,...e.negativePrompt?{negative_prompt:e.negativePrompt}:{}})},{kind:"video",id:"kling-v3-standard",family:"Kling",nativeAudio:!0,label:"Kling 3.0 Standard",provider:"fal",endpoint:"fal-ai/kling-video/v3/standard/image-to-video",mode:"image-to-video",description:"Cheaper Kling 3.0 with native audio.",durations:p(3,15),resolutions:["720p"],defaultResolution:"720p",pricePerSecond:{silent:.084,audio:.126},mapInput:e=>({prompt:e.prompt,start_image_url:e.imageUrl,duration:String(e.durationSec),generate_audio:e.audio,...e.negativePrompt?{negative_prompt:e.negativePrompt}:{}})},{kind:"video",id:"kling-v2.5-turbo-pro",family:"Kling",nativeAudio:!1,label:"Kling 2.5 Turbo Pro",provider:"fal",endpoint:"fal-ai/kling-video/v2.5-turbo/pro/image-to-video",mode:"image-to-video",description:"Fast, silent Kling with very good motion; 5 or 10s.",durations:[5,10],resolutions:["1080p"],defaultResolution:"1080p",pricePerSecond:s(.07),mapInput:e=>({...l(e),...e.negativePrompt?{negative_prompt:e.negativePrompt}:{}})},{kind:"video",id:"hailuo-2.3",family:"Hailuo",nativeAudio:!1,label:"Hailuo 2.3",provider:"fal",endpoint:"fal-ai/minimax/hailuo-2.3/standard/image-to-video",mode:"image-to-video",description:"MiniMax Hailuo: lively, physical motion; silent, 6 or 10s.",durations:[6,10],resolutions:["720p"],defaultResolution:"720p",pricePerSecond:s(.056),mapInput:e=>l(e)},{kind:"video",id:"wan-3.0",family:"Wan",nativeAudio:!0,label:"Wan 3.0",provider:"fal",endpoint:"alibaba/wan-3.0/image-to-video",mode:"image-to-video",description:"Alibaba Wan 3.0 with audio, 2\u201315s.",durations:p(2,15),resolutions:["480p","720p","1080p"],defaultResolution:"720p",pricePerSecond:s(.1),pricePerSecondByResolution:{"480p":s(.05),"1080p":s(.2)},mapInput:e=>({prompt:e.prompt,start_image_url:e.imageUrl,duration:e.durationSec,resolution:e.resolution,aspect_ratio:e.aspectRatio,audio:e.audio,...e.seed!==void 0?{seed:e.seed}:{}})},{kind:"video",id:"ltx-2.3-fast",family:"LTX",nativeAudio:!0,label:"LTX 2.3 Fast",provider:"fal",endpoint:"fal-ai/ltx-2.3/image-to-video/fast",mode:"image-to-video",description:"Lightricks LTX: cheap 1080p with audio, 6\u201320s.",durations:p(6,20,2),resolutions:["1080p"],defaultResolution:"1080p",pricePerSecond:s(.06),mapInput:e=>({prompt:e.prompt,image_url:e.imageUrl,duration:e.durationSec,resolution:e.resolution,aspect_ratio:e.aspectRatio,generate_audio:e.audio})},{kind:"video",id:"sora-2",family:"Sora",nativeAudio:!0,label:"Sora 2",provider:"fal",endpoint:"fal-ai/sora-2/image-to-video",mode:"image-to-video",description:"OpenAI Sora 2: natural scenes and dialogue; always renders audio.",durations:[4,8,12],resolutions:["720p"],defaultResolution:"720p",pricePerSecond:s(.1),mapInput:e=>({prompt:e.prompt,image_url:e.imageUrl,duration:e.durationSec,resolution:"720p",aspect_ratio:e.aspectRatio})},{kind:"video",id:"sora-2-pro",family:"Sora",nativeAudio:!0,label:"Sora 2 Pro",provider:"fal",endpoint:"fal-ai/sora-2/image-to-video/pro",mode:"image-to-video",description:"Higher-fidelity Sora 2, up to 1080p; always renders audio.",durations:[4,8,12],resolutions:["720p","1080p"],defaultResolution:"720p",pricePerSecond:s(.3),pricePerSecondByResolution:{"1080p":s(.5)},mapInput:e=>({prompt:e.prompt,image_url:e.imageUrl,duration:e.durationSec,resolution:e.resolution,aspect_ratio:e.aspectRatio})},{kind:"video",id:"kling-avatar-v2",family:"Kling",nativeAudio:!0,label:"Kling AI Avatar v2",provider:"fal",endpoint:"fal-ai/kling-video/ai-avatar/v2/standard",mode:"audio-to-video",description:"Animates a still from a voice track with lip-sync. Cheapest talking clip; the default for ElevenLabs voices.",durations:[],resolutions:["720p"],defaultResolution:"720p",pricePerSecond:{silent:.0562,audio:.0562},mapInput:e=>({image_url:e.imageUrl,audio_url:e.audioUrl,prompt:e.prompt||"."})},{kind:"video",id:"kling-avatar-v2-pro",family:"Kling",nativeAudio:!0,label:"Kling AI Avatar v2 Pro",provider:"fal",endpoint:"fal-ai/kling-video/ai-avatar/v2/pro",mode:"audio-to-video",description:"Higher-fidelity Kling avatar from a still and a voice track.",durations:[],resolutions:["720p"],defaultResolution:"720p",pricePerSecond:{silent:.115,audio:.115},mapInput:e=>({image_url:e.imageUrl,audio_url:e.audioUrl,prompt:e.prompt||"."})},{kind:"video",id:"omnihuman-v1.5",family:"OmniHuman",nativeAudio:!0,label:"OmniHuman 1.5",provider:"fal",endpoint:"fal-ai/bytedance/omnihuman/v1.5",mode:"audio-to-video",description:"ByteDance OmniHuman: expressive body and face motion that follows the voice.",durations:[],resolutions:["720p"],defaultResolution:"720p",pricePerSecond:{silent:.16,audio:.16},mapInput:e=>({image_url:e.imageUrl,audio_url:e.audioUrl,prompt:e.prompt,resolution:"720p"})},{kind:"video",id:"sync-lipsync-v2",family:"Sync",nativeAudio:!0,label:"Sync Lipsync 2",provider:"fal",endpoint:"fal-ai/sync-lipsync/v2",mode:"lipsync",description:"Re-times a video's mouth to a voice track ($3/min).",durations:[],resolutions:["720p"],defaultResolution:"720p",pricePerSecond:{silent:.05,audio:.05},mapInput:e=>({video_url:e.videoUrl,audio_url:e.audioUrl,model:"lipsync-2",sync_mode:"cut_off"})},{kind:"audio",id:"elevenlabs-tts",label:"ElevenLabs Multilingual v2",provider:"fal",endpoint:"fal-ai/elevenlabs/tts/multilingual-v2",mode:"tts",description:"Natural voiceover in a preset ElevenLabs voice, with word timings for captions.",pricePerKChars:.1,maxChars:2500,mapInput:e=>({text:e.text,voice:e.voice||F,...e.speed?{speed:e.speed}:{},timestamps:!0})},{kind:"audio",id:"lyria2",label:"Lyria 2",provider:"fal",endpoint:"fal-ai/lyria2",mode:"music",description:"Google Lyria 2: a 30-second instrumental bed from a prompt.",pricePerTrack:.1,fixedSeconds:30,mapInput:e=>({prompt:e.prompt,negative_prompt:"vocals, singing, low quality"})},{kind:"audio",id:"stable-audio-25",label:"Stable Audio 2.5",provider:"fal",endpoint:"fal-ai/stable-audio-25/text-to-audio",mode:"music",description:"Stability AI Stable Audio 2.5: music of a chosen length.",pricePerTrack:.2,maxSeconds:190,mapInput:e=>({prompt:e.prompt,seconds_total:e.seconds??30})},{kind:"image",id:"nano-banana",label:"Nano Banana",family:"Nano Banana",provider:"fal",endpoint:"fal-ai/nano-banana",description:"Google Gemini 2.5 Flash Image. Fast, cheap and strong at keeping a creator's face across shots.",maxImages:4,maxReferenceImages:0,pricePerImage:.039,mapInput:e=>({prompt:e.prompt,num_images:e.count,aspect_ratio:e.aspectRatio,output_format:"png",...e.seed!==void 0?{seed:e.seed}:{}}),edit:{endpoint:"fal-ai/nano-banana/edit",maxReferenceImages:6,mapInput:e=>({prompt:e.prompt,image_urls:e.imageUrls??[],num_images:e.count,aspect_ratio:e.aspectRatio,output_format:"png",...e.seed!==void 0?{seed:e.seed}:{}})}},{kind:"image",id:"nano-banana-2",label:"Nano Banana 2",family:"Nano Banana",provider:"fal",endpoint:"fal-ai/nano-banana-2",description:"Newer Gemini image model: sharper detail and text, still keeps faces consistent.",maxImages:4,maxReferenceImages:0,pricePerImage:.08,mapInput:e=>y(e),edit:{endpoint:"fal-ai/nano-banana-2/edit",maxReferenceImages:6,mapInput:e=>y(e,!0)}},{kind:"image",id:"nano-banana-pro",label:"Nano Banana Pro",family:"Nano Banana",provider:"fal",endpoint:"fal-ai/nano-banana-pro",description:"Gemini 3 Pro Image: the most realistic stills and the best text in the frame.",maxImages:4,maxReferenceImages:0,pricePerImage:.15,mapInput:e=>y(e),edit:{endpoint:"fal-ai/nano-banana-pro/edit",maxReferenceImages:6,mapInput:e=>y(e,!0)}},{kind:"image",id:"seedream-4.5",label:"Seedream 4.5",family:"Seedream",provider:"fal",endpoint:"fal-ai/bytedance/seedream/v4.5/text-to-image",description:"ByteDance Seedream: photoreal 2K stills, up to 10 references.",maxImages:4,maxReferenceImages:0,pricePerImage:.04,mapInput:e=>V(e),edit:{endpoint:"fal-ai/bytedance/seedream/v4.5/edit",maxReferenceImages:10,mapInput:e=>V(e,!0)}},{kind:"image",id:"nano-banana-edit",label:"Nano Banana",family:"Nano Banana",hidden:!0,provider:"fal",endpoint:"fal-ai/nano-banana/edit",description:"Consistent-character stills from reference images (persona looks, scenes, product shots).",maxImages:4,maxReferenceImages:6,pricePerImage:.039,mapInput:e=>({prompt:e.prompt,image_urls:e.imageUrls??[],num_images:e.count,aspect_ratio:e.aspectRatio,output_format:"png",...e.seed!==void 0?{seed:e.seed}:{}})}];var F="Rachel",Z=new Map(Q.map(e=>[e.id,e]));function u(e){return Z.get(e)}function q(){let e=Number(process.env.MEDIA_CREDIT_MARKUP);return Number.isFinite(e)&&e>=1?e:1.5}function h(e){return e<=0?0:Math.max(1,Math.ceil(e*100*q()))}function T(e,t){let o=[...e.durations].sort((n,i)=>n-i);return o.find(n=>n>=t)??o[o.length-1]}function ee(e,t){return t&&e.pricePerSecondByResolution?.[t]||e.pricePerSecond}function I(e,t){let o=ee(e,t.resolution)[t.audio&&e.nativeAudio?"audio":"silent"];return Math.round(o*t.durationSec*1e4)/1e4}function N(e,t){return Math.round(e.pricePerImage*t*1e4)/1e4}function f(e,t){let o;try{o=new URL(e)}catch{throw new r(`${t} must be a valid URL`,"INVALID_URL",400)}if(o.protocol!=="https:")throw new r(`${t} must be an https URL`,"INVALID_URL",400);let n=o.hostname.toLowerCase();if(n==="localhost"||n.endsWith(".local")||/^(10|127|169\.254|192\.168|172\.(1[6-9]|2\d|3[01]))\./.test(n))throw new r(`${t} must be a public URL`,"INVALID_URL",400)}function $(e){let t=u(e.modelId);if(!t||t.kind!=="video")throw new r(`Unknown video model "${e.modelId}"`,"INVALID_MODEL",400);if(t.mode==="audio-to-video"||t.mode==="lipsync")return te(t,e);let o=e.prompt?.trim();if(!o)throw new r("prompt is required","MISSING_PROMPT",400);if(o.length>4e3)throw new r("prompt is too long (max 4000 chars)","PROMPT_TOO_LONG",400);let n=e.durationSec??t.durations[t.durations.length-1];if(!t.durations.includes(n))throw new r(`${t.label} takes ${t.durations.join(", ")} second clips`,"INVALID_DURATION",400);let i=e.resolution??t.defaultResolution;if(!t.resolutions.includes(i))throw new r(`${t.label} renders ${t.resolutions.join(", ")}`,"INVALID_RESOLUTION",400);let a=e.aspectRatio??"9:16";if(a!=="9:16"&&a!=="16:9")throw new r("aspectRatio must be 9:16 or 16:9","INVALID_ASPECT_RATIO",400);if(t.mode==="image-to-video"){if(!e.imageUrl)throw new r("imageUrl is required for this model","MISSING_IMAGE",400);f(e.imageUrl,"imageUrl")}else{let g=e.referenceImageUrls??[];if(g.length<1||g.length>3)throw new r("referenceImageUrls needs 1\u20133 images","INVALID_REFERENCES",400);g.forEach((A,U)=>f(A,`referenceImageUrls[${U}]`))}let d={prompt:o,imageUrl:e.imageUrl,referenceImageUrls:e.referenceImageUrls,durationSec:n,resolution:i,aspectRatio:a,audio:t.nativeAudio?e.audio??!0:!1,negativePrompt:e.negativePrompt?.trim()||void 0,seed:e.seed},b=I(t,d);return{kind:"video",model:t,request:d,costUsd:b,credits:h(b)}}function te(e,t){if(!t.audioUrl)throw new r("audioUrl is required for this model","MISSING_AUDIO",400);if(f(t.audioUrl,"audioUrl"),e.mode==="audio-to-video"){if(!t.imageUrl)throw new r("imageUrl is required for this model","MISSING_IMAGE",400);f(t.imageUrl,"imageUrl")}else{if(!t.videoUrl)throw new r("videoUrl is required for lip-sync","MISSING_VIDEO",400);f(t.videoUrl,"videoUrl")}let o=Math.ceil(t.durationSec??8);if(!Number.isFinite(o)||o<1||o>45)throw new r(`Speech must be 1\u2013${45} seconds`,"INVALID_DURATION",400);let n={prompt:(t.prompt??"").trim().slice(0,2e3),imageUrl:t.imageUrl,audioUrl:t.audioUrl,videoUrl:t.videoUrl,durationSec:o,resolution:e.defaultResolution,aspectRatio:t.aspectRatio==="16:9"?"16:9":"9:16",audio:!0},i=I(e,{durationSec:o,audio:!0});return{kind:"video",model:e,request:n,costUsd:i,credits:h(i)}}function oe(e,t){if(!t||!e.edit)return e;let{edit:o}=e;return{...e,endpoint:o.endpoint,maxReferenceImages:o.maxReferenceImages,pricePerImage:o.pricePerImage??e.pricePerImage,mapInput:o.mapInput}}function G(e){let t=u(e.modelId);if(!t||t.kind!=="image")throw new r(`Unknown image model "${e.modelId}"`,"INVALID_MODEL",400);if((e.imageUrls?.length??0)>0&&!t.edit&&t.maxReferenceImages===0)throw new r(`${t.label} can't use reference photos \u2014 pick a model that edits from references`,"INVALID_REFERENCES",400);let o=oe(t,(e.imageUrls?.length??0)>0),n=e.prompt?.trim();if(!n)throw new r("prompt is required","MISSING_PROMPT",400);let i=e.count??1;if(!Number.isInteger(i)||i<1||i>o.maxImages)throw new r(`count must be 1\u2013${o.maxImages}`,"INVALID_COUNT",400);let a=e.imageUrls??[];if(o.maxReferenceImages>0&&a.length<1)throw new r("imageUrls needs at least one reference image","MISSING_IMAGE",400);if(a.length>o.maxReferenceImages)throw new r(`imageUrls allows up to ${o.maxReferenceImages} images`,"INVALID_REFERENCES",400);a.forEach((A,U)=>f(A,`imageUrls[${U}]`));let d=e.aspectRatio??"9:16";if(!["9:16","16:9","1:1","4:5"].includes(d))throw new r("aspectRatio must be 9:16, 16:9, 1:1 or 4:5","INVALID_ASPECT_RATIO",400);let b={prompt:n,imageUrls:a,count:i,aspectRatio:d,seed:e.seed},g=N(o,i);return{kind:"image",model:o,request:b,costUsd:g,credits:h(g)}}var ne="https://queue.fal.run",m=class extends Error{constructor(o,n,i){super(o);this.status=n;this.retryable=i;this.name="FalError"}status;retryable};function ie(e){let t=(typeof e=="function"?e():e)?.trim();if(!t)throw new m("fal API key is not configured",500,!1);return t}async function w(e,t,o={}){let n=await fetch(t,{...o,headers:{Authorization:`Key ${ie(e)}`,"Content-Type":"application/json",...o.headers??{}},signal:AbortSignal.timeout(3e4)}),i=await n.text(),a=null;try{a=i?JSON.parse(i):null}catch{a=i}if(!n.ok){let d=(a&&typeof a=="object"&&"detail"in a?JSON.stringify(a.detail):typeof a=="string"?a:JSON.stringify(a))??"";throw new m(`fal ${n.status}: ${d.slice(0,300)}`,n.status,n.status===429||n.status>=500)}return a}async function re(e,t,o){let n=await w(e,`${ne}/${t}`,{method:"POST",body:JSON.stringify(o)});if(!n?.request_id||!n.status_url||!n.response_url)throw new m("fal submit returned no request id",502,!0);return{requestId:n.request_id,statusUrl:n.status_url,responseUrl:n.response_url,cancelUrl:n.cancel_url}}async function ae(e,t){let o=await w(e,t);if(o.error)return{state:"failed",error:`${o.error_type??"error"}: ${o.error}`};switch(o.status){case"IN_QUEUE":return{state:"queued",position:o.queue_position};case"IN_PROGRESS":return{state:"running"};case"COMPLETED":return{state:"completed"};default:return{state:"running"}}}async function se(e,t){return w(e,t)}var de="https://rest.fal.ai/storage/upload/initiate?storage_type=fal-cdn-v3";async function ce(e,t){let o=await w(e,de,{method:"POST",body:JSON.stringify({content_type:t.contentType,file_name:t.fileName})});if(!o?.upload_url||!o.file_url)throw new m("fal storage returned no upload URL",502,!0);let n=await fetch(o.upload_url,{method:"PUT",headers:{"Content-Type":t.contentType||"application/octet-stream"},body:new Blob([t.data],{type:t.contentType||"application/octet-stream"}),signal:AbortSignal.timeout(12e4)});if(!n.ok)throw new m(`fal storage upload failed (${n.status})`,n.status,n.status===429||n.status>=500);return o.file_url}function pe(e){return{submit:(t,o)=>re(e,t,o),status:t=>ae(e,t),result:t=>se(e,t),upload:t=>ce(e,t)}}function le(e){let t=[],o=e??{},n=o.video;n?.url&&t.push({url:n.url,contentType:n.content_type});let i=o.audio;i?.url&&t.push({url:i.url,contentType:i.content_type});let a=Array.isArray(o.images)?o.images:[];for(let d of a)d?.url&&t.push({url:d.url,contentType:d.content_type});return t}var ue=["ageAppearance","face","eyes","brows","skin","hair","bodyBuild"],me=["device","lens","distance","height","processing"],ge={device:"front-facing smartphone camera",lens:"24-28mm equivalent",distance:"arm's length",height:"slightly above eye level",processing:"subtle HDR, mild phone sharpening, no beauty filter"},fe=["face","hair","signature details","skin texture"],he=300,Se=12,c=(e,t=he)=>typeof e=="string"?e.replace(/\s+/g," ").trim().slice(0,t):"";function k(e,t=Se,o=120){return Array.isArray(e)?[...new Set(e.map(n=>c(n,o)).filter(Boolean))].slice(0,t):[]}var be={ageAppearance:"age_appearance",bodyBuild:"body_build"};function P(e){let t={};for(let i of ue){let a=c(e.identity?.[i]);a&&(t[be[i]??i]=a)}let o=c(e.appearance,600);o&&Object.keys(t).length===0&&(t.appearance=o);let n={};for(let i of me)n[i]=c(e.cameraHabits?.[i])||ge[i];return{creator:c(e.name,60)||"the creator",identity_lock:t,signature_details:k(e.signatureDetails),imperfections:k(e.imperfections),camera_habits:n,never_change:e.neverChange?.length?k(e.neverChange):fe}}function B(e){let t={},o=e.voiceProfile??{};return c(o.tone)&&(t.tone=c(o.tone)),c(o.accent)&&(t.accent=c(o.accent)),o.verbalHabits?.length&&(t.verbal_habits=o.verbalHabits),c(e.personality)&&(t.personality=c(e.personality)),t}var ve=["different face","shorter hair","missing jewelry","plastic skin","studio lighting","perfectly tidy room","extra fingers","warped label","text","watermark","UI overlays"],ye=["morphing face","melting hands","label changing between frames","camera cuts","slow motion"];function Ie(e){return(e?.signature_details??[]).map(t=>`missing ${t}`)}function S(e,t={}){return[...new Set([...ve,...t.video?ye:[],...Ie(e)])]}var xe=["identity from the creator lock (face, hair, signature details)","same camera habits","product and app screen readable","requested wardrobe only","requested location only"],we={style:"authentic user-generated content filmed on a phone",framing:"handheld, slightly imperfect framing, vertical 9:16",light:"natural light, casual real-life setting",avoid:"studio look, cinematic grading, text or logos on screen"},E={image:3800,video:3800,avatar:1200};function ke(e){let t={task:e.task};return e.creator&&(t.priority_order=xe,t.creator=e.creator),e.shot&&Object.keys(e.shot).length&&(t.shot=e.shot),e.direction&&(t.direction=e.direction),e.dialogue&&(t.dialogue=e.dialogue),e.delivery&&Object.keys(e.delivery).length&&(t.delivery=e.delivery),e.notes?.length&&(t.notes=e.notes),t.look=we,t.negative_prompt=e.negatives,t}function _(e,t){return typeof e=="string"?e.length>t?`${e.slice(0,t-1)}\u2026`:e:Array.isArray(e)?e.map(o=>_(o,t)):e&&typeof e=="object"?Object.fromEntries(Object.entries(e).map(([o,n])=>[o,_(n,t)])):e}function O(e,t){let o=ke(e),n=JSON.stringify(o);for(let a of[400,240,160,100]){if(n.length<=t)return n;let d=_(o,a);e.dialogue&&(d.dialogue=e.dialogue),n=JSON.stringify(d)}if(n.length<=t)return n;let i={...o,look:void 0,delivery:void 0};return e.creator&&(i.creator={...e.creator,imperfections:void 0,camera_habits:void 0}),JSON.stringify(_(i,100)).slice(0,t)}var Pe=(e,t=300)=>typeof e=="string"?e.replace(/\s+/g," ").trim().slice(0,t):"";function L(e,t){let o={},n=e.wardrobe||Pe(t);if(n&&(o.wardrobe=n),e.location&&(o.location=e.location),e.action&&(o.action=e.action),e.productPlacement){let i=e.productPlacement;o.product_placement={...i.visible!==void 0?{visible:i.visible}:{},...i.placement?{placement:i.placement}:{},...i.sizeInFrame?{size_in_frame:i.sizeInFrame}:{}}}return e.motionBeats?.length&&(o.motion=e.motionBeats),o}var j=e=>e?P(e):null;function J({persona:e,shot:t,direction:o}){let n=j(e);return O({task:n?`A photo of ${n.creator}: the same person as in the reference images, face, hair and build identical.`:"A candid phone photo.",creator:n,shot:L(t,e?.style?.wardrobe),direction:o||(t.action?"":"A candid everyday moment at home."),negatives:S(n)},E.image)}function K(e){let t=j(e.persona),o=e.script.replace(/\s+/g," ").trim();return O({task:o?`${t?.creator??"The person"} talks straight to the phone camera and says the dialogue line.`:`${t?.creator??"The person"} on camera, no dialogue.`,creator:t,shot:L(e.shot,e.persona?.style?.wardrobe),direction:e.direction,dialogue:o,delivery:e.persona?B(e.persona):void 0,notes:["Only describe what moves; the start frame fixes the face, room and light.","Subtle handheld shake, no background music, no subtitles."],negatives:S(t,{video:!0})},E.video)}var Ue="https://placeholder.appeeky.com/frame.png";var io=675;export{C as MAX_SCRIPT_CHARS,Ue as PLACEHOLDER_IMAGE,P as buildCreatorLock,pe as createFalClient,le as extractOutputUrls,T as fitDuration,u as getMediaModel,S as negativesFor,G as prepareImageJob,$ as prepareVideoJob,J as stillPrompt,K as talkingVideoPrompt};
@@ -0,0 +1,116 @@
1
+ import type { AppeekyApi } from "./appeeky-api.js";
2
+ import type { Brief } from "./ugc.js";
3
+ /**
4
+ * Clips made on this machine with the user's own fal key (FAL_KEY), the same pipeline as Appeeky's
5
+ * hosted UGC and the desktop app's local mode: the persona's look → a still in the scene → a talking
6
+ * video with the script in the persona's voice. Planning, prompts and prices come from Appeeky's
7
+ * UGC core, bundled into dist/ugc-core.js and loaded only when this runs; fal bills the user directly.
8
+ *
9
+ * Personas come from either:
10
+ * appeeky/ugc/personas/<name>.yaml a persona kept in the repo, `looks:` pointing at its images
11
+ * Appeeky UGC a persona made in Appeeky (APPEEKY_API_KEY), its cover as the look
12
+ */
13
+ export declare const localPersonasDir: (root: string) => string;
14
+ export declare const mediaDir: (root: string) => string;
15
+ /** The UgcPersona fields the prompts read; everything else stays at its default. */
16
+ export interface LocalPersona {
17
+ id: string;
18
+ name: string;
19
+ appearance?: string;
20
+ personality?: string;
21
+ bio?: string;
22
+ voice?: string;
23
+ lane?: string;
24
+ identity?: Record<string, unknown>;
25
+ signatureDetails?: string[];
26
+ imperfections?: string[];
27
+ cameraHabits?: Record<string, unknown>;
28
+ voiceProfile?: Record<string, unknown>;
29
+ neverChange?: string[];
30
+ style?: Record<string, unknown>;
31
+ /** Image files (relative to the persona file) or URLs that show the persona. */
32
+ looks: string[];
33
+ }
34
+ interface PreparedJob {
35
+ costUsd: number;
36
+ model: {
37
+ endpoint: string;
38
+ mapInput(request: unknown): Record<string, unknown>;
39
+ };
40
+ request: unknown;
41
+ }
42
+ interface Core {
43
+ MAX_SCRIPT_CHARS: number;
44
+ getMediaModel(id: string): {
45
+ id: string;
46
+ kind: string;
47
+ mode: string;
48
+ label: string;
49
+ nativeAudio?: boolean;
50
+ } | undefined;
51
+ fitDuration(model: unknown, seconds: number): number;
52
+ prepareImageJob(input: Record<string, unknown>): PreparedJob;
53
+ prepareVideoJob(input: Record<string, unknown>): PreparedJob;
54
+ createFalClient(key: string): {
55
+ submit(endpoint: string, input: Record<string, unknown>): Promise<{
56
+ statusUrl: string;
57
+ responseUrl: string;
58
+ }>;
59
+ status(url: string): Promise<{
60
+ state: string;
61
+ error?: string;
62
+ }>;
63
+ result(url: string): Promise<unknown>;
64
+ upload(file: {
65
+ data: Uint8Array;
66
+ contentType: string;
67
+ fileName: string;
68
+ }): Promise<string>;
69
+ };
70
+ extractOutputUrls(result: unknown): Array<{
71
+ url: string;
72
+ contentType?: string;
73
+ }>;
74
+ buildCreatorLock(persona: unknown): unknown;
75
+ negativesFor(lock: unknown, opts: {
76
+ video: boolean;
77
+ }): string[];
78
+ stillPrompt(input: Record<string, unknown>): string;
79
+ talkingVideoPrompt(input: Record<string, unknown>): string;
80
+ PLACEHOLDER_IMAGE: string;
81
+ }
82
+ /** The UGC pipeline ships inside Spec as one minified file (dist/ugc-core.js, built by scripts/vendor-ugc-core.mjs). */
83
+ export declare const CORE_FILE = "./ugc-core.js";
84
+ export declare function loadCore(load?: (specifier: string) => Promise<Record<string, unknown>>): Promise<Core>;
85
+ /** A persona kept in the repo (appeeky/ugc/personas/<name>.yaml), with its looks resolved to paths or URLs. */
86
+ export declare function readLocalPersona(root: string, name: string): Promise<LocalPersona | undefined>;
87
+ /** A persona made in Appeeky UGC: its fields, with the cover image as its look. */
88
+ export declare function readAppeekyPersona(api: AppeekyApi, name: string): Promise<LocalPersona | undefined>;
89
+ interface Plan {
90
+ still: PreparedJob;
91
+ video: (stillUrl: string) => PreparedJob;
92
+ costUsd: number;
93
+ clip: {
94
+ durationSec: number;
95
+ aspectRatio: string;
96
+ videoModelId: string;
97
+ };
98
+ }
99
+ /** The same two steps as the hosted clip render: a still of the persona in the scene, then a talking video. */
100
+ export declare function planLocalClip(core: Core, persona: LocalPersona, brief: Brief, refs: string[]): Plan;
101
+ export interface LocalClipResult {
102
+ file: string;
103
+ videoUrl: string;
104
+ stillUrl: string;
105
+ costUsd: number;
106
+ }
107
+ /** Make the clip with the user's fal key, save it under .appeeky/media/ and record it in the brief. */
108
+ export declare function generateLocal(core: Core, root: string, brief: Brief & {
109
+ file: string;
110
+ }, persona: LocalPersona, falKey: string, options?: {
111
+ fetcher?: typeof fetch;
112
+ sleep?: (ms: number) => Promise<void>;
113
+ now?: Date;
114
+ log?: (line: string) => void;
115
+ }): Promise<LocalClipResult>;
116
+ export {};
@@ -0,0 +1,174 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, extname, isAbsolute, join, relative } from "node:path";
4
+ import YAML from "yaml";
5
+ import { SPEC_DIR } from "./files.js";
6
+ /**
7
+ * Clips made on this machine with the user's own fal key (FAL_KEY), the same pipeline as Appeeky's
8
+ * hosted UGC and the desktop app's local mode: the persona's look → a still in the scene → a talking
9
+ * video with the script in the persona's voice. Planning, prompts and prices come from Appeeky's
10
+ * UGC core, bundled into dist/ugc-core.js and loaded only when this runs; fal bills the user directly.
11
+ *
12
+ * Personas come from either:
13
+ * appeeky/ugc/personas/<name>.yaml a persona kept in the repo, `looks:` pointing at its images
14
+ * Appeeky UGC a persona made in Appeeky (APPEEKY_API_KEY), its cover as the look
15
+ */
16
+ export const localPersonasDir = (root) => join(root, SPEC_DIR, "ugc", "personas");
17
+ export const mediaDir = (root) => join(root, ".appeeky", "media");
18
+ /** The UGC pipeline ships inside Spec as one minified file (dist/ugc-core.js, built by scripts/vendor-ugc-core.mjs). */
19
+ export const CORE_FILE = "./ugc-core.js";
20
+ export async function loadCore(load = (s) => import(s)) {
21
+ try {
22
+ return (await load(CORE_FILE));
23
+ }
24
+ catch {
25
+ throw new Error("This build of @appeeky/spec has no UGC pipeline (dist/ugc-core.js). Update it: npm install -g @appeeky/spec@latest");
26
+ }
27
+ }
28
+ function asUgcPersona(p) {
29
+ return {
30
+ ownerEmail: "",
31
+ appId: null,
32
+ status: "active",
33
+ bio: "",
34
+ story: "",
35
+ personality: "",
36
+ appearance: "",
37
+ voice: "",
38
+ voiceId: null,
39
+ style: {},
40
+ identity: {},
41
+ signatureDetails: [],
42
+ imperfections: [],
43
+ cameraHabits: {},
44
+ voiceProfile: {},
45
+ neverChange: [],
46
+ lane: "",
47
+ formats: [],
48
+ contentPillars: [],
49
+ platforms: [],
50
+ referenceAssetIds: p.looks,
51
+ folderId: null,
52
+ createdAt: "",
53
+ updatedAt: "",
54
+ ...p,
55
+ };
56
+ }
57
+ /** A persona kept in the repo (appeeky/ugc/personas/<name>.yaml), with its looks resolved to paths or URLs. */
58
+ export async function readLocalPersona(root, name) {
59
+ const dir = localPersonasDir(root);
60
+ if (!existsSync(dir))
61
+ return undefined;
62
+ const slug = (n) => n.toLowerCase().replace(/[^a-z0-9]+/g, "-");
63
+ const hit = (await readdir(dir)).find((f) => f.endsWith(".yaml") && slug(basename(f, ".yaml")) === slug(name));
64
+ if (!hit)
65
+ return undefined;
66
+ const file = join(dir, hit);
67
+ const raw = YAML.parse(await readFile(file, "utf8"));
68
+ if (!raw?.looks?.length)
69
+ throw new Error(`${relative(root, file)} needs looks: the persona's images (paths next to the file, or URLs).`);
70
+ return {
71
+ ...raw,
72
+ id: raw.id ?? basename(file, ".yaml"),
73
+ name: raw.name ?? basename(file, ".yaml"),
74
+ looks: raw.looks.map((l) => (/^https?:\/\//.test(l) || isAbsolute(l) ? l : join(dirname(file), l))),
75
+ };
76
+ }
77
+ /** A persona made in Appeeky UGC: its fields, with the cover image as its look. */
78
+ export async function readAppeekyPersona(api, name) {
79
+ const res = await api.get("/ugc/personas");
80
+ const p = res?.personas?.find((x) => x.id === name || x.name.toLowerCase() === name.toLowerCase());
81
+ if (!p)
82
+ return undefined;
83
+ if (!p.coverUrl)
84
+ throw new Error(`${p.name} has no look in Appeeky yet: pick one in Appeeky UGC, or keep the persona in appeeky/ugc/personas/ with its images.`);
85
+ const { coverUrl, referenceAssetIds: _ids, ...rest } = p;
86
+ return { ...rest, looks: [coverUrl] };
87
+ }
88
+ /** The same two steps as the hosted clip render: a still of the persona in the scene, then a talking video. */
89
+ export function planLocalClip(core, persona, brief, refs) {
90
+ const script = brief.script.trim();
91
+ if (script.length > core.MAX_SCRIPT_CHARS)
92
+ throw new Error(`The script is too long for one clip (max ${core.MAX_SCRIPT_CHARS} characters, about 8 seconds of speech).`);
93
+ const model = core.getMediaModel("veo-3.1-fast");
94
+ if (!model)
95
+ throw new Error("The video model veo-3.1-fast is missing from @appeeky/ugc-core: update it.");
96
+ const durationSec = core.fitDuration(model, brief.durationSec ?? 8);
97
+ const aspectRatio = brief.aspectRatio ?? "9:16";
98
+ const ugcPersona = asUgcPersona(persona);
99
+ const negativePrompt = core.negativesFor(core.buildCreatorLock(ugcPersona), { video: true }).join(", ");
100
+ const scene = (brief.scene ?? "").trim();
101
+ const still = core.prepareImageJob({
102
+ ownerEmail: "",
103
+ modelId: "nano-banana",
104
+ prompt: core.stillPrompt({ persona: ugcPersona, shot: {}, direction: scene || "talking to the camera at home" }),
105
+ imageUrls: refs,
106
+ count: 1,
107
+ aspectRatio,
108
+ });
109
+ const video = (stillUrl) => core.prepareVideoJob({
110
+ ownerEmail: "",
111
+ modelId: model.id,
112
+ prompt: core.talkingVideoPrompt({ persona: ugcPersona, shot: {}, direction: scene, script }),
113
+ imageUrl: stillUrl,
114
+ durationSec,
115
+ aspectRatio,
116
+ audio: true,
117
+ negativePrompt,
118
+ });
119
+ const costUsd = Math.round((still.costUsd + video(core.PLACEHOLDER_IMAGE).costUsd) * 10_000) / 10_000;
120
+ return { still, video, costUsd, clip: { durationSec, aspectRatio, videoModelId: model.id } };
121
+ }
122
+ const CONTENT_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
123
+ /** Local look images go to fal storage first, so the models can fetch them. */
124
+ async function referenceUrls(fal, looks) {
125
+ const urls = [];
126
+ for (const look of looks.slice(0, 6)) {
127
+ if (/^https?:\/\//.test(look))
128
+ urls.push(look);
129
+ else
130
+ urls.push(await fal.upload({ data: await readFile(look), contentType: CONTENT_TYPES[extname(look).toLowerCase()] ?? "image/png", fileName: basename(look) }));
131
+ }
132
+ return urls;
133
+ }
134
+ async function runJob(core, fal, job, sleep, timeoutMs = 15 * 60_000) {
135
+ const submission = await fal.submit(job.model.endpoint, job.model.mapInput(job.request));
136
+ const started = Date.now();
137
+ for (;;) {
138
+ const s = await fal.status(submission.statusUrl);
139
+ if (s.state === "completed")
140
+ break;
141
+ if (s.state === "failed")
142
+ throw new Error(`fal: ${s.error}`);
143
+ if (Date.now() - started > timeoutMs)
144
+ throw new Error("fal: timed out after 15 minutes");
145
+ await sleep(4000);
146
+ }
147
+ const out = core.extractOutputUrls(await fal.result(submission.responseUrl));
148
+ if (!out[0])
149
+ throw new Error("fal returned no media");
150
+ return out[0].url;
151
+ }
152
+ /** Make the clip with the user's fal key, save it under .appeeky/media/ and record it in the brief. */
153
+ export async function generateLocal(core, root, brief, persona, falKey, options = {}) {
154
+ const fal = core.createFalClient(falKey);
155
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
156
+ const log = options.log ?? (() => { });
157
+ const refs = await referenceUrls(fal, persona.looks);
158
+ const plan = planLocalClip(core, persona, brief, refs);
159
+ log("still: the persona in the scene");
160
+ const stillUrl = await runJob(core, fal, plan.still, sleep);
161
+ log("video: the persona says the script");
162
+ const videoUrl = await runJob(core, fal, plan.video(stillUrl), sleep);
163
+ const res = await (options.fetcher ?? fetch)(videoUrl);
164
+ if (!res.ok)
165
+ throw new Error(`Could not download the clip: HTTP ${res.status}`);
166
+ await mkdir(mediaDir(root), { recursive: true });
167
+ const file = join(mediaDir(root), `${basename(brief.file, ".yaml")}.mp4`);
168
+ await writeFile(file, new Uint8Array(await res.arrayBuffer()));
169
+ const { file: briefFile, ...rest } = brief;
170
+ const record = { ...rest, status: "generated", clipFile: relative(root, file), videoUrl, costUsd: plan.costUsd, generatedAt: (options.now ?? new Date()).toISOString() };
171
+ await writeFile(briefFile, YAML.stringify(record, { lineWidth: 0 }));
172
+ return { file, videoUrl, stillUrl, costUsd: plan.costUsd };
173
+ }
174
+ //# sourceMappingURL=ugc-local.js.map