@mevdragon/vidfarm-devcli 0.18.1 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/editor-capabilities/SKILL.md +166 -0
- package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
- package/.agents/skills/vidfarm-media/SKILL.md +2 -0
- package/SKILL.director.md +174 -16
- package/SKILL.platform.md +7 -3
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +79 -75
- package/dist/src/account-pages-legacy.js +1 -1
- package/dist/src/app.js +1694 -217
- package/dist/src/cli.js +1365 -105
- package/dist/src/config.js +8 -0
- package/dist/src/devcli/clips.js +3 -0
- package/dist/src/devcli/composition-edit.js +401 -10
- package/dist/src/devcli/local-backend.js +123 -0
- package/dist/src/devcli/migrate-local.js +140 -0
- package/dist/src/devcli/sync.js +311 -0
- package/dist/src/devcli/timeline-edit.js +208 -1
- package/dist/src/editor-chat.js +37 -18
- package/dist/src/frontend/file-directory.js +271 -20
- package/dist/src/frontend/homepage-view.js +3 -3
- package/dist/src/frontend/template-editor-chat.js +6 -3
- package/dist/src/page-shell.js +1 -1
- package/dist/src/primitive-context.js +31 -2
- package/dist/src/primitive-registry.js +409 -4
- package/dist/src/reskin/chat-page.js +103 -15
- package/dist/src/reskin/discover-page.js +247 -10
- package/dist/src/reskin/document.js +147 -10
- package/dist/src/reskin/inpaint-clipper-page.js +649 -0
- package/dist/src/reskin/inpaint-page.js +2459 -452
- package/dist/src/reskin/inpaint-video-page.js +1339 -0
- package/dist/src/reskin/library-page.js +324 -82
- package/dist/src/reskin/portfolio-page.js +687 -0
- package/dist/src/reskin/theme.js +36 -11
- package/dist/src/services/billing.js +4 -0
- package/dist/src/services/clip-curation/hunt.js +2 -0
- package/dist/src/services/clip-records.js +28 -0
- package/dist/src/services/file-directory.js +6 -3
- package/dist/src/services/hyperframes.js +535 -86
- package/dist/src/services/serverless-jobs.js +3 -1
- package/dist/src/services/serverless-records.js +43 -0
- package/dist/src/services/storage.js +24 -2
- package/dist/src/template-editor-pages.js +1 -1
- package/package.json +1 -1
- package/public/assets/file-directory-app.js +2 -2
- package/public/assets/homepage-client-app.js +1 -1
- package/public/assets/page-runtime-client-app.js +2 -2
- 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
|
-
|
|
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
|
-
|
|
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="/
|
|
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">▾</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 (
|
|
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:
|
|
145
|
-
color:var(--rk-ink);background:var(--rk-
|
|
146
|
-
padding:
|
|
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-
|
|
149
|
-
.rk-chat-mode-select:focus{outline:none;border-color:var(--rk-gold-
|
|
150
|
-
.rk-chat-mode-chev{position:absolute;right:
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
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'){
|
|
928
|
+
if(dest && dest!=='/chat'){ navigateWithChat(dest); }
|
|
841
929
|
});
|
|
842
930
|
}
|
|
843
931
|
})();`;
|