@mevdragon/vidfarm-devcli 0.16.0 → 0.18.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 (38) 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 +4 -3
  7. package/.agents/skills/vidfarm-media/references/tts.md +20 -1
  8. package/SKILL.director.md +20 -20
  9. package/SKILL.platform.md +4 -4
  10. package/dist/src/account-pages-legacy.js +2 -2
  11. package/dist/src/app.js +721 -101
  12. package/dist/src/cli.js +11 -10
  13. package/dist/src/devcli/clips.js +64 -64
  14. package/dist/src/editor-chat.js +2 -2
  15. package/dist/src/frontend/homepage-client.js +162 -2
  16. package/dist/src/frontend/homepage-store.js +30 -1
  17. package/dist/src/frontend/homepage-view.js +111 -4
  18. package/dist/src/homepage.js +184 -1
  19. package/dist/src/landing-page.js +367 -0
  20. package/dist/src/primitive-registry.js +278 -2
  21. package/dist/src/reskin/agency-page.js +299 -0
  22. package/dist/src/reskin/calendar-page.js +567 -0
  23. package/dist/src/reskin/chat-page.js +607 -0
  24. package/dist/src/reskin/discover-page.js +1096 -0
  25. package/dist/src/reskin/document.js +663 -0
  26. package/dist/src/reskin/help-page.js +356 -0
  27. package/dist/src/reskin/index-page.js +62 -0
  28. package/dist/src/reskin/inpaint-page.js +541 -0
  29. package/dist/src/reskin/job-runs-page.js +477 -0
  30. package/dist/src/reskin/library-page.js +688 -0
  31. package/dist/src/reskin/login-page.js +262 -0
  32. package/dist/src/reskin/pricing-page.js +388 -0
  33. package/dist/src/reskin/settings-page.js +687 -0
  34. package/dist/src/reskin/theme.js +362 -0
  35. package/dist/src/services/serverless-records.js +54 -0
  36. package/dist/src/services/swipe-customize.js +434 -0
  37. package/package.json +1 -1
  38. 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,299 @@
1
+ // /reskin/agency — the Agency / client-workspaces screen, MIGRATED to real
2
+ // production data + behavior (farmville design system).
3
+ //
4
+ // This mirrors src/account-pages-legacy.ts → renderAgencyPage: an agency
5
+ // managing its client sub-accounts. The live page's client list is NOT
6
+ // server-fetched — it is read CLIENT-SIDE from this browser's localStorage
7
+ // account registry (the same cache the app writes when you sign into a client
8
+ // account), so the server can only render an empty shell + template and the
9
+ // real client cards are built in JS. This reskin replicates that exact read and
10
+ // wires the real per-client actions:
11
+ // • "Open workspace" → opens the client's /u/:id/library in a new tab
12
+ // • "Copy login" → copies the client's autologin URL
13
+ // • "Add client login"→ opens /agency-client-login
14
+ // localStorage contract (identical keys/fields to the live page):
15
+ // index key: "vidfarm:agency:accounts:index" → string[] of userIds
16
+ // entry key: "vidfarm:agency:account_<userId>" → { apiKey, email,
17
+ // displayName, lastSeenAt, autologinUrl? }
18
+ // autologinUrl falls back to "/auto-login?api_key=<encodeURIComponent(apiKey)>"
19
+ //
20
+ // The old reskin rendered fabricated per-client stats (projects, scheduled,
21
+ // wallet, seats, plan, status) and aggregate tiles — NONE of those live in the
22
+ // cache, so they are dropped. Cards now show only real cached fields (name,
23
+ // email, user id, last-seen). Avatars are text initials (no emoji).
24
+ import { reskinDocument, rkEscape } from "./document.js";
25
+ export function renderReskinAgency(input = { userId: null, currentAccountId: null, name: null, email: null, loggedIn: false }) {
26
+ const loggedIn = input.loggedIn ?? Boolean(input.userId);
27
+ const signedInHint = loggedIn && input.email
28
+ ? `<span class="rk-agency-hint">Managing from ${rkEscape(input.email)}</span>`
29
+ : "";
30
+ const PLUS = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>`;
31
+ // The invite / "add a client workspace" card is a static, always-real CTA to
32
+ // the /agency-client-login flow. It lives inside the grid alongside the
33
+ // JS-injected client cards (which render into [data-rk-agency-cards]).
34
+ const inviteCard = `<article class="rk-card rk-agency-invite" data-rk-agency-invite>
35
+ <span class="rk-tile rk-tile-lg rk-tile-gold rk-agency-invite-ic">${PLUS}</span>
36
+ <h3 class="rk-agency-invite-title">Add a client workspace</h3>
37
+ <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>
38
+ <a class="rk-btn rk-btn-gold" href="/agency-client-login" target="_blank" rel="noreferrer">Add client login <span class="rk-arrow">→</span></a>
39
+ </article>`;
40
+ const body = `
41
+ <main class="rk-container rk-page">
42
+ <div class="rk-page-head rk-agency-head">
43
+ <div class="rk-agency-head-copy">
44
+ <h1 class="rk-h1">Agency clients</h1>
45
+ <p class="rk-sub">Every client workspace cached on this device — open their library, or copy a one-tap login to post on their behalf.</p>
46
+ </div>
47
+ <div class="rk-agency-head-cta">
48
+ ${signedInHint}
49
+ <a class="rk-btn rk-btn-gold rk-btn-lg" href="/agency-client-login" target="_blank" rel="noreferrer">Add client login <span class="rk-arrow">→</span></a>
50
+ </div>
51
+ </div>
52
+
53
+ <section class="rk-agency-panel">
54
+ <div class="rk-spread rk-wrap rk-agency-toolbar">
55
+ <div class="rk-agency-toolbar-copy">
56
+ <span class="rk-eyebrow">Clients</span>
57
+ <h2 class="rk-agency-h2"><span data-rk-agency-count>0</span> client workspace<span data-rk-agency-plural>s</span></h2>
58
+ </div>
59
+ <div class="rk-agency-search-wrap">
60
+ <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>
61
+ <input class="rk-input rk-agency-search" type="search" placeholder="Search name, email, or user id" autocomplete="off" spellcheck="false" data-rk-agency-search />
62
+ </div>
63
+ </div>
64
+ <div class="rk-grid rk-grid-3 rk-agency-grid" data-rk-agency-grid>
65
+ <div class="rk-agency-cards" data-rk-agency-cards></div>
66
+ ${inviteCard}
67
+ </div>
68
+ <div class="rk-agency-empty" data-rk-agency-empty hidden>
69
+ <p class="rk-muted" data-rk-agency-empty-msg>No cached accounts found in this browser yet.</p>
70
+ </div>
71
+ </section>
72
+ </main>`;
73
+ const pageCss = `
74
+ .rk-agency-head{grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:20px}
75
+ .rk-agency-head-copy{display:grid;gap:4px;min-width:0}
76
+ .rk-agency-head-cta{display:flex;align-items:center;gap:12px;flex:none}
77
+ .rk-agency-hint{font-size:13px;color:var(--rk-text-muted);white-space:nowrap}
78
+ @media(max-width:760px){.rk-agency-head{grid-template-columns:1fr}.rk-agency-head-cta{flex-wrap:wrap}}
79
+
80
+ .rk-agency-panel{background:var(--rk-bg-section);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);padding:24px}
81
+ .rk-agency-toolbar{margin-bottom:20px}
82
+ .rk-agency-toolbar-copy{display:grid;gap:4px}
83
+ .rk-agency-h2{font-size:var(--rk-text-2xl);letter-spacing:-.02em;color:var(--rk-ink)}
84
+ .rk-agency-search-wrap{position:relative;width:min(360px,100%)}
85
+ .rk-agency-search-ic{position:absolute;left:15px;top:50%;transform:translateY(-50%);font-size:14px;opacity:.6;pointer-events:none}
86
+ .rk-agency-search{padding-left:40px;background:#fff;border-radius:var(--rk-r-full)}
87
+ @media(max-width:620px){.rk-agency-search-wrap{width:100%}}
88
+
89
+ .rk-agency-grid{align-items:stretch}
90
+ /* the JS card wrapper is transparent to the grid so cards + invite share tracks */
91
+ .rk-agency-cards{display:contents}
92
+ .rk-agency-card{padding:22px;display:flex;flex-direction:column;gap:16px}
93
+ .rk-agency-card-top{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:start}
94
+ .rk-agency-avatar{font-family:var(--rk-font-display);font-weight:800;font-size:22px}
95
+ .rk-agency-card-id{min-width:0;display:grid;gap:2px}
96
+ .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}
97
+ .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)}
98
+ .rk-agency-email:hover{color:var(--rk-gold-700)}
99
+ .rk-agency-seen-pill{flex:none;align-self:start;white-space:nowrap}
100
+ .rk-agency-idrow{display:flex}
101
+ .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);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
102
+ .rk-agency-user:hover{color:var(--rk-n-600)}
103
+ .rk-agency-card-foot{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:auto}
104
+ .rk-agency-open{font-weight:600}
105
+
106
+ .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}
107
+ .rk-agency-invite-ic{display:grid;place-items:center}
108
+ .rk-agency-invite-title{font-size:var(--rk-text-lg);font-family:var(--rk-font-display);font-weight:800;color:var(--rk-ink)}
109
+ .rk-agency-invite-copy{font-size:13px;max-width:26ch}
110
+
111
+ .rk-agency-empty{display:none;flex-direction:column;align-items:center;gap:12px;padding:56px 20px;text-align:center}
112
+ .rk-agency-empty[data-rk-shown="1"]{display:flex}
113
+ `;
114
+ // Client-side: replicate the live localStorage account-registry read, build
115
+ // real farmville client cards, and wire the real actions. Written with string
116
+ // concatenation (not template literals) to avoid nested-`${}` escaping.
117
+ const script = `
118
+ (function(){
119
+ var ACCOUNT_REGISTRY_INDEX_KEY = "vidfarm:agency:accounts:index";
120
+ var ACCOUNT_REGISTRY_ENTRY_PREFIX = "vidfarm:agency:account_";
121
+ var TINTS = ["gold","sky","violet","green","coral","ink"];
122
+
123
+ var root=document;
124
+ var cardsWrap=root.querySelector('[data-rk-agency-cards]');
125
+ var invite=root.querySelector('[data-rk-agency-invite]');
126
+ var search=root.querySelector('[data-rk-agency-search]');
127
+ var empty=root.querySelector('[data-rk-agency-empty]');
128
+ var emptyMsg=root.querySelector('[data-rk-agency-empty-msg]');
129
+ var countEl=root.querySelector('[data-rk-agency-count]');
130
+ var pluralEl=root.querySelector('[data-rk-agency-plural]');
131
+ var searchQuery="";
132
+
133
+ function escapeHtml(value){
134
+ return String(value==null?"":value)
135
+ .replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")
136
+ .replace(/"/g,"&quot;").replace(/'/g,"&#39;");
137
+ }
138
+
139
+ function initials(name){
140
+ var letters=String(name||"").replace(/&/g," ").split(/\\s+/)
141
+ .map(function(w){var m=w.match(/[a-z0-9]/i);return m?m[0]:"";})
142
+ .filter(Boolean);
143
+ var out=(letters[0]||"")+(letters[1]||"");
144
+ return (out||"?").toUpperCase();
145
+ }
146
+
147
+ function tintFor(seed){
148
+ var s=String(seed||""),h=0;
149
+ for(var i=0;i<s.length;i++){h=(h*31+s.charCodeAt(i))>>>0;}
150
+ return TINTS[h%TINTS.length];
151
+ }
152
+
153
+ function relTime(iso){
154
+ var t=Date.parse(iso||"");
155
+ if(isNaN(t))return "";
156
+ var diff=Date.now()-t; if(diff<0)diff=0;
157
+ var m=Math.floor(diff/60000);
158
+ if(m<1)return "just now";
159
+ if(m<60)return m+"m ago";
160
+ var hr=Math.floor(m/60);
161
+ if(hr<24)return hr+"h ago";
162
+ var d=Math.floor(hr/24);
163
+ if(d<7)return d+"d ago";
164
+ try{return new Date(t).toLocaleDateString("en-US",{month:"short",day:"numeric"});}catch(_){return d+"d ago";}
165
+ }
166
+
167
+ function readAccountIds(){
168
+ try{
169
+ var raw=window.localStorage.getItem(ACCOUNT_REGISTRY_INDEX_KEY);
170
+ var parsed=raw?JSON.parse(raw):[];
171
+ return Array.isArray(parsed)?parsed.filter(function(v){return typeof v==="string"&&v;}):[];
172
+ }catch(_){return [];}
173
+ }
174
+
175
+ function readAccounts(){
176
+ return readAccountIds().map(function(userId){
177
+ try{
178
+ var raw=window.localStorage.getItem(ACCOUNT_REGISTRY_ENTRY_PREFIX+userId);
179
+ if(!raw)return null;
180
+ var parsed=JSON.parse(raw);
181
+ if(!parsed||typeof parsed!=="object"||typeof parsed.apiKey!=="string"||!parsed.apiKey)return null;
182
+ return {
183
+ userId:userId,
184
+ email:typeof parsed.email==="string"?parsed.email:"",
185
+ displayName:typeof parsed.displayName==="string"?parsed.displayName:"",
186
+ lastSeenAt:typeof parsed.lastSeenAt==="string"?parsed.lastSeenAt:"",
187
+ autologinUrl:typeof parsed.autologinUrl==="string"?parsed.autologinUrl:"/auto-login?api_key="+encodeURIComponent(parsed.apiKey)
188
+ };
189
+ }catch(_){return null;}
190
+ }).filter(Boolean).sort(function(a,b){return Date.parse(b.lastSeenAt||"")-Date.parse(a.lastSeenAt||"");});
191
+ }
192
+
193
+ function matchesSearch(account){
194
+ var needle=searchQuery.trim().toLowerCase();
195
+ if(!needle)return true;
196
+ return [account.displayName,account.email,account.userId]
197
+ .some(function(v){return String(v||"").toLowerCase().indexOf(needle)!==-1;});
198
+ }
199
+
200
+ function cardHtml(account){
201
+ var title=account.displayName||account.email||account.userId;
202
+ var tint=tintFor(account.userId);
203
+ var seen=relTime(account.lastSeenAt);
204
+ var seenPill=seen?'<span class="rk-pill rk-agency-seen-pill">Active '+escapeHtml(seen)+'</span>':'';
205
+ var openTarget="/u/"+encodeURIComponent(account.userId)+"/library";
206
+ return ''
207
+ +'<article class="rk-card rk-card-hover rk-agency-card">'
208
+ +'<div class="rk-agency-card-top">'
209
+ +'<span class="rk-tile rk-tile-lg rk-tile-'+tint+' rk-agency-avatar">'+escapeHtml(initials(title))+'</span>'
210
+ +'<div class="rk-agency-card-id">'
211
+ +'<h3 class="rk-agency-name">'+escapeHtml(title)+'</h3>'
212
+ +'<div class="rk-agency-email" role="button" tabindex="0" data-rk-copy-text="'+escapeHtml(account.email||"")+'" title="Copy email">'+escapeHtml(account.email||"No email")+'</div>'
213
+ +'</div>'
214
+ +seenPill
215
+ +'</div>'
216
+ +'<div class="rk-agency-idrow">'
217
+ +'<span class="rk-agency-user" role="button" tabindex="0" data-rk-copy-text="'+escapeHtml(account.userId)+'" title="Copy user id">User '+escapeHtml(account.userId)+'</span>'
218
+ +'</div>'
219
+ +'<div class="rk-agency-card-foot">'
220
+ +'<button class="rk-btn rk-btn-ink rk-btn-sm rk-agency-open" type="button" data-rk-open-tab="'+escapeHtml(openTarget)+'">Open workspace <span class="rk-arrow">→</span></button>'
221
+ +'<button class="rk-btn rk-btn-ghost rk-btn-sm" type="button" data-rk-copy-login="'+escapeHtml(account.autologinUrl)+'">Copy login</button>'
222
+ +'</div>'
223
+ +'</article>';
224
+ }
225
+
226
+ function flash(el,label){
227
+ if(!el)return;
228
+ var prev=el.textContent;
229
+ el.textContent=label;
230
+ window.setTimeout(function(){el.textContent=prev;},1300);
231
+ }
232
+ function copy(value,el){
233
+ if(!value)return;
234
+ if(navigator.clipboard&&navigator.clipboard.writeText){
235
+ navigator.clipboard.writeText(value).then(function(){flash(el,"Copied");},function(){flash(el,"Clipboard blocked");});
236
+ }else{flash(el,"Clipboard blocked");}
237
+ }
238
+
239
+ function wire(){
240
+ if(!cardsWrap)return;
241
+ cardsWrap.querySelectorAll('[data-rk-copy-text]').forEach(function(el){
242
+ var run=function(){copy(el.getAttribute('data-rk-copy-text'),el);};
243
+ el.addEventListener('click',run);
244
+ el.addEventListener('keydown',function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();run();}});
245
+ });
246
+ cardsWrap.querySelectorAll('[data-rk-copy-login]').forEach(function(btn){
247
+ btn.addEventListener('click',function(){
248
+ var rel=btn.getAttribute('data-rk-copy-login')||'';
249
+ var abs=rel;try{abs=new URL(rel,window.location.origin).toString();}catch(_){}
250
+ copy(abs,btn);
251
+ });
252
+ });
253
+ cardsWrap.querySelectorAll('[data-rk-open-tab]').forEach(function(btn){
254
+ btn.addEventListener('click',function(){
255
+ var target=btn.getAttribute('data-rk-open-tab');
256
+ if(target)window.open(target,'_blank','noopener,noreferrer');
257
+ });
258
+ });
259
+ }
260
+
261
+ function render(){
262
+ if(!cardsWrap)return;
263
+ var accounts=readAccounts();
264
+ var total=accounts.length;
265
+ if(countEl)countEl.textContent=String(total);
266
+ if(pluralEl)pluralEl.textContent=total===1?'':'s';
267
+
268
+ var filtered=accounts.filter(matchesSearch);
269
+ cardsWrap.innerHTML=filtered.map(cardHtml).join('');
270
+ wire();
271
+
272
+ // hide the "add client" invite while actively searching (it isn't a result)
273
+ if(invite)invite.hidden=!!searchQuery.trim();
274
+
275
+ if(empty&&emptyMsg){
276
+ var showEmpty=false,msg='';
277
+ if(total===0){showEmpty=true;msg='No cached accounts found in this browser yet.';}
278
+ else if(filtered.length===0){showEmpty=true;msg='No agency clients match that search.';}
279
+ emptyMsg.textContent=msg;
280
+ empty.hidden=!showEmpty;
281
+ empty.setAttribute('data-rk-shown',showEmpty?'1':'0');
282
+ }
283
+ }
284
+
285
+ if(search){
286
+ search.addEventListener('input',function(){searchQuery=search.value||'';render();});
287
+ }
288
+ render();
289
+ })();`;
290
+ return reskinDocument({
291
+ title: "vidfarm reskin — Agency",
292
+ description: "Reskinned vidfarm agency page — cached client workspaces, open library, copy autologin.",
293
+ activeSlug: "agency",
294
+ pageCss,
295
+ body,
296
+ script
297
+ });
298
+ }
299
+ //# sourceMappingURL=agency-page.js.map