@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.
- package/README.md +3 -3
- package/demo/README.md +28 -0
- package/demo/dist/app.css +1 -0
- package/demo/dist/app.js +1184 -0
- package/demo/dist/chunks/chunk-DXB73IDG.js +1 -0
- package/demo/dist/chunks/chunk-S7OWAJDS.js +36 -0
- package/demo/dist/chunks/chunk-VTIBZ6AN.js +1 -0
- package/demo/dist/chunks/dist-ADSJKBVE.js +332 -0
- package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +1 -0
- package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +425 -0
- package/demo/dist/chunks/lib-XAQ37YOE.js +1 -0
- package/demo/dist/chunks/src-TJ2QYA4U.js +207 -0
- package/demo/dist/favicon.ico +0 -0
- package/demo/dist/icons/timeline/audio.svg +7 -0
- package/demo/dist/icons/timeline/captions.svg +5 -0
- package/demo/dist/icons/timeline/composition.svg +12 -0
- package/demo/dist/icons/timeline/image.svg +18 -0
- package/demo/dist/icons/timeline/music.svg +10 -0
- package/demo/dist/icons/timeline/text.svg +3 -0
- package/demo/dist/index.html +15 -0
- package/dist/src/account-pages-legacy.js +9396 -0
- package/dist/src/account-pages.js +61 -0
- package/dist/src/app.js +14378 -0
- package/dist/src/cli.js +1 -1
- package/dist/src/composition-runtime.js +613 -0
- package/dist/src/config.js +166 -0
- package/dist/src/context.js +447 -0
- package/dist/src/dev-app-legacy.js +739 -0
- package/dist/src/dev-app.js +6 -0
- package/dist/src/domain.js +2 -0
- package/dist/src/editor-chat-history.js +82 -0
- package/dist/src/editor-chat.js +449 -0
- package/dist/src/editor-dark-theme.js +1128 -0
- package/dist/src/frontend/debug.js +71 -0
- package/dist/src/frontend/flockposter-cache-store.js +124 -0
- package/dist/src/frontend/homepage-client.js +182 -0
- package/dist/src/frontend/homepage-shared.js +4 -0
- package/dist/src/frontend/homepage-store.js +28 -0
- package/dist/src/frontend/homepage-view.js +547 -0
- package/dist/src/frontend/page-runtime-client.js +132 -0
- package/dist/src/frontend/page-runtime-store.js +9 -0
- package/dist/src/frontend/sentry.js +42 -0
- package/dist/src/frontend/template-editor-chat.js +3960 -0
- package/dist/src/help-page.js +346 -0
- package/dist/src/homepage.js +1235 -0
- package/dist/src/hyperframes/composition.js +180 -0
- package/dist/src/index.js +16 -0
- package/dist/src/instrument.js +30 -0
- package/dist/src/lib/crypto.js +45 -0
- package/dist/src/lib/dev-log.js +54 -0
- package/dist/src/lib/display-name.js +11 -0
- package/dist/src/lib/ids.js +24 -0
- package/dist/src/lib/images.js +19 -0
- package/dist/src/lib/json.js +15 -0
- package/dist/src/lib/package-root.js +47 -0
- package/dist/src/lib/template-paths.js +28 -0
- package/dist/src/lib/time.js +7 -0
- package/dist/src/lib/url-clean.js +85 -0
- package/dist/src/page-runtime.js +2 -0
- package/dist/src/page-shell.js +1381 -0
- package/dist/src/primitive-context.js +357 -0
- package/dist/src/primitive-registry.js +2436 -0
- package/dist/src/primitive-sdk.js +4 -0
- package/dist/src/primitives/hyperframes-media.js +108 -0
- package/dist/src/react-page-shell.js +35 -0
- package/dist/src/ready-post-schedule-component.js +1540 -0
- package/dist/src/registry.js +296 -0
- package/dist/src/runtime.js +35 -0
- package/dist/src/services/api-call-history.js +249 -0
- package/dist/src/services/auth.js +152 -0
- package/dist/src/services/billing-pricing.js +39 -0
- package/dist/src/services/billing.js +228 -0
- package/dist/src/services/cast.js +127 -0
- package/dist/src/services/chat-threads.js +92 -0
- package/dist/src/services/composition-sanitize.js +124 -0
- package/dist/src/services/composition-watch.js +79 -0
- package/dist/src/services/fork-access.js +93 -0
- package/dist/src/services/fork-manifest.js +42 -0
- package/dist/src/services/ghostcut.js +179 -0
- package/dist/src/services/hyperframes.js +2307 -0
- package/dist/src/services/job-capacity.js +14 -0
- package/dist/src/services/job-logs.js +197 -0
- package/dist/src/services/jobs.js +136 -0
- package/dist/src/services/local-dynamo.js +0 -0
- package/dist/src/services/media-processing.js +766 -0
- package/dist/src/services/primitive-media-lambda.js +280 -0
- package/dist/src/services/providers.js +2926 -0
- package/dist/src/services/rate-limits.js +262 -0
- package/dist/src/services/serverless-auth.js +382 -0
- package/dist/src/services/serverless-jobs.js +1082 -0
- package/dist/src/services/serverless-provider-keys.js +409 -0
- package/dist/src/services/serverless-records.js +1385 -0
- package/dist/src/services/serverless-template-configs.js +75 -0
- package/dist/src/services/storage.js +383 -0
- package/dist/src/services/template-certification.js +413 -0
- package/dist/src/services/template-loader.js +99 -0
- package/dist/src/services/template-runtime-bundles.js +217 -0
- package/dist/src/services/template-sources.js +1017 -0
- package/dist/src/services/video-normalization.js +2 -0
- package/dist/src/services/webhooks.js +62 -0
- package/dist/src/template-editor-pages.js +2576 -0
- package/dist/src/template-editor-shell.js +2840 -0
- package/dist/src/template-sdk.js +4 -0
- package/dist/src/worker.js +17 -0
- package/package.json +6 -4
- package/public/assets/homepage-app.js +54 -0
- package/public/assets/homepage-client-app.js +80 -0
- package/public/assets/page-runtime-client-app.js +94 -0
- package/src/assets/SELLING_AWARENESS_STAGES.md +579 -0
- package/src/assets/SELLING_WITH_HOOKS.md +377 -0
- package/src/assets/SELLING_WITH_VSLS.md +606 -0
- package/src/assets/favicon.ico +0 -0
- 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
|