@mevdragon/vidfarm-devcli 0.5.2 → 0.6.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 (113) hide show
  1. package/README.md +3 -3
  2. package/demo/README.md +28 -0
  3. package/demo/dist/app.css +1 -0
  4. package/demo/dist/app.js +1184 -0
  5. package/demo/dist/chunks/chunk-DXB73IDG.js +1 -0
  6. package/demo/dist/chunks/chunk-S7OWAJDS.js +36 -0
  7. package/demo/dist/chunks/chunk-VTIBZ6AN.js +1 -0
  8. package/demo/dist/chunks/dist-ADSJKBVE.js +332 -0
  9. package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +1 -0
  10. package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +425 -0
  11. package/demo/dist/chunks/lib-XAQ37YOE.js +1 -0
  12. package/demo/dist/chunks/src-TJ2QYA4U.js +207 -0
  13. package/demo/dist/favicon.ico +0 -0
  14. package/demo/dist/icons/timeline/audio.svg +7 -0
  15. package/demo/dist/icons/timeline/captions.svg +5 -0
  16. package/demo/dist/icons/timeline/composition.svg +12 -0
  17. package/demo/dist/icons/timeline/image.svg +18 -0
  18. package/demo/dist/icons/timeline/music.svg +10 -0
  19. package/demo/dist/icons/timeline/text.svg +3 -0
  20. package/demo/dist/index.html +15 -0
  21. package/dist/src/account-pages-legacy.js +9396 -0
  22. package/dist/src/account-pages.js +61 -0
  23. package/dist/src/app.js +14378 -0
  24. package/dist/src/cli.js +1 -1
  25. package/dist/src/composition-runtime.js +613 -0
  26. package/dist/src/config.js +166 -0
  27. package/dist/src/context.js +447 -0
  28. package/dist/src/dev-app-legacy.js +739 -0
  29. package/dist/src/dev-app.js +6 -0
  30. package/dist/src/domain.js +2 -0
  31. package/dist/src/editor-chat-history.js +82 -0
  32. package/dist/src/editor-chat.js +449 -0
  33. package/dist/src/editor-dark-theme.js +1128 -0
  34. package/dist/src/frontend/debug.js +71 -0
  35. package/dist/src/frontend/flockposter-cache-store.js +124 -0
  36. package/dist/src/frontend/homepage-client.js +182 -0
  37. package/dist/src/frontend/homepage-shared.js +4 -0
  38. package/dist/src/frontend/homepage-store.js +28 -0
  39. package/dist/src/frontend/homepage-view.js +547 -0
  40. package/dist/src/frontend/page-runtime-client.js +132 -0
  41. package/dist/src/frontend/page-runtime-store.js +9 -0
  42. package/dist/src/frontend/sentry.js +42 -0
  43. package/dist/src/frontend/template-editor-chat.js +3960 -0
  44. package/dist/src/help-page.js +346 -0
  45. package/dist/src/homepage.js +1235 -0
  46. package/dist/src/hyperframes/composition.js +180 -0
  47. package/dist/src/index.js +16 -0
  48. package/dist/src/instrument.js +30 -0
  49. package/dist/src/lib/crypto.js +45 -0
  50. package/dist/src/lib/dev-log.js +54 -0
  51. package/dist/src/lib/display-name.js +11 -0
  52. package/dist/src/lib/ids.js +24 -0
  53. package/dist/src/lib/images.js +19 -0
  54. package/dist/src/lib/json.js +15 -0
  55. package/dist/src/lib/package-root.js +47 -0
  56. package/dist/src/lib/template-paths.js +28 -0
  57. package/dist/src/lib/time.js +7 -0
  58. package/dist/src/lib/url-clean.js +85 -0
  59. package/dist/src/page-runtime.js +2 -0
  60. package/dist/src/page-shell.js +1381 -0
  61. package/dist/src/primitive-context.js +357 -0
  62. package/dist/src/primitive-registry.js +2436 -0
  63. package/dist/src/primitive-sdk.js +4 -0
  64. package/dist/src/primitives/hyperframes-media.js +108 -0
  65. package/dist/src/react-page-shell.js +35 -0
  66. package/dist/src/ready-post-schedule-component.js +1540 -0
  67. package/dist/src/registry.js +296 -0
  68. package/dist/src/runtime.js +35 -0
  69. package/dist/src/services/api-call-history.js +249 -0
  70. package/dist/src/services/auth.js +152 -0
  71. package/dist/src/services/billing-pricing.js +39 -0
  72. package/dist/src/services/billing.js +228 -0
  73. package/dist/src/services/cast.js +127 -0
  74. package/dist/src/services/chat-threads.js +92 -0
  75. package/dist/src/services/composition-sanitize.js +124 -0
  76. package/dist/src/services/composition-watch.js +79 -0
  77. package/dist/src/services/fork-access.js +93 -0
  78. package/dist/src/services/fork-manifest.js +42 -0
  79. package/dist/src/services/ghostcut.js +179 -0
  80. package/dist/src/services/hyperframes.js +2307 -0
  81. package/dist/src/services/job-capacity.js +14 -0
  82. package/dist/src/services/job-logs.js +197 -0
  83. package/dist/src/services/jobs.js +136 -0
  84. package/dist/src/services/local-dynamo.js +0 -0
  85. package/dist/src/services/media-processing.js +766 -0
  86. package/dist/src/services/primitive-media-lambda.js +280 -0
  87. package/dist/src/services/providers.js +2926 -0
  88. package/dist/src/services/rate-limits.js +262 -0
  89. package/dist/src/services/serverless-auth.js +382 -0
  90. package/dist/src/services/serverless-jobs.js +1082 -0
  91. package/dist/src/services/serverless-provider-keys.js +409 -0
  92. package/dist/src/services/serverless-records.js +1385 -0
  93. package/dist/src/services/serverless-template-configs.js +75 -0
  94. package/dist/src/services/storage.js +383 -0
  95. package/dist/src/services/template-certification.js +413 -0
  96. package/dist/src/services/template-loader.js +99 -0
  97. package/dist/src/services/template-runtime-bundles.js +217 -0
  98. package/dist/src/services/template-sources.js +1017 -0
  99. package/dist/src/services/video-normalization.js +2 -0
  100. package/dist/src/services/webhooks.js +62 -0
  101. package/dist/src/template-editor-pages.js +2576 -0
  102. package/dist/src/template-editor-shell.js +2840 -0
  103. package/dist/src/template-sdk.js +4 -0
  104. package/dist/src/worker.js +17 -0
  105. package/package.json +6 -4
  106. package/public/assets/homepage-app.js +54 -0
  107. package/public/assets/homepage-client-app.js +80 -0
  108. package/public/assets/page-runtime-client-app.js +94 -0
  109. package/src/assets/SELLING_AWARENESS_STAGES.md +579 -0
  110. package/src/assets/SELLING_WITH_HOOKS.md +377 -0
  111. package/src/assets/SELLING_WITH_VSLS.md +606 -0
  112. package/src/assets/favicon.ico +0 -0
  113. package/src/assets/logo-vidfarm.png +0 -0
@@ -0,0 +1,124 @@
1
+ // Sanitizes composition.html before it is stored in a fork's S3 namespace.
2
+ //
3
+ // Fork content lives under a public-read S3 prefix and is rendered SAME-ORIGIN
4
+ // inside the editor iframe (the editor needs same-origin DOM access to morph the
5
+ // timeline). That means any <script> or inline handler an author embeds would
6
+ // execute in a *viewer's* vidfarm.cc origin when a fork is shared — a stored-XSS
7
+ // vector against other users. Compositions are declarative (data-* attributes
8
+ // drive the injected runtime), so scripts are never needed in stored content:
9
+ // the trusted runtime is (re)injected at serve time by
10
+ // rewriteHyperframesDraftCompositionHtml. We therefore strip all author scripts,
11
+ // inline on*= handlers, and javascript:/vbscript: URLs.
12
+ //
13
+ // This is a pragmatic regex sanitizer (the Lambda has no DOM). It errs toward
14
+ // removing anything script-like; it is a hardening layer, not the only defense.
15
+ const SCRIPT_TAG_RE = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
16
+ // Void/self-closed or unterminated <script ...> with no closing tag.
17
+ const LONE_SCRIPT_OPEN_RE = /<script\b[^>]*\/?>/gi;
18
+ // Inline event handlers: on-word = "..." | '...' | bare token. Requires a
19
+ // preceding whitespace so we don't match attributes like `data-on=""`.
20
+ const INLINE_HANDLER_RE = /\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi;
21
+ // Dangerous URL schemes in a navigable attribute value. Matches the whole
22
+ // value — quoted (single/double) or bare — so the closing quote is consumed and
23
+ // the replacement stays well-formed.
24
+ const DANGEROUS_SCHEME = String.raw `(?:javascript\s*:|vbscript\s*:|data\s*:\s*text\/html)`;
25
+ const DANGEROUS_URL_RE = new RegExp(String.raw `((?:href|src|xlink:href|action|formaction)\s*=\s*)` +
26
+ String.raw `(?:"\s*${DANGEROUS_SCHEME}[^"]*"|'\s*${DANGEROUS_SCHEME}[^']*'|${DANGEROUS_SCHEME}[^\s>]*)`, "gi");
27
+ export function sanitizeCompositionHtml(html) {
28
+ let scripts = 0;
29
+ let handlers = 0;
30
+ let urls = 0;
31
+ let out = html.replace(SCRIPT_TAG_RE, () => {
32
+ scripts += 1;
33
+ return "";
34
+ });
35
+ out = out.replace(LONE_SCRIPT_OPEN_RE, () => {
36
+ scripts += 1;
37
+ return "";
38
+ });
39
+ out = out.replace(INLINE_HANDLER_RE, () => {
40
+ handlers += 1;
41
+ return "";
42
+ });
43
+ out = out.replace(DANGEROUS_URL_RE, (_match, prefix) => {
44
+ urls += 1;
45
+ // Neutralize to a harmless anchor while preserving the attribute shape.
46
+ return `${prefix}"#"`;
47
+ });
48
+ const changed = scripts > 0 || handlers > 0 || urls > 0;
49
+ return { html: out, changed, removed: { scripts, handlers, urls } };
50
+ }
51
+ // Re-derives inline z-index from data-track-index on every open tag carrying
52
+ // the attribute. The render pipeline (normalizePublishHtml) enforces
53
+ // z-index = track — higher track draws in front — but the preview runtime
54
+ // trusts whatever inline z is stored, so any stored html with stale or missing
55
+ // z previews with the wrong stacking. Applying this at serve time keeps the
56
+ // preview identical to the render for every composition, including ones never
57
+ // touched by the current editor. String-only pass for the same reason as the
58
+ // sanitizer above (no DOM in the Lambda; and DOM serialization re-quotes
59
+ // font-family values, breaking the render's font resolver).
60
+ const OPEN_TAG_RE = /<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'`=<>]+))?)*)\s*(\/?)>/g;
61
+ // Walks real attribute boundaries. A flat regex over the attr blob can match
62
+ // `style=` or `data-track-index` text INSIDE another attribute's value
63
+ // (e.g. a data-label containing markup), corrupting user content.
64
+ const ATTR_RE = /\s+([^\s"'>/=]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s"'`=<>]+))?/g;
65
+ // KEEP IN SYNC with normalizeCompositionZIndex in
66
+ // demo/src/HyperframesStudioEditor.tsx (client editor copy).
67
+ export function normalizeCompositionZIndexHtml(html) {
68
+ if (!html || !html.includes("data-track-index"))
69
+ return html;
70
+ return html.replace(OPEN_TAG_RE, (match, tag, rawAttrs, selfClose) => {
71
+ let track = null;
72
+ let styleQuote = '"';
73
+ let styleValue = null;
74
+ let styleStart = -1;
75
+ let styleEnd = -1;
76
+ ATTR_RE.lastIndex = 0;
77
+ let attr;
78
+ while ((attr = ATTR_RE.exec(rawAttrs))) {
79
+ const name = attr[1].toLowerCase();
80
+ const rawValue = attr[2];
81
+ if (rawValue === undefined)
82
+ continue;
83
+ const quoted = rawValue.startsWith('"') || rawValue.startsWith("'");
84
+ const value = quoted ? rawValue.slice(1, -1) : rawValue;
85
+ if (name === "data-track-index") {
86
+ const parsed = Number.parseInt(value, 10);
87
+ if (Number.isFinite(parsed))
88
+ track = parsed;
89
+ }
90
+ else if (name === "style") {
91
+ styleQuote = quoted ? rawValue[0] : '"';
92
+ styleValue = value;
93
+ styleStart = attr.index;
94
+ styleEnd = attr.index + attr[0].length;
95
+ }
96
+ }
97
+ if (track === null)
98
+ return match;
99
+ const desired = String(track);
100
+ if (styleValue !== null) {
101
+ let nextStyle;
102
+ if (/(^|;)\s*z-index\s*:/i.test(styleValue)) {
103
+ if (new RegExp(`(^|;)\\s*z-index\\s*:\\s*${desired}\\s*(;|$)`, "i").test(styleValue)) {
104
+ return match;
105
+ }
106
+ // Replacer function: a plain replacement string would interpret $-
107
+ // sequences inside the surrounding style (e.g. content:'20$') as
108
+ // substitution metacharacters and corrupt the attribute.
109
+ nextStyle = styleValue.replace(/(^|;)\s*z-index\s*:\s*[^;]*/i, (_zMatch, lead) => `${lead}z-index:${desired}`);
110
+ }
111
+ else {
112
+ nextStyle = styleValue.endsWith(";") || styleValue.length === 0
113
+ ? `${styleValue}z-index:${desired}`
114
+ : `${styleValue};z-index:${desired}`;
115
+ }
116
+ // Rebuild by slicing around the style attribute — never String.replace
117
+ // with user-controlled content.
118
+ const rebuiltAttrs = `${rawAttrs.slice(0, styleStart)} style=${styleQuote}${nextStyle}${styleQuote}${rawAttrs.slice(styleEnd)}`;
119
+ return `<${tag}${rebuiltAttrs}${selfClose ? "/" : ""}>`;
120
+ }
121
+ return `<${tag}${rawAttrs} style="z-index:${desired}"${selfClose ? "/" : ""}>`;
122
+ });
123
+ }
124
+ //# sourceMappingURL=composition-sanitize.js.map
@@ -0,0 +1,79 @@
1
+ import { mkdirSync, watch } from "node:fs";
2
+ import path from "node:path";
3
+ import { config } from "../config.js";
4
+ // fs.watch on macOS emits 1–3 events per save; coalesce a burst into one
5
+ // broadcast. Also drop events for a fork the server itself just wrote (the
6
+ // editor's own PUT) so a save doesn't echo back as a reload.
7
+ const COALESCE_MS = 80;
8
+ const SELF_WRITE_SUPPRESS_MS = 500;
9
+ class CompositionWatch {
10
+ listeners = new Set();
11
+ suppressUntil = new Map();
12
+ started = false;
13
+ pending = null;
14
+ timer = null;
15
+ forksDir() {
16
+ return path.join(config.VIDFARM_DATA_DIR, "storage", "compositions", "forks");
17
+ }
18
+ start() {
19
+ if (this.started || config.STORAGE_DRIVER !== "local")
20
+ return;
21
+ this.started = true;
22
+ const dir = this.forksDir();
23
+ mkdirSync(dir, { recursive: true });
24
+ try {
25
+ watch(dir, { recursive: true }, (_event, filename) => {
26
+ if (filename)
27
+ this.onFsEvent(String(filename));
28
+ });
29
+ console.log(`[vidfarm] live-reload watching ${dir}`);
30
+ }
31
+ catch (error) {
32
+ console.warn(`[vidfarm] live-reload watch failed: ${error instanceof Error ? error.message : String(error)}`);
33
+ }
34
+ }
35
+ // Call right before the server writes a working composition file so the
36
+ // editor's own save doesn't bounce back as an external reload.
37
+ suppressFork(forkId) {
38
+ this.suppressUntil.set(forkId, Date.now() + SELF_WRITE_SUPPRESS_MS);
39
+ }
40
+ subscribe(listener) {
41
+ this.listeners.add(listener);
42
+ return () => this.listeners.delete(listener);
43
+ }
44
+ onFsEvent(filename) {
45
+ // filename is relative to forksDir, e.g. "<forkId>/working/composition.html".
46
+ const parts = filename.split(/[\\/]/).filter(Boolean);
47
+ if (parts.length < 3 || parts[1] !== "working")
48
+ return;
49
+ const file = parts[2];
50
+ if (file !== "composition.html" && file !== "composition.json")
51
+ return;
52
+ const forkId = parts[0];
53
+ const until = this.suppressUntil.get(forkId);
54
+ if (until && Date.now() < until)
55
+ return;
56
+ this.pending = { forkId, file, at: Date.now() };
57
+ if (this.timer)
58
+ clearTimeout(this.timer);
59
+ this.timer = setTimeout(() => {
60
+ this.timer = null;
61
+ const change = this.pending;
62
+ this.pending = null;
63
+ if (change)
64
+ this.broadcast(change);
65
+ }, COALESCE_MS);
66
+ }
67
+ broadcast(change) {
68
+ for (const listener of [...this.listeners]) {
69
+ try {
70
+ listener(change);
71
+ }
72
+ catch {
73
+ // A dead SSE writer is dropped on its own onAbort; ignore here.
74
+ }
75
+ }
76
+ }
77
+ }
78
+ export const compositionWatch = new CompositionWatch();
79
+ //# sourceMappingURL=composition-watch.js.map
@@ -0,0 +1,93 @@
1
+ import { serverlessRecords } from "./serverless-records.js";
2
+ export class ForkNotFoundError extends Error {
3
+ constructor() { super("Fork not found"); this.name = "ForkNotFoundError"; }
4
+ }
5
+ export class ForkAccessDeniedError extends Error {
6
+ needed;
7
+ constructor(needed) {
8
+ super(`Missing required capability: ${needed}`);
9
+ this.needed = needed;
10
+ this.name = "ForkAccessDeniedError";
11
+ }
12
+ }
13
+ const capabilitiesByRole = {
14
+ viewer: { canView: true, canEdit: false, canPublish: false },
15
+ editor: { canView: true, canEdit: true, canPublish: false },
16
+ publisher: { canView: true, canEdit: true, canPublish: true }
17
+ };
18
+ function ownerCaps() {
19
+ return { isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner" };
20
+ }
21
+ function fromRole(role) {
22
+ return {
23
+ isOwner: false,
24
+ ...capabilitiesByRole[role],
25
+ canShare: false,
26
+ canDelete: false,
27
+ role
28
+ };
29
+ }
30
+ function mergeCaps(a, b) {
31
+ const stronger = roleStrength(a.role) >= roleStrength(b.role) ? a : b;
32
+ return {
33
+ isOwner: a.isOwner || b.isOwner,
34
+ canView: a.canView || b.canView,
35
+ canEdit: a.canEdit || b.canEdit,
36
+ canPublish: a.canPublish || b.canPublish,
37
+ canShare: a.canShare || b.canShare,
38
+ canDelete: a.canDelete || b.canDelete,
39
+ role: stronger.role
40
+ };
41
+ }
42
+ function roleStrength(role) {
43
+ switch (role) {
44
+ case "owner": return 4;
45
+ case "publisher": return 3;
46
+ case "editor": return 2;
47
+ case "viewer": return 1;
48
+ default: return 0;
49
+ }
50
+ }
51
+ function isExpired(expiresAt) {
52
+ if (!expiresAt)
53
+ return false;
54
+ return new Date(expiresAt).getTime() < Date.now();
55
+ }
56
+ export async function resolveForkAccess(input) {
57
+ const fork = await serverlessRecords.getCompositionFork(input.forkId);
58
+ if (!fork || fork.deletedAt) {
59
+ throw new ForkNotFoundError();
60
+ }
61
+ // Fork content lives under a public-read S3 prefix — any client with the
62
+ // fork_id can already fetch composition.html/json/manifest.json directly
63
+ // from S3. Reflect that reality in the API so read endpoints don't lie
64
+ // about protecting content that isn't actually protected. Edit/publish/
65
+ // share/delete stay gated below.
66
+ let caps = fromRole("viewer");
67
+ if (input.customerId && fork.customerId === input.customerId) {
68
+ caps = mergeCaps(caps, ownerCaps());
69
+ }
70
+ if (input.customerId) {
71
+ const allPerms = await serverlessRecords.listForkPermissions(fork.id);
72
+ const granted = allPerms.filter((perm) => perm.granteeType === "customer" && perm.granteeId === input.customerId && !isExpired(perm.expiresAt));
73
+ for (const perm of granted) {
74
+ caps = mergeCaps(caps, fromRole(perm.role));
75
+ }
76
+ }
77
+ if (input.shareToken) {
78
+ const link = await serverlessRecords.getForkShareLinkByToken(input.shareToken);
79
+ if (link && link.forkId === fork.id && !link.revokedAt && !isExpired(link.expiresAt)) {
80
+ caps = mergeCaps(caps, fromRole(link.role));
81
+ }
82
+ }
83
+ return { fork, capabilities: caps };
84
+ }
85
+ export async function assertForkAccess(input, required) {
86
+ const result = await resolveForkAccess(input);
87
+ const need = `can${required[0].toUpperCase()}${required.slice(1)}`;
88
+ if (!result.capabilities[need]) {
89
+ throw new ForkAccessDeniedError(need);
90
+ }
91
+ return result;
92
+ }
93
+ //# sourceMappingURL=fork-access.js.map
@@ -0,0 +1,42 @@
1
+ function buildManifest(storage, fork, target) {
2
+ const isWorking = target.kind === "working";
3
+ const htmlKey = isWorking
4
+ ? storage.compositionForkWorkingKey(fork.id, "composition.html")
5
+ : storage.compositionForkVersionKey(fork.id, target.version, "composition.html");
6
+ const jsonKey = isWorking
7
+ ? storage.compositionForkWorkingKey(fork.id, "composition.json")
8
+ : storage.compositionForkVersionKey(fork.id, target.version, "composition.json");
9
+ const manifestKey = isWorking
10
+ ? storage.compositionForkWorkingKey(fork.id, "manifest.json")
11
+ : storage.compositionForkVersionKey(fork.id, target.version, "manifest.json");
12
+ const parentManifestKey = fork.parentForkId && fork.parentVersion
13
+ ? storage.compositionForkVersionKey(fork.parentForkId, fork.parentVersion, "manifest.json")
14
+ : null;
15
+ return {
16
+ fork_id: fork.id,
17
+ customer_id: fork.customerId,
18
+ is_working: isWorking,
19
+ version: isWorking ? null : target.version,
20
+ latest_version: fork.latestVersion,
21
+ parent_fork_id: fork.parentForkId,
22
+ parent_version: fork.parentVersion,
23
+ template_id: fork.templateId,
24
+ visibility: fork.visibility,
25
+ title: fork.title,
26
+ composition_url: storage.getPublicUrl(htmlKey),
27
+ composition_json_url: storage.getPublicUrl(jsonKey),
28
+ manifest_url: storage.getPublicUrl(manifestKey),
29
+ parent_manifest_url: parentManifestKey ? storage.getPublicUrl(parentManifestKey) : null,
30
+ created_at: !isWorking && target.kind === "version" && target.createdAt ? target.createdAt : fork.createdAt,
31
+ updated_at: fork.updatedAt
32
+ };
33
+ }
34
+ export async function writeForkManifest(storage, fork, target) {
35
+ const manifest = buildManifest(storage, fork, target);
36
+ const key = target.kind === "working"
37
+ ? storage.compositionForkWorkingKey(fork.id, "manifest.json")
38
+ : storage.compositionForkVersionKey(fork.id, target.version, "manifest.json");
39
+ await storage.putJson(key, manifest);
40
+ return manifest;
41
+ }
42
+ //# sourceMappingURL=fork-manifest.js.map
@@ -0,0 +1,179 @@
1
+ import { createHash } from "node:crypto";
2
+ import { config } from "../config.js";
3
+ const BASE_URL = "https://api.zhaoli.com";
4
+ export function isGhostcutConfigured() {
5
+ return Boolean(config.GHOSTCUT_KEY && config.GHOSTCUT_SECRET);
6
+ }
7
+ // GhostCut bills per 30-second chunk with a 1-chunk minimum. A 45s clip is
8
+ // 2 chunks, a 90s clip is 3. Rate defaults to $0.10 per chunk but can be
9
+ // overridden via GHOSTCUT_USD_PER_30S.
10
+ export function estimateGhostcutCostUsd(durationSeconds) {
11
+ const usdPerChunk = config.GHOSTCUT_USD_PER_30S;
12
+ const positiveSeconds = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : 30;
13
+ const chunks = Math.max(1, Math.ceil(positiveSeconds / 30));
14
+ const usd = Number((chunks * usdPerChunk).toFixed(4));
15
+ return { chunks, usd, usdPerChunk };
16
+ }
17
+ function md5Hex(input) {
18
+ return createHash("md5").update(input).digest("hex");
19
+ }
20
+ async function ghostcutPost(path, payload, signal) {
21
+ if (!config.GHOSTCUT_KEY || !config.GHOSTCUT_SECRET) {
22
+ throw new Error("GhostCut credentials not configured");
23
+ }
24
+ const body = JSON.stringify(payload);
25
+ const sign = md5Hex(md5Hex(body) + config.GHOSTCUT_SECRET);
26
+ const response = await fetch(`${BASE_URL}${path}`, {
27
+ method: "POST",
28
+ headers: {
29
+ "Content-Type": "application/json",
30
+ AppKey: config.GHOSTCUT_KEY,
31
+ AppSign: sign
32
+ },
33
+ body,
34
+ signal
35
+ });
36
+ if (!response.ok) {
37
+ throw new Error(`GhostCut HTTP ${response.status} ${response.statusText}`);
38
+ }
39
+ return (await response.json());
40
+ }
41
+ // Submits a subtitle-removal task using GhostCut's Basic text-removal model.
42
+ // We deliberately do NOT pass `extra_inpaint_config.model` — that would opt
43
+ // into one of the Advanced tiers (Lite/Pro/Full), which are:
44
+ // 1. More expensive per 30s chunk
45
+ // 2. Require `videoInpaintMasks` to be supplied — advanced_lite in particular
46
+ // rejects with processStatus=26 / ValidationError
47
+ // ("video_inpaint_masks is not set") when no mask is passed
48
+ // Basic is the cheapest tier and handles hardcoded TikTok-style captions fine
49
+ // without requiring us to hand-crop a mask region for every video.
50
+ //
51
+ // needChineseOcclude=3 = "Automatically remove moving text". Per the docs this
52
+ // is the smart mode that only touches AI-detected text and leaves backgrounds
53
+ // alone — exactly what TikTok-style dynamic burned-in captions need. Mode 1
54
+ // (full-screen inpainting) is more aggressive; mode 3 sidesteps that class of
55
+ // failure while still catching the burned-in subtitles.
56
+ // Returns quickly (<2s). Throws on non-1000 response codes (auth, credits, etc).
57
+ export async function submitSubtitleRemoval(sourceUrl, signal) {
58
+ const payload = {
59
+ urls: [sourceUrl],
60
+ needChineseOcclude: 3,
61
+ videoInpaintLang: "all"
62
+ };
63
+ const res = await ghostcutPost("/v-w-c/gateway/ve/work/free", payload, signal);
64
+ if (res.code !== 1000) {
65
+ throw new Error(`GhostCut submit failed: code=${res.code} msg=${res.msg ?? "unknown"}`);
66
+ }
67
+ const taskId = res.body?.idProject ??
68
+ res.body?.dataList?.[0]?.idProject ??
69
+ res.body?.dataList?.[0]?.id;
70
+ if (typeof taskId !== "number") {
71
+ throw new Error(`GhostCut response missing task id: ${JSON.stringify(res)}`);
72
+ }
73
+ return { taskId };
74
+ }
75
+ export async function pollStatus(taskId, signal) {
76
+ const res = await ghostcutPost("/v-w-c/gateway/ve/work/status", { idProjects: [taskId] }, signal);
77
+ if (res.code !== 1000) {
78
+ return { state: "failed", reason: `status code=${res.code}` };
79
+ }
80
+ const entry = res.body?.content?.[0];
81
+ if (!entry) {
82
+ return { state: "queued", progress: 0 };
83
+ }
84
+ const processStatus = Number(entry.processStatus ?? 0);
85
+ const progress = Number(entry.processProgress ?? 0);
86
+ // GhostCut's canonical live states: 1 = done, 0 = processing, -2 = queued.
87
+ // Everything else is a terminal error condition. Notable codes we've hit
88
+ // in the wild: -1/2/3 (generic failure), 73 (insufficient points — account
89
+ // out of credits, task will NEVER progress). Treat all non-live codes as
90
+ // failed so we stop polling and fall back to the original video instead
91
+ // of spinning forever.
92
+ if (processStatus === 1) {
93
+ const rawUrl = entry.videoUrl ?? entry.videoUrls;
94
+ const videoUrl = Array.isArray(rawUrl) ? String(rawUrl[0] ?? "") : String(rawUrl ?? "");
95
+ if (!videoUrl)
96
+ return { state: "failed", reason: "processStatus=1 but no videoUrl" };
97
+ return { state: "done", videoUrl, progress: 100 };
98
+ }
99
+ if (processStatus === 0) {
100
+ return { state: "processing", progress };
101
+ }
102
+ if (processStatus === -2) {
103
+ return { state: "queued", progress };
104
+ }
105
+ // Any other code is terminal-failure. Assemble the most helpful reason
106
+ // string GhostCut gives us, in priority order:
107
+ // 1. processStatusEnum.descriptionEn — official English label for the code
108
+ // 2. errorDetail — long-form failure message ("Video too short", etc.)
109
+ // 3. errorMsg — short label
110
+ // 4. compact raw entry dump — fallback so unknown codes still capture
111
+ // whatever fields GhostCut populated, letting us reverse-engineer
112
+ // new failure modes without a second API round-trip.
113
+ // The Appendix A enum table (Feishu-only) isn't public, so relying on
114
+ // GhostCut's own free-text fields is the only way to name codes like 26.
115
+ const enumObj = entry.processStatusEnum;
116
+ const enumEn = typeof enumObj?.descriptionEn === "string" ? enumObj.descriptionEn.trim() : "";
117
+ const errorDetail = typeof entry.errorDetail === "string" ? entry.errorDetail.trim() : "";
118
+ const errorMsg = typeof entry.errorMsg === "string" ? entry.errorMsg.trim() : "";
119
+ const parts = [];
120
+ if (enumEn)
121
+ parts.push(enumEn);
122
+ if (errorDetail && errorDetail !== enumEn)
123
+ parts.push(`detail=${errorDetail}`);
124
+ if (errorMsg && errorMsg !== enumEn && errorMsg !== errorDetail)
125
+ parts.push(`msg=${errorMsg}`);
126
+ if (parts.length === 0) {
127
+ // Nothing human-readable — dump the raw payload so operators can still see
128
+ // whatever GhostCut *did* send back.
129
+ return { state: "failed", reason: `processStatus=${processStatus} raw=${safeCompactJson(entry, 500)}` };
130
+ }
131
+ return { state: "failed", reason: `processStatus=${processStatus} ${parts.join(" ")}` };
132
+ }
133
+ // Best-effort compact stringifier — clamps the output so a huge GhostCut
134
+ // entry doesn't blow up the log line, and falls back to a marker string
135
+ // when serialization itself fails (circular refs shouldn't happen but
136
+ // defense-in-depth for JSON.stringify).
137
+ function safeCompactJson(value, maxLen) {
138
+ try {
139
+ const raw = JSON.stringify(value);
140
+ if (raw.length <= maxLen)
141
+ return raw;
142
+ return `${raw.slice(0, maxLen)}…(+${raw.length - maxLen}b)`;
143
+ }
144
+ catch {
145
+ return "<unserializable>";
146
+ }
147
+ }
148
+ // Polls GhostCut until the task is done, fails, or the timeout expires.
149
+ // Throws on failure/timeout — callers use that to fall back to the original video.
150
+ export async function waitForCompletion(taskId, opts) {
151
+ const started = Date.now();
152
+ const pollIntervalMs = opts.pollIntervalMs ?? 5000;
153
+ while (true) {
154
+ if (opts.signal?.aborted)
155
+ throw new Error("GhostCut wait aborted");
156
+ const remaining = opts.timeoutMs - (Date.now() - started);
157
+ if (remaining <= 0)
158
+ throw new Error(`GhostCut task ${taskId} timed out`);
159
+ const status = await pollStatus(taskId, opts.signal);
160
+ if (status.state === "done")
161
+ return { videoUrl: status.videoUrl };
162
+ if (status.state === "failed")
163
+ throw new Error(`GhostCut task ${taskId} failed: ${status.reason}`);
164
+ await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, remaining)));
165
+ }
166
+ }
167
+ // Downloads the GhostCut-hosted result and mirrors it to our S3 bucket so
168
+ // composition HTML can point at a stable URL under our control (rather than a
169
+ // short-lived third-party CDN link).
170
+ export async function mirrorGhostcutVideoToStorage(input) {
171
+ const response = await fetch(input.videoUrl, { signal: input.signal });
172
+ if (!response.ok) {
173
+ throw new Error(`Failed to fetch GhostCut result: HTTP ${response.status}`);
174
+ }
175
+ const arrayBuffer = await response.arrayBuffer();
176
+ const result = await input.storage.putBuffer(input.key, Buffer.from(arrayBuffer), "video/mp4");
177
+ return { key: result.key, publicUrl: result.url };
178
+ }
179
+ //# sourceMappingURL=ghostcut.js.map