@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/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
- const title = isPrivate ? (template.title || template.id) : template.id;
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
- // Discover "Add Template": ingest a social video URL as a PRIVATE inspiration
4846
- // owned by the caller. The download job runs async; /discover/feed finalizes
4847
- // it and mints the private template once the video lands.
4848
- app.post("/discover/templates", async (c) => {
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
- title: inspiration.trendTagline || inspiration.title || inspiration.sourceHost || "New template",
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 || "Video download failed. Check the URL and try adding it again.")
4984
- : (inspiration.notes || "Downloading the source video. This card unlocks automatically once it's ready."),
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: page.items.map(serializeVersion),
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
- title: ready.trendTagline || ready.title || ready.sourceHost || "Untitled",
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,