@mevdragon/vidfarm-devcli 0.7.0 → 0.7.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/SKILL.director.md +43 -10
- package/SKILL.platform.md +5 -3
- package/demo/dist/app.js +106 -56
- package/dist/src/app.js +252 -15
- package/dist/src/cli.js +245 -40
- package/dist/src/composition-runtime.js +26 -0
- package/dist/src/devcli/composition-edit.js +12 -0
- package/dist/src/editor-chat.js +1 -0
- package/dist/src/frontend/homepage-client.js +61 -6
- package/dist/src/frontend/homepage-view.js +17 -4
- package/dist/src/homepage.js +46 -0
- package/dist/src/hyperframes/composition.js +87 -0
- package/dist/src/primitive-registry.js +136 -0
- package/dist/src/services/billing.js +1 -0
- package/dist/src/services/hyperframes.js +26 -1
- package/dist/src/services/storage.js +6 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +18 -18
package/dist/src/app.js
CHANGED
|
@@ -21,6 +21,7 @@ import { writeForkManifest } from "./services/fork-manifest.js";
|
|
|
21
21
|
import { renderHelpPage } from "./help-page.js";
|
|
22
22
|
import { renderHomepage } from "./homepage.js";
|
|
23
23
|
import { COMPOSITION_RUNTIME_SCRIPT_BODY } from "./composition-runtime.js";
|
|
24
|
+
import { KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER } from "./hyperframes/composition.js";
|
|
24
25
|
import { normalizeCompositionZIndexHtml, sanitizeCompositionHtml } from "./services/composition-sanitize.js";
|
|
25
26
|
import { applyMarkupUsd } from "./services/billing-pricing.js";
|
|
26
27
|
import { primitiveRegistry } from "./primitive-registry.js";
|
|
@@ -4140,6 +4141,8 @@ function createEditorActionTool() {
|
|
|
4140
4141
|
background_style: z.enum(["plain", "outline", "highlight-solid", "highlight-translucent"]).optional().describe("Text background treatment."),
|
|
4141
4142
|
object_fit: z.string().optional().describe("CSS object-fit for image/video (cover, contain, fill, none, scale-down)."),
|
|
4142
4143
|
object_position: z.string().optional().describe("CSS object-position for image/video (e.g., center, top, bottom left)."),
|
|
4144
|
+
ken_burns: z.enum(["zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right", "none"]).optional().describe("Ken Burns motion for still-image layers: a slow pan/zoom spanning the clip's full duration. Apply with set_layer_media on an existing image, or seed it on add_layer/generate_layer when kind/media_type is image. 'none' removes the effect. When the user says 'animate the images' / 'ken burns', apply it to every still image, varying presets across adjacent clips (zoom-in, pan-left, zoom-out, ...) for a documentary feel."),
|
|
4145
|
+
ken_burns_intensity: z.number().optional().describe("Ken Burns strength: extra zoom / pan travel as a fraction of the frame, 0.04-0.5 (default 0.18; ~0.1 subtle, ~0.3 dramatic). Only meaningful alongside ken_burns."),
|
|
4143
4146
|
volume: z.number().optional().describe("Volume 0..1 for audio/video."),
|
|
4144
4147
|
muted: z.boolean().optional().describe("Toggle mute on audio/video."),
|
|
4145
4148
|
group_label: z.string().optional().describe("Optional label for group_layers."),
|
|
@@ -4514,6 +4517,21 @@ function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
|
|
|
4514
4517
|
rewritten = `${rewritten}\n${runtimeTag}`;
|
|
4515
4518
|
}
|
|
4516
4519
|
}
|
|
4520
|
+
// Ken Burns keyframes: always available in the editor iframe so toggling
|
|
4521
|
+
// data-kenburns on a layer animates immediately, even for compositions
|
|
4522
|
+
// stored before the builder embedded the stylesheet.
|
|
4523
|
+
if (!rewritten.includes(KEN_BURNS_STYLE_MARKER)) {
|
|
4524
|
+
const kenBurnsTag = `<style data-vf-kenburns="true">${KEN_BURNS_CSS}</style>`;
|
|
4525
|
+
if (/<\/head>/i.test(rewritten)) {
|
|
4526
|
+
rewritten = rewritten.replace(/<\/head>/i, `${kenBurnsTag}\n</head>`);
|
|
4527
|
+
}
|
|
4528
|
+
else if (/<\/body>/i.test(rewritten)) {
|
|
4529
|
+
rewritten = rewritten.replace(/<\/body>/i, `${kenBurnsTag}\n</body>`);
|
|
4530
|
+
}
|
|
4531
|
+
else {
|
|
4532
|
+
rewritten = `${rewritten}\n${kenBurnsTag}`;
|
|
4533
|
+
}
|
|
4534
|
+
}
|
|
4517
4535
|
return rewritten;
|
|
4518
4536
|
}
|
|
4519
4537
|
async function renderHyperframesStudioEditorPage(input) {
|
|
@@ -4806,7 +4824,10 @@ function buildTemplateHomepageEntry(template) {
|
|
|
4806
4824
|
if (!template.videoUrl)
|
|
4807
4825
|
return null;
|
|
4808
4826
|
const isPrivate = template.visibility === "private";
|
|
4809
|
-
|
|
4827
|
+
// Older records stored the source host ("youtube.com") as a title fallback —
|
|
4828
|
+
// never show that as a card title; the template id is the canonical default.
|
|
4829
|
+
const storedTitle = template.title && template.title !== template.sourceHost ? template.title : null;
|
|
4830
|
+
const title = isPrivate ? (storedTitle || template.id) : template.id;
|
|
4810
4831
|
const previewUrl = template.thumbnailUrl || template.videoUrl || null;
|
|
4811
4832
|
return {
|
|
4812
4833
|
title,
|
|
@@ -4842,26 +4863,213 @@ const DISCOVER_SUBMISSION_HOSTS = [
|
|
|
4842
4863
|
function isSupportedDiscoverSubmissionHost(host) {
|
|
4843
4864
|
return DISCOVER_SUBMISSION_HOSTS.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
|
|
4844
4865
|
}
|
|
4845
|
-
//
|
|
4846
|
-
//
|
|
4847
|
-
//
|
|
4848
|
-
|
|
4866
|
+
// Upload cap for "Add Template → upload a video". Generous relative to the
|
|
4867
|
+
// 50 MB My Files cap because template sources are full short-form videos; the
|
|
4868
|
+
// bytes go straight to S3 via presigned PUT, never through a Lambda body.
|
|
4869
|
+
const INSPIRATION_UPLOAD_MAX_BYTES = 200 * 1024 * 1024;
|
|
4870
|
+
function isUploadableInspirationVideo(fileName, contentType) {
|
|
4871
|
+
if (contentType.startsWith("video/"))
|
|
4872
|
+
return true;
|
|
4873
|
+
return [".mp4", ".m4v", ".mov", ".webm"].includes(path.extname(fileName).toLowerCase());
|
|
4874
|
+
}
|
|
4875
|
+
function isOwnInspirationUploadKey(storageKey, customerId) {
|
|
4876
|
+
return storageKey.startsWith(`users/${customerId}/inspiration-uploads/`);
|
|
4877
|
+
}
|
|
4878
|
+
// Discover "Add Template" (upload, step 1 of 3): mint a write target for a
|
|
4879
|
+
// local video file. Contract shared by web + devcli: presign here (JSON), PUT
|
|
4880
|
+
// (or multipart-fallback POST) the bytes, then POST /discover/templates with
|
|
4881
|
+
// { upload: { storage_key, file_name } } to queue the ingest. In `vidfarm
|
|
4882
|
+
// serve` cloud-passthrough mode this proxies upstream so the client PUTs
|
|
4883
|
+
// straight to cloud S3 and the ingest lands in the cloud account — multipart
|
|
4884
|
+
// bodies can't proxy (text-only proxy), so proxying the presign avoids ever
|
|
4885
|
+
// needing to.
|
|
4886
|
+
app.post("/discover/templates/upload/presign", async (c) => {
|
|
4849
4887
|
const customer = await requireBrowserCustomer(c);
|
|
4850
4888
|
if (!customer)
|
|
4851
4889
|
return c.json({ ok: false, error: "Log in to add templates." }, 401);
|
|
4852
|
-
// Cloud passthrough (vidfarm serve): Add Template ingests into the CLOUD
|
|
4853
|
-
// account — the download job, billing, and catalog entry all live upstream,
|
|
4854
|
-
// and the proxied /discover/feed finalizes it. Local ingest only happens
|
|
4855
|
-
// when no upstream key is configured.
|
|
4856
4890
|
if (hasUpstreamKey()) {
|
|
4857
4891
|
const proxied = await proxyRequestToUpstream(c);
|
|
4858
4892
|
if (proxied)
|
|
4859
4893
|
return proxied;
|
|
4860
4894
|
}
|
|
4861
4895
|
const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
|
|
4896
|
+
const fileName = sanitizeFileName(readNonEmptyString(body.file_name) ?? "upload.mp4");
|
|
4897
|
+
const contentType = readNonEmptyString(body.content_type) ?? inferAttachmentContentType(fileName);
|
|
4898
|
+
if (!isUploadableInspirationVideo(fileName, contentType)) {
|
|
4899
|
+
return c.json({ ok: false, error: "Upload a video file (MP4, MOV, or WebM)." }, 400);
|
|
4900
|
+
}
|
|
4901
|
+
const sizeBytes = Number(body.size_bytes);
|
|
4902
|
+
if (Number.isFinite(sizeBytes) && sizeBytes > INSPIRATION_UPLOAD_MAX_BYTES) {
|
|
4903
|
+
return c.json({ ok: false, error: "Video uploads can be at most 200 MB." }, 400);
|
|
4904
|
+
}
|
|
4905
|
+
const uploadId = randomUUID();
|
|
4906
|
+
const storageKey = storage.inspirationUploadKey(customer.id, uploadId, fileName);
|
|
4907
|
+
if (config.STORAGE_DRIVER !== "s3") {
|
|
4908
|
+
return c.json({
|
|
4909
|
+
transport: "server",
|
|
4910
|
+
upload_id: uploadId,
|
|
4911
|
+
file_name: fileName,
|
|
4912
|
+
content_type: contentType,
|
|
4913
|
+
storage_key: storageKey,
|
|
4914
|
+
upload: {
|
|
4915
|
+
method: "POST",
|
|
4916
|
+
url: "/discover/templates/upload",
|
|
4917
|
+
form_field: "file"
|
|
4918
|
+
}
|
|
4919
|
+
});
|
|
4920
|
+
}
|
|
4921
|
+
const upload = await storage.createWriteUrl(storageKey, { contentType, expiresIn: 3600 });
|
|
4922
|
+
return c.json({
|
|
4923
|
+
transport: "presigned",
|
|
4924
|
+
upload_id: uploadId,
|
|
4925
|
+
file_name: fileName,
|
|
4926
|
+
content_type: contentType,
|
|
4927
|
+
storage_key: storageKey,
|
|
4928
|
+
upload: {
|
|
4929
|
+
method: upload.method,
|
|
4930
|
+
url: upload.url,
|
|
4931
|
+
headers: upload.headers,
|
|
4932
|
+
expires_in_seconds: upload.expiresIn
|
|
4933
|
+
}
|
|
4934
|
+
});
|
|
4935
|
+
});
|
|
4936
|
+
// Upload step 2 (multipart fallback): only used when presign returned
|
|
4937
|
+
// transport "server" — i.e. the local storage driver (vidfarm serve without
|
|
4938
|
+
// S3). Deliberately not proxied upstream: the upstream proxy is text-only,
|
|
4939
|
+
// and in passthrough mode the presign already handed the client a direct
|
|
4940
|
+
// cloud target.
|
|
4941
|
+
app.post("/discover/templates/upload", async (c) => {
|
|
4942
|
+
const customer = await requireBrowserCustomer(c);
|
|
4943
|
+
if (!customer)
|
|
4944
|
+
return c.json({ ok: false, error: "Log in to add templates." }, 401);
|
|
4945
|
+
const formData = await c.req.formData().catch(() => null);
|
|
4946
|
+
const file = formData?.get("file");
|
|
4947
|
+
if (!isFormFile(file))
|
|
4948
|
+
return c.json({ ok: false, error: "Select a video file to upload." }, 400);
|
|
4949
|
+
const fileName = sanitizeFileName(file.name || "upload.mp4");
|
|
4950
|
+
const contentType = file.type || inferAttachmentContentType(fileName);
|
|
4951
|
+
if (!isUploadableInspirationVideo(fileName, contentType)) {
|
|
4952
|
+
return c.json({ ok: false, error: "Upload a video file (MP4, MOV, or WebM)." }, 400);
|
|
4953
|
+
}
|
|
4954
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
4955
|
+
if (buffer.byteLength > INSPIRATION_UPLOAD_MAX_BYTES) {
|
|
4956
|
+
return c.json({ ok: false, error: "Video uploads can be at most 200 MB." }, 400);
|
|
4957
|
+
}
|
|
4958
|
+
const uploadId = randomUUID();
|
|
4959
|
+
const storageKey = storage.inspirationUploadKey(customer.id, uploadId, fileName);
|
|
4960
|
+
const stored = await storage.putBuffer(storageKey, buffer, contentType);
|
|
4961
|
+
return c.json({
|
|
4962
|
+
ok: true,
|
|
4963
|
+
storage_key: storageKey,
|
|
4964
|
+
file_name: fileName,
|
|
4965
|
+
content_type: contentType,
|
|
4966
|
+
size_bytes: buffer.byteLength,
|
|
4967
|
+
url: stored.url
|
|
4968
|
+
}, 201);
|
|
4969
|
+
});
|
|
4970
|
+
// Discover "Add Template": ingest a social video URL — or a video the caller
|
|
4971
|
+
// just uploaded via the presign/upload pair above — as a PRIVATE inspiration
|
|
4972
|
+
// owned by the caller. The download/ingest job runs async; /discover/feed
|
|
4973
|
+
// finalizes it and mints the private template once the video lands.
|
|
4974
|
+
app.post("/discover/templates", async (c) => {
|
|
4975
|
+
const customer = await requireBrowserCustomer(c);
|
|
4976
|
+
if (!customer)
|
|
4977
|
+
return c.json({ ok: false, error: "Log in to add templates." }, 401);
|
|
4978
|
+
const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
|
|
4979
|
+
const uploadRef = asRecord(body.upload);
|
|
4980
|
+
const uploadStorageKey = readNonEmptyString(uploadRef?.storage_key);
|
|
4981
|
+
// Cloud passthrough (vidfarm serve): Add Template ingests into the CLOUD
|
|
4982
|
+
// account — the download/ingest job, billing, and catalog entry all live
|
|
4983
|
+
// upstream, and the proxied /discover/feed finalizes it. The one exception:
|
|
4984
|
+
// an upload key minted by THIS box (local presign fallback while upstream
|
|
4985
|
+
// was unreachable) — its bytes only exist locally, so ingest locally.
|
|
4986
|
+
if (hasUpstreamKey() && !(uploadStorageKey && isOwnInspirationUploadKey(uploadStorageKey, customer.id))) {
|
|
4987
|
+
const proxied = await proxyRequestToUpstream(c);
|
|
4988
|
+
if (proxied)
|
|
4989
|
+
return proxied;
|
|
4990
|
+
}
|
|
4991
|
+
if (uploadStorageKey) {
|
|
4992
|
+
if (!isOwnInspirationUploadKey(uploadStorageKey, customer.id)) {
|
|
4993
|
+
return c.json({ ok: false, error: "That upload doesn't belong to this account." }, 403);
|
|
4994
|
+
}
|
|
4995
|
+
const uploadUrl = storage.getPublicUrl(uploadStorageKey);
|
|
4996
|
+
if (!uploadUrl)
|
|
4997
|
+
return c.json({ ok: false, error: "Uploaded video could not be resolved." }, 400);
|
|
4998
|
+
const explicitTitle = readNonEmptyString(body.title) ?? null;
|
|
4999
|
+
const tagline = readNonEmptyString(body.tagline) ?? readNonEmptyString(body.trend_tagline) ?? null;
|
|
5000
|
+
const notes = readNonEmptyString(body.notes) ?? null;
|
|
5001
|
+
const uploadFileName = readNonEmptyString(uploadRef?.file_name) ?? null;
|
|
5002
|
+
const primitive = primitiveRegistry.get("primitive:video_ingest");
|
|
5003
|
+
if (!primitive)
|
|
5004
|
+
return c.json({ ok: false, error: "Video ingest primitive not available" }, 500);
|
|
5005
|
+
const operation = primitive.operations["run"];
|
|
5006
|
+
if (!operation)
|
|
5007
|
+
return c.json({ ok: false, error: "Video ingest operation not available" }, 500);
|
|
5008
|
+
// Only a caller-chosen title flows into the manifest — never the file
|
|
5009
|
+
// name. An untitled upload finalizes with title null so its /discover
|
|
5010
|
+
// card displays the minted template id.
|
|
5011
|
+
const payload = operation.inputSchema.parse({
|
|
5012
|
+
source_video_url: uploadUrl,
|
|
5013
|
+
title: explicitTitle ?? tagline ?? undefined,
|
|
5014
|
+
file_name: uploadFileName ?? undefined,
|
|
5015
|
+
save_manifest: true
|
|
5016
|
+
});
|
|
5017
|
+
let job;
|
|
5018
|
+
try {
|
|
5019
|
+
await assertWalletCanQueueJobs(customer.id);
|
|
5020
|
+
job = await jobs.createRootJob({
|
|
5021
|
+
templateId: primitive.id,
|
|
5022
|
+
operationName: "run",
|
|
5023
|
+
workflowName: operation.workflow,
|
|
5024
|
+
tracer: `discover_template_upload:${customer.id}`,
|
|
5025
|
+
payload,
|
|
5026
|
+
webhookUrl: null,
|
|
5027
|
+
customer,
|
|
5028
|
+
providerHint: undefined
|
|
5029
|
+
});
|
|
5030
|
+
}
|
|
5031
|
+
catch (error) {
|
|
5032
|
+
if (error instanceof InsufficientWalletFundsError)
|
|
5033
|
+
return insufficientFundsResponse(c, error);
|
|
5034
|
+
if (error instanceof ActiveJobLimitExceededError)
|
|
5035
|
+
return activeJobLimitExceededResponse(c, error);
|
|
5036
|
+
throw error;
|
|
5037
|
+
}
|
|
5038
|
+
// No Step Functions worker on a local (vidfarm serve) box — run the ingest
|
|
5039
|
+
// in-process, detached, exactly like local renders. /discover/feed polls
|
|
5040
|
+
// finalize the inspiration when the job lands.
|
|
5041
|
+
if (!config.VIDFARM_JOB_STATE_MACHINE_ARN) {
|
|
5042
|
+
void runPrimitiveJobInProcess(job.id).catch((error) => {
|
|
5043
|
+
console.error("local video ingest failed", job.id, error instanceof Error ? error.message : error);
|
|
5044
|
+
});
|
|
5045
|
+
}
|
|
5046
|
+
// Every upload key is unique (uuid segment), so no dedupe pass: two
|
|
5047
|
+
// uploads of the same file are two intentional inspirations.
|
|
5048
|
+
let inspiration = await serverlessRecords.createInspiration({
|
|
5049
|
+
customerId: customer.id,
|
|
5050
|
+
sourceUrl: uploadUrl,
|
|
5051
|
+
originalUrl: uploadUrl,
|
|
5052
|
+
sourceHost: "upload",
|
|
5053
|
+
downloadJobId: job.id,
|
|
5054
|
+
trendTagline: tagline,
|
|
5055
|
+
visibility: "private",
|
|
5056
|
+
notes
|
|
5057
|
+
});
|
|
5058
|
+
// Pending-card label while the ingest runs: explicit title, else the file
|
|
5059
|
+
// name. Finalize overwrites this from the manifest, so an untitled upload
|
|
5060
|
+
// ends at title null → the card shows the minted template id.
|
|
5061
|
+
const pendingTitle = explicitTitle ?? uploadFileName;
|
|
5062
|
+
if (pendingTitle) {
|
|
5063
|
+
inspiration = (await serverlessRecords.updateInspiration({
|
|
5064
|
+
inspirationId: inspiration.id,
|
|
5065
|
+
patch: { title: pendingTitle }
|
|
5066
|
+
})) ?? inspiration;
|
|
5067
|
+
}
|
|
5068
|
+
return c.json(serializeInspiration(inspiration), 202);
|
|
5069
|
+
}
|
|
4862
5070
|
const sourceUrl = readNonEmptyString(body.source_url);
|
|
4863
5071
|
if (!sourceUrl)
|
|
4864
|
-
return c.json({ ok: false, error: "Enter a video URL." }, 400);
|
|
5072
|
+
return c.json({ ok: false, error: "Enter a video URL or upload a video file." }, 400);
|
|
4865
5073
|
const originalUrl = stripTrackingParams(sourceUrl);
|
|
4866
5074
|
const sourceHost = safeUrlHostname(originalUrl);
|
|
4867
5075
|
if (!sourceHost)
|
|
@@ -4908,6 +5116,13 @@ app.post("/discover/templates", async (c) => {
|
|
|
4908
5116
|
return activeJobLimitExceededResponse(c, error);
|
|
4909
5117
|
throw error;
|
|
4910
5118
|
}
|
|
5119
|
+
// Same local-box treatment as the upload branch: no Step Functions worker
|
|
5120
|
+
// means the download job must run in-process or it queues forever.
|
|
5121
|
+
if (!config.VIDFARM_JOB_STATE_MACHINE_ARN) {
|
|
5122
|
+
void runPrimitiveJobInProcess(job.id).catch((error) => {
|
|
5123
|
+
console.error("local video download failed", job.id, error instanceof Error ? error.message : error);
|
|
5124
|
+
});
|
|
5125
|
+
}
|
|
4911
5126
|
const inspiration = await serverlessRecords.createInspiration({
|
|
4912
5127
|
customerId: customer.id,
|
|
4913
5128
|
sourceUrl,
|
|
@@ -4975,13 +5190,21 @@ app.delete("/discover/templates/:entryId", async (c) => {
|
|
|
4975
5190
|
// client-side via status.
|
|
4976
5191
|
function buildPendingInspirationHomepageEntry(inspiration) {
|
|
4977
5192
|
return {
|
|
4978
|
-
|
|
5193
|
+
// Uploads: explicit title (or file name, stored at submit time) outranks
|
|
5194
|
+
// the tagline; URL ingests keep tagline-first.
|
|
5195
|
+
title: (inspiration.sourceHost === "upload"
|
|
5196
|
+
? (inspiration.title || inspiration.trendTagline)
|
|
5197
|
+
: (inspiration.trendTagline || inspiration.title)) || inspiration.sourceHost || "New template",
|
|
4979
5198
|
templateId: inspiration.id,
|
|
4980
5199
|
slugId: inspiration.id,
|
|
4981
5200
|
difficulty: "easy",
|
|
4982
5201
|
viralDna: inspiration.status === "failed"
|
|
4983
|
-
? (inspiration.errorMessage ||
|
|
4984
|
-
|
|
5202
|
+
? (inspiration.errorMessage || (inspiration.sourceHost === "upload"
|
|
5203
|
+
? "Video ingest failed. Try uploading the file again."
|
|
5204
|
+
: "Video download failed. Check the URL and try adding it again."))
|
|
5205
|
+
: (inspiration.notes || (inspiration.sourceHost === "upload"
|
|
5206
|
+
? "Processing your uploaded video. This card unlocks automatically once it's ready."
|
|
5207
|
+
: "Downloading the source video. This card unlocks automatically once it's ready.")),
|
|
4985
5208
|
previewUrl: inspiration.thumbnailUrl || "",
|
|
4986
5209
|
approvedAt: inspiration.createdAt,
|
|
4987
5210
|
sourceType: inspiration.sourceHost || "social",
|
|
@@ -7781,8 +8004,15 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/versions`, async (c) => {
|
|
|
7781
8004
|
const limit = Number.parseInt(c.req.query("limit") ?? "25", 10);
|
|
7782
8005
|
const cursor = c.req.query("cursor") ?? null;
|
|
7783
8006
|
const page = await serverlessRecords.listForkVersions({ forkId: access.fork.id, limit, cursor });
|
|
8007
|
+
// Fork content is world-viewable, but the snapshot trail is personal.
|
|
8008
|
+
// /editor parks users without their own fork on the template's shared
|
|
8009
|
+
// default fork, so without this scoping the History modal would list the
|
|
8010
|
+
// fork owner's (i.e. another user's) publish/decompose history.
|
|
8011
|
+
const visible = access.capabilities.isOwner
|
|
8012
|
+
? page.items
|
|
8013
|
+
: page.items.filter((version) => Boolean(customer && version.createdBy === customer.id));
|
|
7784
8014
|
return c.json({
|
|
7785
|
-
versions:
|
|
8015
|
+
versions: visible.map(serializeVersion),
|
|
7786
8016
|
next_cursor: page.nextCursor
|
|
7787
8017
|
});
|
|
7788
8018
|
}
|
|
@@ -7945,7 +8175,14 @@ async function finalizeInspirationIfReady(inspiration) {
|
|
|
7945
8175
|
customerId: ready.customerId,
|
|
7946
8176
|
originalUrl: ready.originalUrl,
|
|
7947
8177
|
sourceHost: ready.sourceHost,
|
|
7948
|
-
|
|
8178
|
+
// No host/"Untitled" fallback: a template without an explicit title or
|
|
8179
|
+
// tagline displays as its template id on /discover. For uploads the
|
|
8180
|
+
// manifest title IS the caller's explicit title, so it outranks the
|
|
8181
|
+
// tagline; URL ingests keep tagline-first (manifest title is just the
|
|
8182
|
+
// social post's own caption).
|
|
8183
|
+
title: (ready.sourceHost === "upload"
|
|
8184
|
+
? (ready.title || ready.trendTagline)
|
|
8185
|
+
: (ready.trendTagline || ready.title)) || null,
|
|
7949
8186
|
thumbnailUrl: ready.thumbnailUrl,
|
|
7950
8187
|
videoUrl: ready.videoUrl,
|
|
7951
8188
|
durationSeconds: ready.durationSeconds,
|