@mevdragon/vidfarm-devcli 0.16.0 → 0.17.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 (33) hide show
  1. package/.agents/skills/farmville-saas-ux/SKILL.md +156 -0
  2. package/.agents/skills/farmville-saas-ux/assets/starter.html +294 -0
  3. package/.agents/skills/farmville-saas-ux/references/components.md +340 -0
  4. package/.agents/skills/farmville-saas-ux/references/porting-guide.md +121 -0
  5. package/.agents/skills/farmville-saas-ux/references/tokens.md +271 -0
  6. package/.agents/skills/vidfarm-media/SKILL.md +2 -1
  7. package/.agents/skills/vidfarm-media/references/tts.md +20 -1
  8. package/dist/src/account-pages-legacy.js +2 -2
  9. package/dist/src/app.js +441 -77
  10. package/dist/src/editor-chat.js +2 -2
  11. package/dist/src/frontend/homepage-client.js +162 -2
  12. package/dist/src/frontend/homepage-store.js +30 -1
  13. package/dist/src/frontend/homepage-view.js +111 -4
  14. package/dist/src/homepage.js +184 -1
  15. package/dist/src/landing-page.js +367 -0
  16. package/dist/src/primitive-registry.js +278 -2
  17. package/dist/src/reskin/agency-page.js +255 -0
  18. package/dist/src/reskin/calendar-page.js +252 -0
  19. package/dist/src/reskin/chat-page.js +414 -0
  20. package/dist/src/reskin/discover-page.js +380 -0
  21. package/dist/src/reskin/document.js +74 -0
  22. package/dist/src/reskin/help-page.js +318 -0
  23. package/dist/src/reskin/index-page.js +62 -0
  24. package/dist/src/reskin/job-runs-page.js +249 -0
  25. package/dist/src/reskin/library-page.js +515 -0
  26. package/dist/src/reskin/login-page.js +225 -0
  27. package/dist/src/reskin/pricing-page.js +359 -0
  28. package/dist/src/reskin/settings-page.js +353 -0
  29. package/dist/src/reskin/theme.js +265 -0
  30. package/dist/src/services/serverless-records.js +54 -0
  31. package/dist/src/services/swipe-customize.js +434 -0
  32. package/package.json +1 -1
  33. package/public/assets/homepage-client-app.js +22 -22
@@ -7,7 +7,7 @@ import { config } from "./config.js";
7
7
  import { definePrimitive } from "./primitive-sdk.js";
8
8
  import { PrimitiveMediaLambdaService } from "./services/primitive-media-lambda.js";
9
9
  import { estimateGhostcutCostUsd, isGhostcutConfigured, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
10
- import { buildSrtFromSegments, defaultSpeechModelFor, inferSpeechProviderForVoice, speechVoiceMismatch } from "./services/speech.js";
10
+ import { buildSrtFromSegments, defaultSpeechModelFor, GEMINI_TTS_VOICES, inferSpeechProviderForVoice, OPENAI_TTS_VOICES, speechVoiceFamilyFor, speechVoiceMismatch } from "./services/speech.js";
11
11
  import { buildCaptionCues, cuesFromText } from "./services/captions.js";
12
12
  import { CAPTION_ANIMATIONS, CAPTION_STYLE_PRESETS, captionStylePresetById } from "./hyperframes/composition.js";
13
13
  import { buildHtmlImageCompositionHtml, buildMediaDedupeCompositionHtml, DEDUPE_DEFAULT_EFFECTS } from "./primitives/hyperframes-media.js";
@@ -460,6 +460,54 @@ const captionsPayloadSchema = z.preprocess((raw) => {
460
460
  }).refine((payload) => Boolean(payload.source_url || payload.text), {
461
461
  message: "Provide source_url (audio/video to transcribe) or text (script to page)."
462
462
  }));
463
+ // Regenerate speech in (approximately) the original speaker's voice: listen to
464
+ // the source, transcribe + profile the voice, reword the script per the
465
+ // caller's instruction (or take an exact new script), then TTS with the
466
+ // closest preset voice + a style prompt matched to the speaker. OpenAI/Gemini
467
+ // APIs cannot clone voice IDENTITY (preset voices only), so the result is an
468
+ // honest approximation, never a clone — the output says so via voice_match.
469
+ const speechRegeneratePayloadSchema = z.preprocess((raw) => {
470
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
471
+ return raw;
472
+ const payload = raw;
473
+ return {
474
+ ...payload,
475
+ source_url: payload.source_url ??
476
+ payload.source_video_url ??
477
+ payload.source_audio_url ??
478
+ payload.source_media_url ??
479
+ payload.video_url ??
480
+ payload.audio_url ??
481
+ payload.url,
482
+ text: payload.text ?? payload.script ?? payload.new_text,
483
+ rewrite_instruction: payload.rewrite_instruction ?? payload.instruction ?? payload.rewrite ?? payload.reword,
484
+ instructions: payload.instructions ?? payload.style ?? payload.style_instructions ?? payload.voice_style
485
+ };
486
+ }, z.object({
487
+ provider: speechProviderSchema.optional(),
488
+ model: z.string().min(1).optional(),
489
+ source_url: mediaSourceUrlSchema,
490
+ media_type: z.enum(["auto", "video", "audio"]).default("auto"),
491
+ /** Exact new script to speak. Mutually optional with rewrite_instruction; one is required. */
492
+ text: z.string().min(1).max(8000).optional(),
493
+ /** How to reword the original transcript (e.g. "mention the summer sale instead of the discount code"). */
494
+ rewrite_instruction: z.string().min(1).max(4000).optional(),
495
+ /** Explicit preset override; skips the profiled voice recommendation. */
496
+ voice: z.string().min(1).max(120).optional(),
497
+ /** Extra voice-style guidance appended to the profiled style instructions. */
498
+ instructions: z.string().min(1).max(4000).optional(),
499
+ language: z.string().min(2).max(16).optional(),
500
+ output_format: z.enum(["mp3", "wav"]).default("mp3")
501
+ }).refine((value) => Boolean(value.text || value.rewrite_instruction), {
502
+ message: "Provide text (the exact new script) or rewrite_instruction (how to reword the original transcript)."
503
+ }).superRefine((value, ctx) => {
504
+ if (!value.provider || !value.voice)
505
+ return;
506
+ const mismatch = speechVoiceMismatch({ provider: value.provider, model: value.model, voice: value.voice });
507
+ if (mismatch) {
508
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["voice"], message: mismatch });
509
+ }
510
+ }));
463
511
  const probeVideoPayloadSchema = z.object({
464
512
  source_video_url: mediaSourceUrlSchema
465
513
  });
@@ -613,6 +661,7 @@ const PRIMITIVE_MEDIA_DEDUPE_ID = "primitive:media_dedupe";
613
661
  const PRIMITIVE_TTS_ID = "primitive:tts";
614
662
  const PRIMITIVE_STT_ID = "primitive:stt";
615
663
  const PRIMITIVE_CAPTIONS_ID = "primitive:captions";
664
+ const PRIMITIVE_SPEECH_REGENERATE_ID = "primitive:speech_regenerate";
616
665
  // Gemini inline audio caps out around 20MB; at the 64kbps mono mp3 we extract,
617
666
  // that is roughly 40 minutes of speech.
618
667
  const MAX_TRANSCRIBE_AUDIO_BYTES = 20 * 1024 * 1024;
@@ -1746,6 +1795,7 @@ async function loadSpeechAudioForPrimitive(ctx, payload) {
1746
1795
  const sourceKind = payload.media_type === "auto" ? inferSpeechSourceKind(payload.source_url) : payload.media_type;
1747
1796
  let audioBytes;
1748
1797
  let audioContentType;
1798
+ let audioUrl;
1749
1799
  let extractionMetadata = null;
1750
1800
  if (sourceKind === "audio") {
1751
1801
  ctx.logger.progress(0.1, "Fetching source audio");
@@ -1755,6 +1805,7 @@ async function loadSpeechAudioForPrimitive(ctx, payload) {
1755
1805
  }
1756
1806
  audioBytes = new Uint8Array(await response.arrayBuffer());
1757
1807
  audioContentType = response.headers.get("content-type")?.split(";")[0]?.trim() || "audio/mpeg";
1808
+ audioUrl = payload.source_url;
1758
1809
  }
1759
1810
  else {
1760
1811
  ctx.logger.progress(0.1, "Extracting audio track from source", {
@@ -1778,6 +1829,7 @@ async function loadSpeechAudioForPrimitive(ctx, payload) {
1778
1829
  }
1779
1830
  audioBytes = new Uint8Array(await response.arrayBuffer());
1780
1831
  audioContentType = extracted.contentType || "audio/mpeg";
1832
+ audioUrl = extracted.publicUrl;
1781
1833
  extractionMetadata = extracted.metadata ?? null;
1782
1834
  }
1783
1835
  if (audioBytes.byteLength > MAX_TRANSCRIBE_AUDIO_BYTES) {
@@ -1785,7 +1837,7 @@ async function loadSpeechAudioForPrimitive(ctx, payload) {
1785
1837
  const maxMb = Math.round(MAX_TRANSCRIBE_AUDIO_BYTES / (1024 * 1024));
1786
1838
  throw new Error(`Audio is too large to transcribe (${sizeMb}MB > ${maxMb}MB, roughly 40 minutes of speech). Trim the source with /videos/trim or /audio/trim and transcribe in parts.`);
1787
1839
  }
1788
- return { audioBytes, audioContentType, extractionMetadata };
1840
+ return { audioBytes, audioContentType, audioUrl, extractionMetadata };
1789
1841
  }
1790
1842
  const sttPrimitive = definePrimitive({
1791
1843
  id: PRIMITIVE_STT_ID,
@@ -1991,6 +2043,229 @@ const captionsPrimitive = definePrimitive({
1991
2043
  }
1992
2044
  }
1993
2045
  });
2046
+ // Voice-casting hints for the profiling prompt: the exact preset names each
2047
+ // provider accepts (imported lists are the guard), annotated so an audio-native
2048
+ // model can pick the closest match to what it hears.
2049
+ const OPENAI_VOICE_CASTING_HINTS = "alloy (neutral, balanced), ash (warm, mature male), ballad (calm British male), cedar (deep, steady male), coral (warm, friendly female), echo (confident male), fable (bright British, androgynous), marin (clear, upbeat female), nova (energetic young female), onyx (deep, authoritative male), sage (soft, gentle female), shimmer (crisp, expressive female), verse (versatile, expressive male)";
2050
+ const GEMINI_VOICE_CASTING_HINTS = "Zephyr (bright female), Puck (upbeat male), Charon (informative male), Kore (firm female), Fenrir (excitable male), Leda (youthful female), Orus (firm male), Aoede (breezy female), Callirrhoe (easy-going female), Autonoe (bright female), Enceladus (breathy male), Iapetus (clear male), Umbriel (easy-going male), Algieba (smooth male), Despina (smooth female), Erinome (clear female), Algenib (gravelly male), Rasalgethi (informative male), Laomedeia (upbeat female), Achernar (soft female), Alnilam (firm male), Schedar (even male), Gacrux (mature female), Pulcherrima (forward female), Achird (friendly male), Zubenelgenubi (casual male), Vindemiatrix (gentle female), Sadachbia (lively male), Sadaltager (knowledgeable male), Sulafat (warm female)";
2051
+ // Loose on purpose: a shape miss must degrade to transcript-only, never fail
2052
+ // the whole regeneration job.
2053
+ const speechVoiceProfileResultSchema = z.object({
2054
+ transcript: z.string().optional().default(""),
2055
+ language: z.string().nullish(),
2056
+ voice_profile: z.object({
2057
+ gender: z.string().nullish(),
2058
+ age_range: z.string().nullish(),
2059
+ accent: z.string().nullish(),
2060
+ pace: z.string().nullish(),
2061
+ pitch: z.string().nullish(),
2062
+ energy: z.string().nullish(),
2063
+ tone: z.string().nullish()
2064
+ }).nullish(),
2065
+ style_instructions: z.string().nullish(),
2066
+ recommended_voice: z.object({
2067
+ openai: z.string().nullish(),
2068
+ gemini: z.string().nullish()
2069
+ }).nullish()
2070
+ });
2071
+ const speechRewriteResultSchema = z.object({
2072
+ script: z.string().min(1)
2073
+ });
2074
+ function buildVoiceProfilePrompt(language) {
2075
+ return [
2076
+ "You are a professional voice-casting director. Listen to the attached audio.",
2077
+ "Profile the dominant speaker and transcribe their words verbatim (if several people speak, profile only the dominant speaker and transcribe only their words).",
2078
+ "Return STRICT JSON ONLY with this exact shape:",
2079
+ `{"transcript": "<verbatim words spoken>", "language": "<BCP-47 code or null>", "voice_profile": {"gender": "...", "age_range": "...", "accent": "...", "pace": "...", "pitch": "...", "energy": "...", "tone": "..."}, "style_instructions": "<2-4 sentences>", "recommended_voice": {"openai": "<one name>", "gemini": "<one name>"}}`,
2080
+ "style_instructions must be a dense free-form text-to-speech voice direction that would make a voice actor sound as close as possible to this speaker: accent, pacing, pitch, energy, emotion, attitude, and delivery quirks (pauses, emphasis, breathiness, smile, hesitations).",
2081
+ `recommended_voice.openai must be exactly one name from: ${OPENAI_VOICE_CASTING_HINTS}.`,
2082
+ `recommended_voice.gemini must be exactly one name from: ${GEMINI_VOICE_CASTING_HINTS}.`,
2083
+ "Pick the preset closest to the speaker's gender, age, and timbre.",
2084
+ ...(language?.trim() ? [`Expected language: ${language.trim()}.`] : [])
2085
+ ].join("\n");
2086
+ }
2087
+ function buildSpeechRewritePrompt(input) {
2088
+ return [
2089
+ "You rewrite spoken-word scripts. Rewrite the original transcript below, following the instruction, as words to be SPOKEN aloud by the same person.",
2090
+ "Rules:",
2091
+ "- Keep roughly the same length and rhythm as the original unless the instruction says otherwise (the new audio should fill a similar duration).",
2092
+ "- Keep the speaker's register, energy, and phrasing style; this must sound like the same person talking.",
2093
+ "- Plain spoken words only: no stage directions, no markdown, no quotes, no speaker labels, no timestamps.",
2094
+ ...(input.language?.trim() ? [`- Write the script in this language: ${input.language.trim()}.`] : ["- Write in the same language as the original transcript."]),
2095
+ `Return STRICT JSON ONLY with shape {"script": "..."}.`,
2096
+ "",
2097
+ `Instruction: ${input.instruction}`,
2098
+ "",
2099
+ "Original transcript:",
2100
+ input.transcript
2101
+ ].join("\n");
2102
+ }
2103
+ // Voice-matched speech regeneration ("same voice, new words"). Pipeline:
2104
+ // (1) load the source audio (videos are demuxed), (2) profile the speaker with
2105
+ // an audio-native LLM — verbatim transcript + voice profile + closest preset
2106
+ // voices in ONE call (needs a saved Gemini key; without one this degrades to a
2107
+ // plain STT transcript and no voice match), (3) reword the transcript per
2108
+ // rewrite_instruction unless an exact `text` script was given, (4) TTS with
2109
+ // the matched preset + profiled style instructions. Neither OpenAI nor Gemini
2110
+ // exposes voice cloning through their public APIs (preset voices only; actual
2111
+ // cloning is sales-allowlisted at both), so voice_match is always an honest
2112
+ // approximation, never a clone.
2113
+ const speechRegeneratePrimitive = definePrimitive({
2114
+ id: PRIMITIVE_SPEECH_REGENERATE_ID,
2115
+ kind: "speech_regenerate",
2116
+ operations: {
2117
+ run: {
2118
+ description: "Regenerate the speech of a video/audio source as new AI audio that sounds like the original speaker (closest preset voice + matched style direction — an approximation, not a clone), speaking a reworded or replacement script.",
2119
+ inputSchema: speechRegeneratePayloadSchema,
2120
+ workflow: "run"
2121
+ }
2122
+ },
2123
+ jobs: {
2124
+ async run(ctx, input) {
2125
+ const payload = speechRegeneratePayloadSchema.parse(input);
2126
+ const { audioBytes, audioContentType, audioUrl } = await loadSpeechAudioForPrimitive(ctx, payload);
2127
+ // Step 1 — hear the speaker. One Gemini call returns transcript + voice
2128
+ // profile together; a failure here must never sink the job.
2129
+ const available = ctx.providers.listAvailableProviders();
2130
+ let profile = null;
2131
+ if (available.includes("gemini") && audioUrl) {
2132
+ ctx.logger.progress(0.3, "Profiling the original voice");
2133
+ try {
2134
+ const profiled = await runBrainstormJson(ctx, {
2135
+ kind: "speech_voice_profile",
2136
+ provider: "gemini",
2137
+ prompt: buildVoiceProfilePrompt(payload.language),
2138
+ attachments: [audioUrl],
2139
+ resultSchema: speechVoiceProfileResultSchema
2140
+ });
2141
+ profile = profiled.result;
2142
+ }
2143
+ catch (error) {
2144
+ ctx.logger.warn("Voice profiling failed; continuing without a voice match", {
2145
+ error: error instanceof Error ? error.message : String(error)
2146
+ });
2147
+ }
2148
+ }
2149
+ let originalTranscript = profile?.transcript?.trim() || "";
2150
+ if (!originalTranscript && payload.rewrite_instruction) {
2151
+ ctx.logger.progress(0.4, "Transcribing original speech");
2152
+ const sttProvider = ["gemini", "openai", "openrouter"].find((provider) => available.includes(provider)) ?? "gemini";
2153
+ const transcription = await ctx.providers.transcribeSpeech({
2154
+ provider: sttProvider,
2155
+ model: defaultSpeechModelFor("stt", sttProvider) ?? "",
2156
+ audio: audioBytes,
2157
+ contentType: audioContentType,
2158
+ language: payload.language,
2159
+ diarize: false
2160
+ });
2161
+ originalTranscript = transcription.text.trim();
2162
+ if (!originalTranscript) {
2163
+ throw new Error("The source has no transcribable speech to reword. Pass text with the exact script instead.");
2164
+ }
2165
+ }
2166
+ // Step 2 — the words: exact script wins, otherwise reword the transcript.
2167
+ let script = payload.text?.trim() ?? "";
2168
+ if (!script) {
2169
+ ctx.logger.progress(0.5, "Rewording the script");
2170
+ const rewrite = await runBrainstormJson(ctx, {
2171
+ kind: "speech_rewrite",
2172
+ prompt: buildSpeechRewritePrompt({
2173
+ transcript: originalTranscript,
2174
+ instruction: payload.rewrite_instruction,
2175
+ language: payload.language ?? profile?.language ?? null
2176
+ }),
2177
+ resultSchema: speechRewriteResultSchema
2178
+ });
2179
+ script = rewrite.result.script.trim();
2180
+ }
2181
+ if (!script) {
2182
+ throw new Error("No script produced to speak.");
2183
+ }
2184
+ if (script.length > 8000) {
2185
+ throw new Error("The regenerated script is too long for TTS (8000 characters max). Trim the source with /audio/trim or narrow the rewrite_instruction.");
2186
+ }
2187
+ // Step 3 — voice + style. openai first when nothing else decides: its
2188
+ // TTS takes the style `instructions` natively (gemini gets them
2189
+ // prepended into the prompt). A bare payload voice implies its provider.
2190
+ const provider = payload.provider
2191
+ ?? inferSpeechProviderForVoice(payload.voice)
2192
+ ?? ["openai", "gemini", "openrouter"].find((candidate) => available.includes(candidate))
2193
+ ?? "openai";
2194
+ const model = payload.model ?? defaultSpeechModelFor("tts", provider) ?? "";
2195
+ const family = speechVoiceFamilyFor(provider, model);
2196
+ const presets = family === "gemini" ? GEMINI_TTS_VOICES : OPENAI_TTS_VOICES;
2197
+ const recommendedRaw = (family === "gemini" ? profile?.recommended_voice?.gemini : profile?.recommended_voice?.openai)?.trim().toLowerCase();
2198
+ const recommendedVoice = recommendedRaw ? presets.find((preset) => preset.toLowerCase() === recommendedRaw) : undefined;
2199
+ const voice = payload.voice ?? recommendedVoice;
2200
+ const profiledStyle = profile?.style_instructions?.trim();
2201
+ const instructions = [profiledStyle, payload.instructions?.trim()].filter(Boolean).join(" ") || undefined;
2202
+ ctx.logger.progress(0.65, "Generating voice-matched speech");
2203
+ const generated = await ctx.providers.generateSpeech({
2204
+ provider,
2205
+ model,
2206
+ text: script,
2207
+ voice,
2208
+ instructions,
2209
+ responseFormat: payload.output_format
2210
+ });
2211
+ const extension = generated.contentType.includes("wav") ? "wav" : "mp3";
2212
+ const stored = await ctx.storage.putBuffer(`speech.${extension}`, generated.bytes, {
2213
+ contentType: generated.contentType,
2214
+ kind: "audio",
2215
+ metadata: {
2216
+ primitive: "speech_regenerate",
2217
+ provider,
2218
+ model,
2219
+ voice: voice ?? null,
2220
+ styleInstructions: instructions ?? null
2221
+ }
2222
+ });
2223
+ ctx.logger.progress(0.9, "Storing regeneration artifacts");
2224
+ const voiceMatch = profiledStyle || recommendedVoice ? "approximate" : "none";
2225
+ const voiceMatchNote = voiceMatch === "approximate"
2226
+ ? "The audio uses the closest preset voice with style instructions matched to the original speaker. OpenAI/Gemini APIs cannot clone voice identity, so this is an approximation, not a clone."
2227
+ : "No voice profile was available (profiling needs a saved Gemini key), so a default or explicitly requested preset voice was used without matching the original speaker.";
2228
+ const storedJson = await ctx.storage.putJson("regenerate-speech.json", {
2229
+ source_url: payload.source_url,
2230
+ provider,
2231
+ model,
2232
+ voice: voice ?? null,
2233
+ style_instructions: instructions ?? null,
2234
+ voice_match: voiceMatch,
2235
+ voice_match_note: voiceMatchNote,
2236
+ voice_profile: profile?.voice_profile ?? null,
2237
+ original_transcript: originalTranscript || null,
2238
+ rewrite_instruction: payload.rewrite_instruction ?? null,
2239
+ script,
2240
+ audio_url: stored.url
2241
+ });
2242
+ ctx.logger.progress(1, "Speech regeneration complete");
2243
+ return {
2244
+ progress: 1,
2245
+ output: {
2246
+ files: compactUrls([stored.url, storedJson.url]),
2247
+ primary_file_url: stored.url,
2248
+ audioUrl: stored.url,
2249
+ script,
2250
+ original_transcript: originalTranscript ? originalTranscript.slice(0, MAX_INLINE_TRANSCRIPT_CHARS) : null,
2251
+ voice_match: voiceMatch,
2252
+ voice_match_note: voiceMatchNote,
2253
+ voice_profile: profile?.voice_profile ?? null,
2254
+ audio: {
2255
+ file_url: stored.url,
2256
+ content_type: generated.contentType,
2257
+ provider,
2258
+ model,
2259
+ voice: voice ?? null,
2260
+ style_instructions: instructions ?? null,
2261
+ text_length: script.length
2262
+ },
2263
+ details_json_url: storedJson.url
2264
+ }
2265
+ };
2266
+ }
2267
+ }
2268
+ });
1994
2269
  const videoProbePrimitive = definePrimitive({
1995
2270
  id: PRIMITIVE_VIDEO_PROBE_ID,
1996
2271
  kind: "video_probe",
@@ -2570,6 +2845,7 @@ class PrimitiveRegistry {
2570
2845
  [ttsPrimitive.id, ttsPrimitive],
2571
2846
  [sttPrimitive.id, sttPrimitive],
2572
2847
  [captionsPrimitive.id, captionsPrimitive],
2848
+ [speechRegeneratePrimitive.id, speechRegeneratePrimitive],
2573
2849
  [hyperframesRenderPrimitive.id, hyperframesRenderPrimitive],
2574
2850
  [brainstormHooksPrimitive.id, brainstormHooksPrimitive],
2575
2851
  [brainstormAwarenessStagesPrimitive.id, brainstormAwarenessStagesPrimitive],
@@ -0,0 +1,255 @@
1
+ // /reskin/agency — the Agency / client-workspaces screen ported into the
2
+ // farmville design system. Uses ONLY shared rk-* primitives + a small
3
+ // page-scoped CSS block + a self-contained module script.
4
+ //
5
+ // Renders with representative SAMPLE data so it can be viewed without auth.
6
+ // Faithfully mirrors src/account-pages-legacy.ts → renderAgencyPage: an agency
7
+ // managing its client sub-accounts. The legacy page is a searchable list of
8
+ // cached client accounts (name · email · user id) with "Open Tab" (opens the
9
+ // client's /u/:id/library) and "Copy Login" (autologin URL) actions plus an
10
+ // "Add Login" button. This reskin keeps that spine and dresses it up with the
11
+ // per-client stats the admin agency-link model already tracks (wallet, seats,
12
+ // projects) into warm workspace cards.
13
+ import { reskinDocument, rkEscape } from "./document.js";
14
+ const SAMPLE = {
15
+ agencyName: "Sunset Social",
16
+ ownerEmail: "hey@sunsetsocial.co",
17
+ stats: { clients: 7, activeProjects: 34, seatsUsed: 12, seatsTotal: 20, monthlySpendUsd: 486.2 },
18
+ clients: [
19
+ { name: "Meadow Coffee Co.", email: "ops@meadowcoffee.com", userId: "usr_4b12a9", plan: "Pro", status: "active", tint: "gold", projects: 9, scheduled: 6, walletUsd: 42.8, seats: 3, lastSeen: "2h ago" },
20
+ { name: "Northwind Outdoors", email: "team@northwind.gear", userId: "usr_9f0c31", plan: "Pro", status: "active", tint: "green", projects: 7, scheduled: 4, walletUsd: 88.15, seats: 2, lastSeen: "5h ago" },
21
+ { name: "Lumen Skincare", email: "grow@lumenskin.co", userId: "usr_2d77e4", plan: "Growth", status: "active", tint: "violet", projects: 6, scheduled: 8, walletUsd: 24.0, seats: 2, lastSeen: "yesterday" },
22
+ { name: "Harborline Fitness", email: "studio@harborline.fit", userId: "usr_71aa08", plan: "Starter", status: "trial", tint: "sky", projects: 4, scheduled: 2, walletUsd: 5.0, seats: 1, lastSeen: "3d ago" },
23
+ { name: "Cedar & Co. Home", email: "hello@cedarandco.com", userId: "usr_58bb1c", plan: "Pro", status: "active", tint: "coral", projects: 5, scheduled: 3, walletUsd: 61.4, seats: 2, lastSeen: "6h ago" },
24
+ { name: "Beacon Legal", email: "marketing@beaconlegal.io", userId: "usr_3c90f7", plan: "Growth", status: "paused", tint: "ink", projects: 2, scheduled: 0, walletUsd: 12.9, seats: 1, lastSeen: "2w ago" },
25
+ { name: "Pixel Pantry", email: "founders@pixelpantry.app", userId: "usr_6e14d0", plan: "Starter", status: "trial", tint: "sky", projects: 1, scheduled: 1, walletUsd: 5.0, seats: 1, lastSeen: "1d ago" }
26
+ ]
27
+ };
28
+ const STATUS_PILL = {
29
+ active: { cls: "rk-pill-green", label: "Active", dot: "●" },
30
+ trial: { cls: "rk-pill-sky", label: "Trial", dot: "◐" },
31
+ paused: { cls: "rk-pill-red", label: "Paused", dot: "◌" }
32
+ };
33
+ function money(n) {
34
+ const frac = Number.isInteger(n) ? 0 : 2;
35
+ return `$${n.toLocaleString("en-US", { minimumFractionDigits: frac, maximumFractionDigits: 2 })}`;
36
+ }
37
+ function initials(name) {
38
+ const letters = name
39
+ .replace(/&/g, " ")
40
+ .split(/\s+/)
41
+ .map((w) => (w.match(/[a-z0-9]/i) || [""])[0])
42
+ .filter(Boolean);
43
+ const out = `${letters[0] || ""}${letters[1] || ""}`;
44
+ return (out || "?").toUpperCase();
45
+ }
46
+ function statTile(label, value, sub) {
47
+ return `<div class="rk-card rk-card-pad-sm rk-agency-stat">
48
+ <div class="rk-stat-label">${rkEscape(label)}</div>
49
+ <div class="rk-stat-num">${value}</div>
50
+ <div class="rk-agency-stat-sub">${rkEscape(sub)}</div>
51
+ </div>`;
52
+ }
53
+ function clientCard(c) {
54
+ const pill = STATUS_PILL[c.status];
55
+ const search = `${c.name} ${c.email} ${c.userId}`.toLowerCase();
56
+ return `<article class="rk-card rk-card-hover rk-agency-card" data-rk-client="${rkEscape(search)}">
57
+ <div class="rk-agency-card-top">
58
+ <span class="rk-tile rk-tile-lg rk-tile-${c.tint} rk-agency-avatar">${rkEscape(initials(c.name))}</span>
59
+ <div class="rk-agency-card-id">
60
+ <h3 class="rk-agency-name">${rkEscape(c.name)}</h3>
61
+ <div class="rk-agency-email" role="button" tabindex="0" data-rk-copy-text="${rkEscape(c.email)}" title="Copy email">${rkEscape(c.email)}</div>
62
+ </div>
63
+ <span class="rk-pill ${pill.cls} rk-agency-status">${pill.dot} ${pill.label}</span>
64
+ </div>
65
+ <div class="rk-agency-meta">
66
+ <span class="rk-pill rk-pill-gold">${rkEscape(c.plan)} plan</span>
67
+ <span class="rk-pill">${c.seats} ${c.seats === 1 ? "seat" : "seats"}</span>
68
+ <span class="rk-agency-user" role="button" tabindex="0" data-rk-copy-text="${rkEscape(c.userId)}" title="Copy user id">${rkEscape(c.userId)}</span>
69
+ </div>
70
+ <div class="rk-agency-minis">
71
+ <div class="rk-agency-mini">
72
+ <div class="rk-agency-mini-num">${c.projects}</div>
73
+ <div class="rk-agency-mini-lbl">Projects</div>
74
+ </div>
75
+ <div class="rk-agency-mini">
76
+ <div class="rk-agency-mini-num">${c.scheduled}</div>
77
+ <div class="rk-agency-mini-lbl">Scheduled</div>
78
+ </div>
79
+ <div class="rk-agency-mini">
80
+ <div class="rk-agency-mini-num">${money(c.walletUsd)}</div>
81
+ <div class="rk-agency-mini-lbl">Wallet</div>
82
+ </div>
83
+ </div>
84
+ <div class="rk-agency-card-foot">
85
+ <a class="rk-btn rk-btn-ghost rk-btn-sm rk-agency-open" href="/u/${encodeURIComponent(c.userId)}/library" target="_blank" rel="noreferrer">Open workspace <span class="rk-arrow">→</span></a>
86
+ <button class="rk-btn rk-btn-ghost rk-btn-sm" type="button" data-rk-copy-login="/auto-login?client=${encodeURIComponent(c.userId)}">Copy login</button>
87
+ <span class="rk-agency-seen">Active ${rkEscape(c.lastSeen)}</span>
88
+ </div>
89
+ </article>`;
90
+ }
91
+ export function renderReskinAgency() {
92
+ const a = SAMPLE;
93
+ const s = a.stats;
94
+ const stats = `
95
+ <div class="rk-grid rk-grid-4 rk-agency-stats">
96
+ ${statTile("Client workspaces", String(s.clients), "all sub-accounts you manage")}
97
+ ${statTile("Active projects", String(s.activeProjects), "across every client")}
98
+ ${statTile("Seats used", `${s.seatsUsed}<span class="rk-agency-stat-of"> / ${s.seatsTotal}</span>`, "team members with access")}
99
+ ${statTile("Monthly spend", money(s.monthlySpendUsd), "pooled render + clip usage")}
100
+ </div>`;
101
+ const clientCards = a.clients.map(clientCard).join("");
102
+ const inviteCard = `<article class="rk-card rk-agency-invite" data-rk-client="__invite__">
103
+ <span class="rk-tile rk-tile-lg rk-tile-gold rk-agency-invite-ic">+</span>
104
+ <h3 class="rk-agency-invite-title">Add a client workspace</h3>
105
+ <p class="rk-muted rk-agency-invite-copy">Link a client's account and get an autologin so your team can post on their behalf.</p>
106
+ <button class="rk-btn rk-btn-gold" type="button" data-rk-add-client>Add client <span class="rk-arrow">→</span></button>
107
+ </article>`;
108
+ const body = `
109
+ <main class="rk-container rk-page">
110
+ <div class="rk-page-head rk-agency-head">
111
+ <div class="rk-agency-head-copy">
112
+ <span class="rk-badge">Agency workspace</span>
113
+ <h1 class="rk-h1">Agency</h1>
114
+ <p class="rk-sub">Manage every client workspace from one seat — ${a.clients.length} accounts, shared seats, and one pooled bill.</p>
115
+ </div>
116
+ <div class="rk-agency-head-cta">
117
+ <a class="rk-btn rk-btn-ghost" href="/agency-client-login" target="_blank" rel="noreferrer">Add login</a>
118
+ <button class="rk-btn rk-btn-gold rk-btn-lg" type="button" data-rk-add-client>Add client <span class="rk-arrow">→</span></button>
119
+ </div>
120
+ </div>
121
+
122
+ ${stats}
123
+
124
+ <section class="rk-agency-panel">
125
+ <div class="rk-spread rk-wrap rk-agency-toolbar">
126
+ <div class="rk-agency-toolbar-copy">
127
+ <span class="rk-eyebrow">Clients</span>
128
+ <h2 class="rk-agency-h2">${a.clients.length} client workspaces</h2>
129
+ </div>
130
+ <div class="rk-agency-search-wrap">
131
+ <span class="rk-agency-search-ic" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg></span>
132
+ <input class="rk-input rk-agency-search" type="search" placeholder="Search name, email, or user id" autocomplete="off" spellcheck="false" data-rk-agency-search />
133
+ </div>
134
+ </div>
135
+ <div class="rk-grid rk-grid-3 rk-agency-grid" data-rk-agency-grid>
136
+ ${clientCards}
137
+ ${inviteCard}
138
+ </div>
139
+ <div class="rk-agency-empty" data-rk-agency-empty hidden>
140
+ <p class="rk-muted">No client workspaces match that search.</p>
141
+ </div>
142
+ </section>
143
+ </main>`;
144
+ const pageCss = `
145
+ .rk-agency-head{grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:20px}
146
+ .rk-agency-head-copy{display:grid;gap:8px;min-width:0}
147
+ .rk-agency-head-copy .rk-badge{justify-self:start}
148
+ .rk-agency-head-cta{display:flex;align-items:center;gap:10px;flex:none}
149
+ @media(max-width:760px){.rk-agency-head{grid-template-columns:1fr}.rk-agency-head-cta{flex-wrap:wrap}}
150
+
151
+ .rk-agency-stats{margin-bottom:28px}
152
+ .rk-agency-stat{display:grid;gap:6px;align-content:start}
153
+ .rk-agency-stat-ic{display:grid;place-items:center;width:24px;height:24px;border-radius:8px;background:var(--rk-gold-tint);font-size:13px}
154
+ .rk-agency-stat-sub{font-size:12px;color:var(--rk-text-faint)}
155
+ .rk-agency-stat-of{font-size:var(--rk-text-xl);color:var(--rk-text-faint);font-weight:700}
156
+
157
+ .rk-agency-panel{background:var(--rk-bg-section);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);padding:24px}
158
+ .rk-agency-toolbar{margin-bottom:20px}
159
+ .rk-agency-toolbar-copy{display:grid;gap:4px}
160
+ .rk-agency-h2{font-size:var(--rk-text-2xl);letter-spacing:-.02em;color:var(--rk-ink)}
161
+ .rk-agency-search-wrap{position:relative;width:min(360px,100%)}
162
+ .rk-agency-search-ic{position:absolute;left:15px;top:50%;transform:translateY(-50%);font-size:14px;opacity:.6;pointer-events:none}
163
+ .rk-agency-search{padding-left:40px;background:#fff;border-radius:var(--rk-r-full)}
164
+ @media(max-width:620px){.rk-agency-search-wrap{width:100%}}
165
+
166
+ .rk-agency-grid{align-items:stretch}
167
+ .rk-agency-card{padding:22px;display:flex;flex-direction:column;gap:16px}
168
+ .rk-agency-card-top{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:start}
169
+ .rk-agency-avatar{font-family:var(--rk-font-display);font-weight:800;font-size:22px}
170
+ .rk-agency-card-id{min-width:0;display:grid;gap:2px}
171
+ .rk-agency-name{font-size:var(--rk-text-lg);font-weight:800;font-family:var(--rk-font-display);color:var(--rk-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
172
+ .rk-agency-email{font-size:13px;color:var(--rk-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;transition:color var(--rk-dur) var(--rk-ease)}
173
+ .rk-agency-email:hover{color:var(--rk-gold-700)}
174
+ .rk-agency-status{flex:none;align-self:start;white-space:nowrap}
175
+ .rk-agency-meta{display:flex;align-items:center;flex-wrap:wrap;gap:8px}
176
+ .rk-agency-user{font-family:var(--rk-font-mono);font-size:12px;color:var(--rk-text-faint);cursor:pointer;transition:color var(--rk-dur) var(--rk-ease)}
177
+ .rk-agency-user:hover{color:var(--rk-n-600)}
178
+ .rk-agency-minis{display:grid;grid-template-columns:repeat(3,1fr);gap:2px;background:var(--rk-n-100);border-radius:var(--rk-r-xl);padding:14px 10px;margin-top:auto}
179
+ .rk-agency-mini{display:grid;gap:3px;justify-items:center;text-align:center;position:relative}
180
+ .rk-agency-mini+.rk-agency-mini::before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);height:26px;width:1px;background:var(--rk-border)}
181
+ .rk-agency-mini-num{font-family:var(--rk-font-display);font-weight:800;font-size:1.1rem;color:var(--rk-ink);letter-spacing:-.02em}
182
+ .rk-agency-mini-lbl{font-size:11px;font-weight:600;color:var(--rk-text-muted);text-transform:uppercase;letter-spacing:.03em}
183
+ .rk-agency-card-foot{display:flex;align-items:center;flex-wrap:wrap;gap:8px}
184
+ .rk-agency-open{font-weight:600}
185
+ .rk-agency-seen{margin-left:auto;font-size:12px;color:var(--rk-text-faint);white-space:nowrap}
186
+
187
+ .rk-agency-invite{border-style:dashed;border-color:var(--rk-border-strong);background:linear-gradient(180deg,var(--rk-gold-tint),#fff 70%);display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:12px;min-height:240px}
188
+ .rk-agency-invite-ic{font-size:26px}
189
+ .rk-agency-invite-title{font-size:var(--rk-text-lg);font-family:var(--rk-font-display);font-weight:800;color:var(--rk-ink)}
190
+ .rk-agency-invite-copy{font-size:13px;max-width:26ch}
191
+
192
+ .rk-agency-empty{display:none;flex-direction:column;align-items:center;gap:12px;padding:56px 20px;text-align:center}
193
+ .rk-agency-empty[data-rk-shown="1"]{display:flex}
194
+ `;
195
+ const script = `
196
+ (function(){
197
+ var root=document;
198
+ var grid=root.querySelector('[data-rk-agency-grid]');
199
+ var search=root.querySelector('[data-rk-agency-search]');
200
+ var empty=root.querySelector('[data-rk-agency-empty]');
201
+ // client-side search filter over the workspace cards
202
+ function filter(){
203
+ if(!grid)return;
204
+ var q=(search&&search.value||'').trim().toLowerCase();
205
+ var visible=0;
206
+ grid.querySelectorAll('[data-rk-client]').forEach(function(card){
207
+ var key=card.getAttribute('data-rk-client')||'';
208
+ var isInvite=key==='__invite__';
209
+ var hit=!q||(!isInvite&&key.indexOf(q)!==-1);
210
+ // keep the invite card visible only when not searching
211
+ var show=isInvite?!q:hit;
212
+ card.hidden=!show;
213
+ if(show&&!isInvite)visible++;
214
+ });
215
+ if(empty){empty.hidden=!(q&&visible===0);empty.setAttribute('data-rk-shown',(q&&visible===0)?'1':'0');}
216
+ }
217
+ if(search)search.addEventListener('input',filter);
218
+ // copy helpers (email / user id / login link)
219
+ function flash(el,label){
220
+ var tag=el.getAttribute('data-rk-flash-tag')||el.getAttribute('title')||'';
221
+ var prev=el.textContent;
222
+ el.textContent=label;
223
+ setTimeout(function(){el.textContent=prev;},1200);
224
+ }
225
+ function copy(value,el){
226
+ if(!value||!navigator.clipboard)return;
227
+ navigator.clipboard.writeText(value).then(function(){flash(el,'Copied!');});
228
+ }
229
+ root.querySelectorAll('[data-rk-copy-text]').forEach(function(el){
230
+ var run=function(){copy(el.getAttribute('data-rk-copy-text'),el);};
231
+ el.addEventListener('click',run);
232
+ el.addEventListener('keydown',function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();run();}});
233
+ });
234
+ root.querySelectorAll('[data-rk-copy-login]').forEach(function(btn){
235
+ btn.addEventListener('click',function(){
236
+ var rel=btn.getAttribute('data-rk-copy-login')||'';
237
+ var abs=rel;try{abs=new URL(rel,location.origin).toString();}catch(_){}
238
+ copy(abs,btn);
239
+ });
240
+ });
241
+ // "Add client" CTA — reskin surface has no backend; nudge to real flow
242
+ root.querySelectorAll('[data-rk-add-client]').forEach(function(btn){
243
+ btn.addEventListener('click',function(){window.open('/agency-client-login','_blank','noopener,noreferrer');});
244
+ });
245
+ })();`;
246
+ return reskinDocument({
247
+ title: "vidfarm reskin — Agency",
248
+ description: "Reskinned vidfarm agency page (client workspaces, seats, pooled billing).",
249
+ activeSlug: "agency",
250
+ pageCss,
251
+ body,
252
+ script
253
+ });
254
+ }
255
+ //# sourceMappingURL=agency-page.js.map