@mevdragon/vidfarm-devcli 0.18.1 → 0.19.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 (42) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +166 -0
  2. package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
  3. package/.agents/skills/vidfarm-media/SKILL.md +2 -0
  4. package/SKILL.director.md +174 -16
  5. package/SKILL.platform.md +7 -3
  6. package/demo/dist/app.css +1 -1
  7. package/demo/dist/app.js +77 -75
  8. package/dist/src/app.js +1513 -145
  9. package/dist/src/cli.js +1365 -105
  10. package/dist/src/config.js +7 -0
  11. package/dist/src/devcli/clips.js +3 -0
  12. package/dist/src/devcli/composition-edit.js +396 -8
  13. package/dist/src/devcli/local-backend.js +123 -0
  14. package/dist/src/devcli/migrate-local.js +140 -0
  15. package/dist/src/devcli/sync.js +311 -0
  16. package/dist/src/devcli/timeline-edit.js +208 -1
  17. package/dist/src/editor-chat.js +33 -17
  18. package/dist/src/frontend/file-directory.js +271 -20
  19. package/dist/src/frontend/homepage-view.js +3 -3
  20. package/dist/src/frontend/template-editor-chat.js +6 -3
  21. package/dist/src/page-shell.js +1 -1
  22. package/dist/src/primitive-registry.js +409 -4
  23. package/dist/src/reskin/chat-page.js +103 -15
  24. package/dist/src/reskin/discover-page.js +247 -10
  25. package/dist/src/reskin/document.js +147 -10
  26. package/dist/src/reskin/inpaint-clipper-page.js +649 -0
  27. package/dist/src/reskin/inpaint-page.js +2459 -452
  28. package/dist/src/reskin/inpaint-video-page.js +1339 -0
  29. package/dist/src/reskin/library-page.js +324 -82
  30. package/dist/src/reskin/theme.js +36 -11
  31. package/dist/src/services/billing.js +4 -0
  32. package/dist/src/services/clip-curation/hunt.js +2 -0
  33. package/dist/src/services/clip-records.js +28 -0
  34. package/dist/src/services/file-directory.js +6 -3
  35. package/dist/src/services/hyperframes.js +283 -3
  36. package/dist/src/services/serverless-records.js +43 -0
  37. package/dist/src/services/storage.js +24 -2
  38. package/package.json +1 -1
  39. package/public/assets/file-directory-app.js +2 -2
  40. package/public/assets/homepage-client-app.js +1 -1
  41. package/public/assets/page-runtime-client-app.js +2 -2
  42. package/public/assets/placeholders/scene-placeholder.png +0 -0
@@ -110,6 +110,58 @@ const removeBackgroundImagePayloadSchema = z.preprocess((raw) => {
110
110
  source_image_url: z.string().url(),
111
111
  output_format: z.enum(["png", "jpeg", "webp"]).default("webp")
112
112
  }));
113
+ // Transparent-capable output only (jpeg has no alpha) for the chroma-key routes.
114
+ const alphaOutputFormatSchema = z.enum(["png", "webp"]).default("png");
115
+ const greenscreenRemoveBackgroundPayloadSchema = z.preprocess((raw) => {
116
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
117
+ return raw;
118
+ const p = raw;
119
+ return {
120
+ ...p,
121
+ source_image_url: p.source_image_url ?? p.image_url ?? p.imageUrl ?? p.url,
122
+ key_color: p.key_color ?? p.keyColor ?? p.background_color ?? p.backgroundColor ?? p.color,
123
+ tolerance: p.tolerance ?? p.threshold,
124
+ softness: p.softness ?? p.feather,
125
+ despill: p.despill ?? p.spill_suppression ?? p.spillSuppression
126
+ };
127
+ }, z.object({
128
+ source_image_url: z.string().url(),
129
+ // The background color to key out. Green by default; pass white/#FFFFFF,
130
+ // blue, or any hex/rgb/named color to key a different flat background.
131
+ key_color: z.string().min(1).max(64).default("#00FF00"),
132
+ // Base similarity radius (0..1). Larger = more aggressive removal.
133
+ tolerance: z.coerce.number().min(0).max(1).default(0.3),
134
+ // Feather band beyond `tolerance` for soft anti-aliased edges (0..1).
135
+ softness: z.coerce.number().min(0).max(1).default(0.1),
136
+ // Suppress residual key-color fringe on the subject's edges.
137
+ despill: z.boolean().default(true),
138
+ output_format: alphaOutputFormatSchema
139
+ }));
140
+ const mediaOverlayPayloadSchema = z.preprocess((raw) => {
141
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
142
+ return raw;
143
+ const p = raw;
144
+ return {
145
+ ...p,
146
+ key_color: p.key_color ?? p.keyColor ?? p.background_color ?? p.backgroundColor,
147
+ tolerance: p.tolerance ?? p.threshold,
148
+ softness: p.softness ?? p.feather,
149
+ despill: p.despill ?? p.spill_suppression ?? p.spillSuppression,
150
+ prompt_attachments: p.prompt_attachments ?? p.promptAttachments ?? p.reference_attachments
151
+ };
152
+ }, z.object({
153
+ provider: imageProviderSchema.optional(),
154
+ model: z.string().min(1).optional(),
155
+ prompt: z.string().min(3).max(8000),
156
+ prompt_attachments: z.array(z.string().url()).max(16).default([]),
157
+ aspect_ratio: aspectRatioSchema,
158
+ image_size: imageSizeSchema,
159
+ key_color: z.string().min(1).max(64).default("#00FF00"),
160
+ tolerance: z.coerce.number().min(0).max(1).default(0.3),
161
+ softness: z.coerce.number().min(0).max(1).default(0.1),
162
+ despill: z.boolean().default(true),
163
+ output_format: alphaOutputFormatSchema
164
+ }));
113
165
  const renderHtmlImagePayloadSchema = z.object({
114
166
  width: z.number().int().min(16).max(4096).default(1080),
115
167
  height: z.number().int().min(16).max(4096).default(1920),
@@ -634,6 +686,8 @@ const PRIMITIVE_IMAGE_EDIT_ID = "primitive:image_edit";
634
686
  const PRIMITIVE_IMAGE_INPAINT_ID = "primitive:image_inpaint";
635
687
  const PRIMITIVE_IMAGE_RENDER_HTML_ID = "primitive:image_render_html";
636
688
  const PRIMITIVE_IMAGE_REMOVE_BACKGROUND_ID = "primitive:image_remove_background";
689
+ const PRIMITIVE_IMAGE_REMOVE_BACKGROUND_GREENSCREEN_ID = "primitive:image_remove_background_greenscreen";
690
+ const PRIMITIVE_MEDIA_OVERLAY_ID = "primitive:media_overlay";
637
691
  const PRIMITIVE_VIDEO_GENERATE_ID = "primitive:video_generate";
638
692
  const PRIMITIVE_VIDEO_RENDER_SLIDES_ID = "primitive:video_render_slides";
639
693
  const PRIMITIVE_VIDEO_NORMALIZE_ID = "primitive:video_normalize";
@@ -963,6 +1017,172 @@ const imageRemoveBackgroundPrimitive = definePrimitive({
963
1017
  }
964
1018
  }
965
1019
  });
1020
+ const imageRemoveBackgroundGreenscreenPrimitive = definePrimitive({
1021
+ id: PRIMITIVE_IMAGE_REMOVE_BACKGROUND_GREENSCREEN_ID,
1022
+ kind: "image_remove_background_greenscreen",
1023
+ operations: {
1024
+ run: {
1025
+ description: "Cheap local chroma-key background removal. Drops a flat, solid key-color background (green screen by default; pass key_color for white/blue/any flat fill) to a transparent PNG/WebP cut-out using sharp — no third-party API, compute only. Ideal for images you generated on a controlled solid background; for arbitrary photos with a messy background use image_remove_background (RapidAPI matting) instead.",
1026
+ inputSchema: greenscreenRemoveBackgroundPayloadSchema,
1027
+ workflow: "run"
1028
+ }
1029
+ },
1030
+ jobs: {
1031
+ async run(ctx, input) {
1032
+ const payload = greenscreenRemoveBackgroundPayloadSchema.parse(input);
1033
+ const sourceUrl = payload.source_image_url;
1034
+ ctx.logger.progress(0.2, "Downloading source image for chroma key", { sourceUrl });
1035
+ const source = await fetchRemoteBinary(sourceUrl, "image/png");
1036
+ ctx.logger.progress(0.5, "Keying out background", { keyColor: payload.key_color });
1037
+ const keyed = await chromaKeyRemoveBackground(source.bytes, {
1038
+ keyColor: payload.key_color,
1039
+ tolerance: payload.tolerance,
1040
+ softness: payload.softness,
1041
+ despill: payload.despill,
1042
+ outputFormat: payload.output_format
1043
+ });
1044
+ const stored = await storePrimitiveImage(ctx, {
1045
+ key: `output.${keyed.contentType === "image/webp" ? "webp" : "png"}`,
1046
+ bytes: keyed.bytes,
1047
+ contentType: keyed.contentType,
1048
+ metadata: {
1049
+ primitive: "image_remove_background_greenscreen",
1050
+ source_image_url: sourceUrl,
1051
+ key_color: payload.key_color,
1052
+ tolerance: payload.tolerance,
1053
+ softness: payload.softness,
1054
+ despill: payload.despill,
1055
+ keyed_fraction: keyed.keyedFraction
1056
+ }
1057
+ });
1058
+ await ctx.billing.record({
1059
+ type: "cpu_estimate",
1060
+ costUsd: config.GREENSCREEN_CHROMA_KEY_COST_USD,
1061
+ costCenterSlug: "greenscreen_chroma_key",
1062
+ occurredAtMs: Date.now(),
1063
+ metadata: {
1064
+ operation: "remove-background-greenscreen",
1065
+ source_image_url: sourceUrl,
1066
+ key_color: payload.key_color,
1067
+ keyed_fraction: keyed.keyedFraction,
1068
+ output_key: stored.key
1069
+ }
1070
+ });
1071
+ ctx.logger.progress(1, "Chroma-key background removal complete");
1072
+ return {
1073
+ progress: 1,
1074
+ output: {
1075
+ ...buildImagePrimitiveOutput({
1076
+ fileUrl: stored.url,
1077
+ contentType: keyed.contentType,
1078
+ derivedFromUrl: sourceUrl,
1079
+ files: compactUrls([stored.url])
1080
+ }),
1081
+ key_color: payload.key_color,
1082
+ keyed_fraction: keyed.keyedFraction
1083
+ }
1084
+ };
1085
+ }
1086
+ }
1087
+ });
1088
+ const mediaOverlayPrimitive = definePrimitive({
1089
+ id: PRIMITIVE_MEDIA_OVERLAY_ID,
1090
+ kind: "media_overlay",
1091
+ operations: {
1092
+ run: {
1093
+ description: "Create a transparent-background media overlay in ONE job: generate an AI image of the subject on a forced flat key-color background (using the caller's own BYOK image keys), then chroma-key that background out to a ready-to-composite transparent PNG/WebP. This is the go-to way to mint 'Vox-style' floating illustrations, props, icons, or cut-out characters to animate over a composition — prefer it over generate-then-manually-remove.",
1094
+ inputSchema: mediaOverlayPayloadSchema,
1095
+ workflow: "run",
1096
+ providerHint: "openai"
1097
+ }
1098
+ },
1099
+ jobs: {
1100
+ async run(ctx, input) {
1101
+ const payload = mediaOverlayPayloadSchema.parse(input);
1102
+ const provider = payload.provider ?? "openai";
1103
+ const model = payload.model ?? defaultImageModelForProvider(provider);
1104
+ const keyRgb = parseKeyColor(payload.key_color);
1105
+ const keyLabel = describeKeyColor(payload.key_color, keyRgb);
1106
+ const augmentedPrompt = buildOverlayGenerationPrompt(payload.prompt, payload.key_color, keyLabel);
1107
+ ctx.logger.progress(0.12, "Generating overlay subject on flat key-color background", { provider, model, keyColor: payload.key_color });
1108
+ const generated = await ctx.providers.generateImage({
1109
+ provider,
1110
+ model,
1111
+ prompt: augmentedPrompt,
1112
+ promptAttachments: payload.prompt_attachments,
1113
+ aspectRatio: payload.aspect_ratio,
1114
+ imageSize: payload.image_size
1115
+ });
1116
+ // Keep the raw key-color frame as an artifact for debugging / re-keying at
1117
+ // different tolerances without paying for a second generation.
1118
+ const normalizedSource = await normalizePrimitiveImageOutput(generated.bytes, "png");
1119
+ const sourceStored = await storePrimitiveImage(ctx, {
1120
+ key: "source-greenscreen.png",
1121
+ bytes: normalizedSource.bytes,
1122
+ contentType: normalizedSource.contentType,
1123
+ metadata: {
1124
+ primitive: "media_overlay",
1125
+ stage: "greenscreen_source",
1126
+ prompt: payload.prompt,
1127
+ key_color: payload.key_color,
1128
+ revised_prompt: generated.revisedPrompt
1129
+ }
1130
+ });
1131
+ ctx.logger.progress(0.62, "Keying out background", { keyColor: payload.key_color });
1132
+ const keyed = await chromaKeyRemoveBackground(generated.bytes, {
1133
+ keyColor: payload.key_color,
1134
+ tolerance: payload.tolerance,
1135
+ softness: payload.softness,
1136
+ despill: payload.despill,
1137
+ outputFormat: payload.output_format
1138
+ });
1139
+ const stored = await storePrimitiveImage(ctx, {
1140
+ key: `output.${keyed.contentType === "image/webp" ? "webp" : "png"}`,
1141
+ bytes: keyed.bytes,
1142
+ contentType: keyed.contentType,
1143
+ metadata: {
1144
+ primitive: "media_overlay",
1145
+ stage: "transparent_overlay",
1146
+ prompt: payload.prompt,
1147
+ provider,
1148
+ model,
1149
+ key_color: payload.key_color,
1150
+ keyed_fraction: keyed.keyedFraction,
1151
+ revised_prompt: generated.revisedPrompt
1152
+ }
1153
+ });
1154
+ await ctx.billing.record({
1155
+ type: "cpu_estimate",
1156
+ costUsd: config.MEDIA_OVERLAY_COST_USD,
1157
+ costCenterSlug: "media_overlay",
1158
+ occurredAtMs: Date.now(),
1159
+ metadata: {
1160
+ operation: "create-media-overlay",
1161
+ prompt: payload.prompt,
1162
+ key_color: payload.key_color,
1163
+ keyed_fraction: keyed.keyedFraction,
1164
+ output_key: stored.key,
1165
+ greenscreen_source_key: sourceStored.key
1166
+ }
1167
+ });
1168
+ ctx.logger.progress(1, "Media overlay ready");
1169
+ return {
1170
+ progress: 1,
1171
+ output: {
1172
+ ...buildImagePrimitiveOutput({
1173
+ fileUrl: stored.url,
1174
+ contentType: keyed.contentType,
1175
+ revisedPrompt: generated.revisedPrompt,
1176
+ files: compactUrls([stored.url, sourceStored.url])
1177
+ }),
1178
+ greenscreen_source_url: sourceStored.url,
1179
+ key_color: payload.key_color,
1180
+ keyed_fraction: keyed.keyedFraction
1181
+ }
1182
+ };
1183
+ }
1184
+ }
1185
+ });
966
1186
  const videoGeneratePrimitive = definePrimitive({
967
1187
  id: PRIMITIVE_VIDEO_GENERATE_ID,
968
1188
  kind: "video_generate",
@@ -1285,13 +1505,58 @@ const videoDownloadPrimitive = definePrimitive({
1285
1505
  upstream_source: lookup.source ?? null
1286
1506
  }
1287
1507
  });
1508
+ // Re-host a DURABLE poster so the frontend never depends on the ephemeral
1509
+ // third-party `lookup.thumbnail` URL. Prefer extracting a frame from the
1510
+ // MP4 we just stored (works even when the provider gives no thumbnail);
1511
+ // fall back to re-hosting the provider thumbnail bytes. Best-effort.
1512
+ let durableThumbnailUrl = null;
1513
+ ctx.logger.progress(0.7, "Extracting thumbnail frame");
1514
+ try {
1515
+ const frame = await executePrimitiveMediaOperation(ctx, {
1516
+ operation: "video_extract_frame",
1517
+ payload: { source_video_url: storedVideo.url, at_ms: 1000, output_format: "jpeg" },
1518
+ outputKey: "thumbnail.jpg"
1519
+ });
1520
+ durableThumbnailUrl = frame.publicUrl;
1521
+ }
1522
+ catch (error) {
1523
+ ctx.logger.progress(0.75, "Thumbnail frame extraction skipped", {
1524
+ error: error instanceof Error ? error.message : String(error)
1525
+ });
1526
+ }
1527
+ if (!durableThumbnailUrl && lookup.thumbnail) {
1528
+ try {
1529
+ const remoteThumb = await fetchRemoteBinary(lookup.thumbnail, "image/jpeg");
1530
+ const thumbContentType = remoteThumb.contentType.startsWith("image/") ? remoteThumb.contentType : "image/jpeg";
1531
+ const storedThumb = await storePrimitiveImage(ctx, {
1532
+ key: "thumbnail.jpg",
1533
+ bytes: remoteThumb.bytes,
1534
+ contentType: thumbContentType,
1535
+ metadata: {
1536
+ primitive: "video_download",
1537
+ operation: "download-video",
1538
+ source_url: sourceUrl,
1539
+ rehosted_from: lookup.thumbnail
1540
+ }
1541
+ });
1542
+ durableThumbnailUrl = storedThumb.url;
1543
+ }
1544
+ catch (error) {
1545
+ ctx.logger.progress(0.78, "Thumbnail re-host skipped", {
1546
+ error: error instanceof Error ? error.message : String(error)
1547
+ });
1548
+ }
1549
+ }
1288
1550
  const manifest = {
1289
1551
  primitive: "video_download",
1290
1552
  operation: "download-video",
1291
1553
  sourceUrl,
1292
1554
  sourceHost,
1293
1555
  title: lookup.title ?? null,
1294
- thumbnail: lookup.thumbnail ?? null,
1556
+ // Raw ephemeral third-party thumbnail (kept for provenance) plus the
1557
+ // durable re-hosted poster the finalizer/frontend should actually use.
1558
+ thumbnail: durableThumbnailUrl ?? lookup.thumbnail ?? null,
1559
+ rawThumbnail: lookup.thumbnail ?? null,
1295
1560
  duration: lookup.duration ?? null,
1296
1561
  source: lookup.source ?? null,
1297
1562
  selectedMedia: {
@@ -1336,19 +1601,22 @@ const videoDownloadPrimitive = definePrimitive({
1336
1601
  });
1337
1602
  ctx.logger.progress(1, "Video download complete", {
1338
1603
  videoUrl: storedVideo.url,
1339
- sourceHost
1604
+ sourceHost,
1605
+ thumbnailUrl: durableThumbnailUrl
1340
1606
  });
1341
1607
  return {
1342
1608
  progress: 1,
1343
1609
  output: {
1344
- files: compactUrls([storedVideo.url, manifestArtifact?.url]),
1610
+ files: compactUrls([storedVideo.url, durableThumbnailUrl, manifestArtifact?.url]),
1345
1611
  sourceUrl,
1346
1612
  sourceHost,
1347
1613
  videoUrl: storedVideo.url,
1348
1614
  sourceVideoUrl: storedVideo.url,
1349
1615
  primary_file_url: storedVideo.url,
1350
1616
  title: lookup.title ?? null,
1351
- thumbnail: lookup.thumbnail ?? null,
1617
+ // Durable re-hosted poster (falls back to the raw provider thumbnail).
1618
+ thumbnailUrl: durableThumbnailUrl ?? lookup.thumbnail ?? null,
1619
+ thumbnail: durableThumbnailUrl ?? lookup.thumbnail ?? null,
1352
1620
  duration: lookup.duration ?? null,
1353
1621
  selectedMediaUrl: selectedMedia.url,
1354
1622
  manifest: manifestArtifact
@@ -2824,6 +3092,8 @@ class PrimitiveRegistry {
2824
3092
  [imageInpaintPrimitive.id, imageInpaintPrimitive],
2825
3093
  [imageRenderHtmlPrimitive.id, imageRenderHtmlPrimitive],
2826
3094
  [imageRemoveBackgroundPrimitive.id, imageRemoveBackgroundPrimitive],
3095
+ [imageRemoveBackgroundGreenscreenPrimitive.id, imageRemoveBackgroundGreenscreenPrimitive],
3096
+ [mediaOverlayPrimitive.id, mediaOverlayPrimitive],
2827
3097
  [videoGeneratePrimitive.id, videoGeneratePrimitive],
2828
3098
  [videoRenderSlidesPrimitive.id, videoRenderSlidesPrimitive],
2829
3099
  [mediaDedupePrimitive.id, mediaDedupePrimitive],
@@ -3017,6 +3287,141 @@ async function normalizePrimitiveImageOutput(bytes, format) {
3017
3287
  contentType: "image/png"
3018
3288
  };
3019
3289
  }
3290
+ function clampByte(value) {
3291
+ const n = Math.round(Number.isFinite(value) ? value : 0);
3292
+ return Math.max(0, Math.min(255, n));
3293
+ }
3294
+ const NAMED_KEY_COLORS = {
3295
+ green: [0, 255, 0],
3296
+ lime: [0, 255, 0],
3297
+ "chroma-green": [0, 177, 64],
3298
+ "chromakey-green": [0, 177, 64],
3299
+ white: [255, 255, 255],
3300
+ black: [0, 0, 0],
3301
+ blue: [0, 0, 255],
3302
+ "chroma-blue": [0, 71, 187],
3303
+ "chromakey-blue": [0, 71, 187],
3304
+ red: [255, 0, 0],
3305
+ magenta: [255, 0, 255],
3306
+ cyan: [0, 255, 255],
3307
+ gray: [128, 128, 128],
3308
+ grey: [128, 128, 128]
3309
+ };
3310
+ // Parse a key color from hex (#rgb / #rrggbb), rgb()/rgba() triples, or a small
3311
+ // set of named colors. Throws on anything unrecognized so a typo surfaces
3312
+ // instead of silently keying out the wrong color.
3313
+ function parseKeyColor(value) {
3314
+ const raw = value.trim().toLowerCase();
3315
+ const named = NAMED_KEY_COLORS[raw];
3316
+ if (named) {
3317
+ return { r: named[0], g: named[1], b: named[2] };
3318
+ }
3319
+ const rgbMatch = raw.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
3320
+ if (rgbMatch) {
3321
+ return { r: clampByte(Number(rgbMatch[1])), g: clampByte(Number(rgbMatch[2])), b: clampByte(Number(rgbMatch[3])) };
3322
+ }
3323
+ let hex = raw.startsWith("#") ? raw.slice(1) : raw;
3324
+ if (/^[0-9a-f]{3}$/.test(hex)) {
3325
+ hex = hex.split("").map((ch) => ch + ch).join("");
3326
+ }
3327
+ if (/^[0-9a-f]{6}$/.test(hex)) {
3328
+ return {
3329
+ r: parseInt(hex.slice(0, 2), 16),
3330
+ g: parseInt(hex.slice(2, 4), 16),
3331
+ b: parseInt(hex.slice(4, 6), 16)
3332
+ };
3333
+ }
3334
+ throw new Error(`Unrecognized key_color "${value}". Use a hex value (e.g. #00FF00), an rgb(...) triple, or a named color (green/white/blue/black).`);
3335
+ }
3336
+ // A plain-English label for the key color so the image-generation prompt can
3337
+ // tell the model exactly which flat background to paint.
3338
+ function describeKeyColor(raw, rgb) {
3339
+ const named = raw.trim().toLowerCase();
3340
+ if (/green|lime/.test(named))
3341
+ return "chroma green";
3342
+ if (/blue/.test(named))
3343
+ return "chroma blue";
3344
+ if (named === "white" || (rgb.r > 240 && rgb.g > 240 && rgb.b > 240))
3345
+ return "solid white";
3346
+ if (named === "black" || (rgb.r < 15 && rgb.g < 15 && rgb.b < 15))
3347
+ return "solid black";
3348
+ if (rgb.g > rgb.r && rgb.g > rgb.b)
3349
+ return "chroma green";
3350
+ if (rgb.b > rgb.r && rgb.b > rgb.g)
3351
+ return "chroma blue";
3352
+ return "flat solid";
3353
+ }
3354
+ function buildOverlayGenerationPrompt(subject, keyHex, keyLabel) {
3355
+ return [
3356
+ subject.trim(),
3357
+ "",
3358
+ `BACKGROUND REQUIREMENT (critical for clean keying): render ONLY the subject, fully in frame, on a completely flat, uniform, evenly-lit ${keyLabel} background (target color ${keyHex}). The background must be one single solid ${keyLabel} fill — absolutely NO gradient, NO vignette, NO cast shadows or reflections on the background, and NO ${keyLabel} tones anywhere on the subject itself (avoid ${keyLabel} clothing, props, or rim light). Do not add any border, frame, text, or watermark. Give the subject clean, crisp edges against the ${keyLabel} so it can be cut out to a transparent PNG.`
3359
+ ].join("\n");
3360
+ }
3361
+ // Local chroma-key background removal. Distance-thresholds every pixel against
3362
+ // the key color: within `tolerance` becomes fully transparent, a `softness`
3363
+ // feather band anti-aliases the edge, and (for chromatic keys) a classic
3364
+ // despill clamps the key's dominant channel so no colored fringe survives.
3365
+ async function chromaKeyRemoveBackground(source, options) {
3366
+ const key = parseKeyColor(options.keyColor);
3367
+ const buffer = Buffer.isBuffer(source) ? source : Buffer.from(source);
3368
+ const { data, info } = await sharp(buffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
3369
+ const channels = info.channels;
3370
+ const maxDist = Math.sqrt(3) * 255;
3371
+ const inner = Math.max(0, options.tolerance) * maxDist;
3372
+ const outer = Math.max(inner + 1e-6, (options.tolerance + Math.max(0, options.softness)) * maxDist);
3373
+ const keyChannels = [key.r, key.g, key.b];
3374
+ const keyChroma = Math.max(...keyChannels) - Math.min(...keyChannels);
3375
+ // Only despill chromatic keys (green/blue/etc.); white/gray/black have no hue
3376
+ // fringe to suppress and clamping them would tint the subject.
3377
+ const doDespill = options.despill && keyChroma >= 40;
3378
+ const dominant = keyChannels.indexOf(Math.max(...keyChannels));
3379
+ let keyedPixels = 0;
3380
+ const totalPixels = info.width * info.height;
3381
+ for (let i = 0; i < data.length; i += channels) {
3382
+ const r = data[i];
3383
+ const g = data[i + 1];
3384
+ const b = data[i + 2];
3385
+ const baseAlpha = channels === 4 ? data[i + 3] : 255;
3386
+ const dr = r - key.r;
3387
+ const dg = g - key.g;
3388
+ const db = b - key.b;
3389
+ const dist = Math.sqrt(dr * dr + dg * dg + db * db);
3390
+ let alpha;
3391
+ if (dist <= inner) {
3392
+ alpha = 0;
3393
+ keyedPixels++;
3394
+ }
3395
+ else if (dist >= outer) {
3396
+ alpha = baseAlpha;
3397
+ }
3398
+ else {
3399
+ const t = (dist - inner) / (outer - inner);
3400
+ alpha = Math.round(baseAlpha * t);
3401
+ }
3402
+ if (doDespill && alpha > 0) {
3403
+ if (dominant === 1)
3404
+ data[i + 1] = Math.min(g, Math.max(r, b));
3405
+ else if (dominant === 0)
3406
+ data[i] = Math.min(r, Math.max(g, b));
3407
+ else
3408
+ data[i + 2] = Math.min(b, Math.max(r, g));
3409
+ }
3410
+ if (channels === 4)
3411
+ data[i + 3] = alpha;
3412
+ }
3413
+ const encoded = sharp(data, { raw: { width: info.width, height: info.height, channels } });
3414
+ const bytes = options.outputFormat === "webp"
3415
+ ? await encoded.webp({ quality: 92, alphaQuality: 100 }).toBuffer()
3416
+ : await encoded.png().toBuffer();
3417
+ return {
3418
+ bytes,
3419
+ contentType: options.outputFormat === "webp" ? "image/webp" : "image/png",
3420
+ width: info.width,
3421
+ height: info.height,
3422
+ keyedFraction: totalPixels > 0 ? keyedPixels / totalPixels : 0
3423
+ };
3424
+ }
3020
3425
  async function recordPrimitiveProviderBilling(ctx, generated, kind) {
3021
3426
  if (!Number.isFinite(generated.usageCostUsd) || generated.usageCostUsd <= 0) {
3022
3427
  return;
@@ -58,7 +58,9 @@ export function renderReskinChat(input = DEFAULT_INPUT, opts = {}) {
58
58
  <label class="rk-chat-mode" aria-label="Studio mode">
59
59
  <select class="rk-chat-mode-select" id="rk-chat-mode">
60
60
  <option value="/chat" selected>Chat</option>
61
- <option value="/inpaint">Create Image</option>
61
+ <option value="/tools/image">Create Image</option>
62
+ <option value="/tools/clipper">Clip a Video</option>
63
+ <option value="/editor/original/fork/new">Create Video</option>
62
64
  </select>
63
65
  <span class="rk-chat-mode-chev" aria-hidden="true">&#9662;</span>
64
66
  </label>
@@ -139,15 +141,18 @@ export function renderReskinChat(input = DEFAULT_INPUT, opts = {}) {
139
141
  .rk-chat-head-actions{display:flex;align-items:center;gap:9px;flex:none}
140
142
  .rk-chat-plus{font-size:14px;font-weight:700;line-height:1;margin-right:1px}
141
143
 
142
- /* studio mode select (Brainstorm ↔ Create Image → /reskin/inpaint) */
144
+ /* studio mode select (Chat ↔ Create Image → /tools/image ↔ Create Video → /editor).
145
+ A prominent gold pill so it reads clearly as the "what do you want to make?"
146
+ switcher, not a faint afterthought. */
143
147
  .rk-chat-mode{position:relative;display:inline-flex;align-items:center}
144
- .rk-chat-mode-select{appearance:none;-webkit-appearance:none;font-family:var(--rk-font-body);font-size:13px;font-weight:700;
145
- color:var(--rk-ink);background:var(--rk-surface);border:1px solid var(--rk-border-strong);border-radius:var(--rk-r-full);
146
- padding:9px 32px 9px 15px;cursor:pointer;box-shadow:var(--rk-shadow-xs);
147
- transition:border-color var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease)}
148
- .rk-chat-mode-select:hover{border-color:var(--rk-gold-600)}
149
- .rk-chat-mode-select:focus{outline:none;border-color:var(--rk-gold-600);box-shadow:var(--rk-ring-gold)}
150
- .rk-chat-mode-chev{position:absolute;right:13px;top:50%;transform:translateY(-50%);pointer-events:none;font-size:11px;color:var(--rk-n-500)}
148
+ .rk-chat-mode-select{appearance:none;-webkit-appearance:none;font-family:var(--rk-font-body);font-size:13.5px;font-weight:800;
149
+ letter-spacing:-.01em;color:var(--rk-ink);background:var(--rk-gold-tint);border:1.5px solid var(--rk-gold-600);
150
+ border-radius:var(--rk-r-full);padding:10px 38px 10px 17px;cursor:pointer;box-shadow:var(--rk-shadow-sm);
151
+ transition:border-color var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease)}
152
+ .rk-chat-mode-select:hover{background:var(--rk-gold-tint2);border-color:var(--rk-gold-700);box-shadow:var(--rk-shadow-md,var(--rk-shadow-sm))}
153
+ .rk-chat-mode-select:focus{outline:none;border-color:var(--rk-gold-700);box-shadow:var(--rk-ring-gold)}
154
+ .rk-chat-mode-chev{position:absolute;right:15px;top:50%;transform:translateY(-50%);pointer-events:none;font-size:12px;
155
+ font-weight:900;color:var(--rk-gold-700)}
151
156
 
152
157
  /* thread — flex:1 pushes the dock to the bottom when the page is short */
153
158
  .rk-chat-thread{flex:1;display:grid;gap:28px;align-content:start;padding:30px 0 26px}
@@ -255,6 +260,8 @@ export function renderReskinChat(input = DEFAULT_INPUT, opts = {}) {
255
260
  .rk-chat-attach-chip .rk-chat-attach-preview{font-family:inherit;font-size:12.5px;font-weight:600;padding:0;max-width:200px;
256
261
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:underline;text-underline-offset:2px;text-decoration-color:transparent}
257
262
  .rk-chat-attach-chip .rk-chat-attach-preview:hover{text-decoration-color:currentColor}
263
+ .rk-chat-attach-chip.is-pending{opacity:.75}
264
+ .rk-chat-attach-chip.is-pending .rk-chat-attach-preview{text-decoration:none;cursor:default}
258
265
  .rk-chat-input{width:100%;min-width:0;min-height:76px;border:0;background:transparent;resize:none;font-family:var(--rk-font-body);
259
266
  font-size:15px;line-height:1.5;color:var(--rk-ink);padding:4px 4px 0;max-height:210px}
260
267
  .rk-chat-input:focus{outline:none;box-shadow:none}
@@ -314,10 +321,11 @@ export function renderReskinChat(input = DEFAULT_INPUT, opts = {}) {
314
321
  if(!chipsEl) return;
315
322
  chipsEl.innerHTML='';
316
323
  attachments.forEach(function(f, i){
317
- var chip=el('rk-chat-attach-chip');
324
+ var chip=el('rk-chat-attach-chip'+(f.pending?' is-pending':''));
325
+ if(f.pending){ var sp=root.createElement('span'); sp.className='rk-aichat-chip-spin'; sp.setAttribute('aria-hidden','true'); chip.appendChild(sp); }
318
326
  var label=root.createElement('button'); label.type='button'; label.className='rk-chat-attach-preview';
319
- label.title='Preview '+f.name; label.textContent=f.name;
320
- if(f.viewUrl){ label.addEventListener('click',function(){ openFilePreview(attachments, i); }); }
327
+ label.title=f.pending?('Uploading '+f.name+'\\u2026'):('Preview '+f.name); label.textContent=f.name;
328
+ if(f.viewUrl && !f.pending){ label.addEventListener('click',function(){ var vs=attachments.filter(function(a){return !a.pending;}); openFilePreview(vs, vs.indexOf(f)); }); }
321
329
  chip.appendChild(label);
322
330
  var x=root.createElement('button'); x.type='button'; x.setAttribute('aria-label','Remove '+f.name); x.textContent='\\u00d7';
323
331
  x.addEventListener('click',function(){ attachments=attachments.filter(function(a){ return a.id!==f.id; }); renderAttachChips(); });
@@ -335,6 +343,46 @@ export function renderReskinChat(input = DEFAULT_INPUT, opts = {}) {
335
343
  if(input) input.focus();
336
344
  });
337
345
 
346
+ // ─── paste-to-attach: a copied image/file dropped into the composer gets
347
+ // auto-uploaded to the user's /temp folder, then attached like a picked file.
348
+ function pasteExtName(blob, idx){
349
+ var ct=(blob && blob.type)||'';
350
+ var base=ct.indexOf('image/')===0?'pasted-image':ct.indexOf('video/')===0?'pasted-video':ct.indexOf('audio/')===0?'pasted-audio':'pasted-file';
351
+ var ext=ct && ct.indexOf('/')>-1?ct.split('/')[1].split('+')[0].split(';')[0]:'bin';
352
+ if(ext==='jpeg') ext='jpg';
353
+ return base+'-'+Date.now().toString(36)+(idx?'-'+idx:'')+'.'+ext;
354
+ }
355
+ function uploadPasted(blob, fileName, pendingId){
356
+ var fd=new FormData(); fd.append('file', blob, fileName);
357
+ var h={}; if(API_KEY) h['vidfarm-api-key']=API_KEY;
358
+ fetch(window.location.origin+'/api/v1/user/me/temporary-files/upload', { method:'POST', credentials:'same-origin', headers:h, body:fd })
359
+ .then(function(r){ return r.json().catch(function(){ return {}; }).then(function(j){ if(!r.ok) throw new Error((j&&j.error)||('upload failed ('+r.status+')')); return j; }); })
360
+ .then(function(j){
361
+ var f=j&&j.file; if(!f||!f.viewUrl) throw new Error('upload returned no file');
362
+ var idx=attachments.map(function(a){ return a.id; }).indexOf(pendingId);
363
+ var item={ id:String(f.id), name:f.fileName||fileName, contentType:f.contentType||(blob&&blob.type)||'application/octet-stream', viewUrl:f.viewUrl };
364
+ if(idx>-1) attachments[idx]=item; else attachments.push(item);
365
+ renderAttachChips();
366
+ })
367
+ .catch(function(){ attachments=attachments.filter(function(a){ return a.id!==pendingId; }); renderAttachChips(); });
368
+ }
369
+ if(input) input.addEventListener('paste', function(e){
370
+ var dt=e.clipboardData; if(!dt) return;
371
+ var blobs=[], items=dt.items;
372
+ if(items && items.length){ for(var i=0;i<items.length;i++){ if(items[i] && items[i].kind==='file'){ var b=items[i].getAsFile(); if(b) blobs.push(b); } } }
373
+ if(!blobs.length && dt.files && dt.files.length){ for(var k=0;k<dt.files.length;k++) blobs.push(dt.files[k]); }
374
+ if(!blobs.length) return; // plain-text paste → leave it to the textarea
375
+ e.preventDefault();
376
+ blobs.forEach(function(blob, idx){
377
+ var name=(blob && blob.name)||pasteExtName(blob, idx);
378
+ var pendingId='paste-'+Date.now().toString(36)+'-'+idx+'-'+Math.random().toString(36).slice(2,6);
379
+ attachments.push({ id:pendingId, name:name, contentType:(blob&&blob.type)||'application/octet-stream', pending:true });
380
+ renderAttachChips();
381
+ uploadPasted(blob, name, pendingId);
382
+ });
383
+ if(input) input.focus();
384
+ });
385
+
338
386
  // Each conversation has its own URL (/chat/<id>); a fresh page is /chat.
339
387
  var pageEl=root.querySelector('.rk-chat-page');
340
388
  var INITIAL_THREAD=(pageEl && pageEl.getAttribute('data-initial-thread')) || null;
@@ -552,11 +600,22 @@ export function renderReskinChat(input = DEFAULT_INPUT, opts = {}) {
552
600
  else if(chunk.type==='error'){ h.onError((typeof chunk.errorText==='string')?chunk.errorText:((chunk.error && chunk.error.message)||'Unable to read assistant response.')); }
553
601
  }
554
602
 
603
+ // Attachment URL line the model can quote back as REST payload URLs, alongside
604
+ // the binary file content parts. Mirrors the /editor React dock. Double-
605
+ // backslash-n = a browser-side newline (this function lives in a template literal).
606
+ function buildAttachmentUrlText(atts){
607
+ if(!atts || !atts.length) return '';
608
+ var lines=atts.map(function(a){ return '- '+(a.fileName||'file')+' ('+(a.contentType||'')+'): '+a.viewUrl; });
609
+ return '\\n\\nAttached file URLs for REST payloads:\\n'+lines.join('\\n')+'\\nUse these exact URLs as prompt_attachments for image generation, source_image_url for image edits, media_url for video slides, or reference attachment URLs when calling template routes.';
610
+ }
611
+
555
612
  function sendMessage(text){
556
613
  if(busy) return;
557
614
  text=(text||'').trim();
558
615
  // Attachments picked from the drawer travel in user_message.attachments;
559
616
  // sending is allowed with only files (no text) too.
617
+ // A pasted file may still be uploading to /temp — wait rather than drop it.
618
+ if(attachments.some(function(f){ return f && f.pending; })){ renderAttachChips(); return; }
560
619
  var atts=attachments.map(function(f){ return { id:String(f.id), fileName:f.name, contentType:f.contentType, viewUrl:f.viewUrl }; });
561
620
  if(!text && !atts.length) return;
562
621
 
@@ -578,8 +637,19 @@ export function renderReskinChat(input = DEFAULT_INPUT, opts = {}) {
578
637
  var userMsgId=genId('user');
579
638
  var asstMsgId=genId('assistant');
580
639
 
640
+ // Attachments must ride in messages[].content as file parts + a URL text
641
+ // line — the backend feeds the model messages[].content, not the persistence-
642
+ // only user_message.attachments. Append to the latest user turn (= last entry).
643
+ var attUrlText = atts.length ? buildAttachmentUrlText(atts) : '';
581
644
  var body={
582
- messages: history.map(function(m){ return { role:m.role, content:[{ type:'text', text:m.text }] }; }),
645
+ messages: history.map(function(m, i){
646
+ var isLastUser = (i === history.length - 1) && m.role === 'user';
647
+ var t = m.text;
648
+ if(isLastUser && attUrlText) t = t + attUrlText;
649
+ var content = [{ type:'text', text:t }];
650
+ if(isLastUser && atts.length){ atts.forEach(function(a){ content.push({ type:'file', data:a.viewUrl, mediaType:a.contentType||'application/octet-stream' }); }); }
651
+ return { role:m.role, content:content };
652
+ }),
583
653
  thread_id: threadId,
584
654
  thread_template_id: (TEMPLATE && TEMPLATE.templateId) || 'chat-brainstorm',
585
655
  thread_title: (text||line).slice(0,42),
@@ -832,12 +902,30 @@ export function renderReskinChat(input = DEFAULT_INPUT, opts = {}) {
832
902
  newBtn.addEventListener('click',function(){ if(busy) return; resetChat(); });
833
903
  }
834
904
 
835
- // studio mode: choosing "Create Image" navigates to the inpaint workbench
905
+ // Navigate to another surface (Create Image /tools/image, Create Video → a new
906
+ // /editor project, or any AI-driven page nav) while HANDING OFF the live
907
+ // conversation: append ?rk_chat=<threadId> so the destination's AI pop-panel
908
+ // (document.ts) auto-opens and reloads this exact thread — a seamless jump that
909
+ // never loses the chat context. Uses a global URL param so any page's dock can
910
+ // honor it. No active thread → just navigate.
911
+ function navigateWithChat(dest){
912
+ if(!dest) return;
913
+ try{
914
+ if(threadId){
915
+ var u=new URL(dest, window.location.origin);
916
+ u.searchParams.set('rk_chat', threadId);
917
+ dest=u.pathname + u.search + u.hash;
918
+ }
919
+ }catch(e){}
920
+ window.location.href=dest;
921
+ }
922
+ // studio mode: choosing "Create Image" / "Create Video" navigates to that
923
+ // workbench, carrying the current chat thread along for the ride.
836
924
  var modeSel=root.getElementById('rk-chat-mode');
837
925
  if(modeSel){
838
926
  modeSel.addEventListener('change',function(){
839
927
  var dest=modeSel.value;
840
- if(dest && dest!=='/chat'){ window.location.href=dest; }
928
+ if(dest && dest!=='/chat'){ navigateWithChat(dest); }
841
929
  });
842
930
  }
843
931
  })();`;