@shun-js/remotion-server 0.6.7 → 2.0.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/app.js +16 -2
- package/package.json +12 -10
- package/server/controller/LLMController.js +12 -0
- package/server/service/LLMService.js +152 -0
- package/server/util/check.js +23 -0
- package/server/util/feishu.js +37 -4
- package/server/util/prompt-agent.js +112 -0
- package/LICENSE +0 -21
- package/server/model/RemotionModel.js +0 -58
- package/server/service/RemotionService.js +0 -31
- package/server/task/RemotionTask.js +0 -26
- package/server/util/renderer.js +0 -184
- package/server/util/uploader.js +0 -51
- package/server/util/work.js +0 -92
package/app.js
CHANGED
|
@@ -13,16 +13,30 @@ const { parseServerConfig } = require('@shun-js/shun-config');
|
|
|
13
13
|
// options
|
|
14
14
|
const options = {};
|
|
15
15
|
|
|
16
|
+
// options cros
|
|
17
|
+
options.cros = true;
|
|
18
|
+
|
|
16
19
|
// options config
|
|
17
20
|
options.config = config;
|
|
18
21
|
|
|
19
|
-
// options
|
|
20
|
-
options.
|
|
22
|
+
// options redis
|
|
23
|
+
options.redis = require('qiao-redis');
|
|
24
|
+
options.redisOptions = config.redisOptions;
|
|
21
25
|
|
|
22
26
|
// options log
|
|
23
27
|
options.log = require('qiao-log');
|
|
24
28
|
options.logOptions = require('./server/log-options.js')();
|
|
25
29
|
|
|
30
|
+
// options rate limit
|
|
31
|
+
options.rateLimitLib = require('qiao-rate-limit');
|
|
32
|
+
options.rateLimitOptions = config.rateLimitOptions;
|
|
33
|
+
|
|
34
|
+
// options checks
|
|
35
|
+
options.checks = [require('./server/util/check.js').checkUserAuth];
|
|
36
|
+
|
|
37
|
+
// options modules
|
|
38
|
+
options.modules = [require('qiao-z-sms').init, require('qiao-z-nuser').init];
|
|
39
|
+
|
|
26
40
|
// go
|
|
27
41
|
const app = await require('qiao-z')(options);
|
|
28
42
|
app.listen(config.port);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shun-js/remotion-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "remotion.cool server",
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "remotion.cool server - AI agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "uikoo9 <uikoo9@qq.com>",
|
|
7
7
|
"homepage": "https://github.com/uikoo9/shun-js",
|
|
@@ -17,19 +17,21 @@
|
|
|
17
17
|
"app.js"
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@aws-sdk/client-s3": "^3.988.0",
|
|
21
|
-
"@remotion/bundler": "^4.0.421",
|
|
22
|
-
"@remotion/renderer": "^4.0.421",
|
|
23
20
|
"@shun-js/shun-config": "^0.3.1",
|
|
24
21
|
"@shun-js/shun-service": "^0.3.1",
|
|
25
|
-
"
|
|
26
|
-
"qiao-log": "^
|
|
27
|
-
"qiao-
|
|
28
|
-
"qiao-
|
|
22
|
+
"mime-types": "^3.0.2",
|
|
23
|
+
"qiao-log": "^6.0.0",
|
|
24
|
+
"qiao-rate-limit": "^6.0.0",
|
|
25
|
+
"qiao-redis": "^6.0.0",
|
|
26
|
+
"qiao-z": "^6.0.0",
|
|
27
|
+
"qiao-z-nuser": "^6.0.0",
|
|
28
|
+
"qiao-z-service": "^6.0.0",
|
|
29
|
+
"qiao-z-sms": "^6.0.0",
|
|
30
|
+
"viho-llm": "^1.1.0"
|
|
29
31
|
},
|
|
30
32
|
"publishConfig": {
|
|
31
33
|
"access": "public",
|
|
32
34
|
"registry": "https://registry.npmjs.org/"
|
|
33
35
|
},
|
|
34
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "d68cc4aa2cbfbe63c5db319956fd39e9a42694ca"
|
|
35
37
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// llm
|
|
2
|
+
const { OpenAIAPI, runAgents } = require('viho-llm');
|
|
3
|
+
const prompts = require('../util/prompt-agent.js');
|
|
4
|
+
|
|
5
|
+
// util
|
|
6
|
+
const { chatFeishuMsg, errorFeishuMsgWithReq } = require('../util/feishu.js');
|
|
7
|
+
|
|
8
|
+
// LLM config
|
|
9
|
+
const llmConfig = global.QZ_CONFIG.llm;
|
|
10
|
+
const finalLLMConfig = llmConfig[llmConfig.default];
|
|
11
|
+
const llm = OpenAIAPI(finalLLMConfig);
|
|
12
|
+
const modelName = finalLLMConfig.modelName;
|
|
13
|
+
const thinking = finalLLMConfig.thinking;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* remotionAgent - Remotion video code generation agent
|
|
17
|
+
* Decision LLM -> generate / modify / reply / irrelevant
|
|
18
|
+
*/
|
|
19
|
+
exports.remotionAgent = async (req, res) => {
|
|
20
|
+
const methodName = 'remotionAgent';
|
|
21
|
+
const messages = req.body.messages;
|
|
22
|
+
const currentCode = req.body.currentCode || '';
|
|
23
|
+
|
|
24
|
+
if (!messages?.length) {
|
|
25
|
+
const msg = 'need messages';
|
|
26
|
+
req.logger.error(methodName, msg);
|
|
27
|
+
res.jsonFail(msg);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Start SSE
|
|
32
|
+
res.streamingStart();
|
|
33
|
+
|
|
34
|
+
const lastUserMsg = messages.filter((m) => m.role === 'user').pop()?.content || '';
|
|
35
|
+
req.logger.info(methodName, 'lastUserMsg', lastUserMsg);
|
|
36
|
+
chatFeishuMsg(req, `${lastUserMsg}`);
|
|
37
|
+
|
|
38
|
+
// Clean conversation history
|
|
39
|
+
const cleanedMessages = messages.map((m) => {
|
|
40
|
+
if (m.role === 'assistant' && /^[✅❌]/.test(m.content)) {
|
|
41
|
+
return { ...m, content: '[executed]' };
|
|
42
|
+
}
|
|
43
|
+
return m;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const startTime = Date.now();
|
|
48
|
+
|
|
49
|
+
// 1. Agent decision
|
|
50
|
+
res.streaming(`data: ${JSON.stringify({ type: 'status', step: 'thinking' })}\n\n`);
|
|
51
|
+
|
|
52
|
+
let decision = null;
|
|
53
|
+
try {
|
|
54
|
+
await runAgents([
|
|
55
|
+
{
|
|
56
|
+
agentStartCallback: () => {
|
|
57
|
+
req.logger.info(methodName, 'step: agent decision');
|
|
58
|
+
},
|
|
59
|
+
agentRequestOptions: {
|
|
60
|
+
llm,
|
|
61
|
+
modelName,
|
|
62
|
+
thinking,
|
|
63
|
+
isJson: true,
|
|
64
|
+
messages: [{ role: 'system', content: prompts.AGENT_PROMPT }, ...cleanedMessages],
|
|
65
|
+
},
|
|
66
|
+
agentEndCallback: (result) => {
|
|
67
|
+
decision = result;
|
|
68
|
+
const duration = Date.now() - startTime;
|
|
69
|
+
req.logger.info(methodName, 'decision', JSON.stringify(decision), `${duration}ms`);
|
|
70
|
+
chatFeishuMsg(req, `decision-${decision.action}`);
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
} catch (jsonErr) {
|
|
75
|
+
req.logger.warn(methodName, 'JSON parse fallback', jsonErr.message);
|
|
76
|
+
const rawText = jsonErr.message.replace('Cannot parse JSON from LLM response:', '').trim();
|
|
77
|
+
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
|
|
78
|
+
if (jsonMatch) {
|
|
79
|
+
try {
|
|
80
|
+
decision = JSON.parse(jsonMatch[0]);
|
|
81
|
+
} catch {
|
|
82
|
+
decision = { action: 'reply', message: 'Sure! What kind of video animation would you like to create?' };
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
decision = { action: 'reply', message: 'Sure! What kind of video animation would you like to create?' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 2. Dispatch
|
|
90
|
+
if (decision.action === 'reply') {
|
|
91
|
+
res.streaming(`data: ${JSON.stringify({ type: 'message', content: decision.message })}\n\n`);
|
|
92
|
+
} else if (decision.action === 'irrelevant') {
|
|
93
|
+
res.streaming(`data: ${JSON.stringify({ type: 'welcome' })}\n\n`);
|
|
94
|
+
} else if (decision.action === 'generate' || decision.action === 'modify') {
|
|
95
|
+
// Generate or modify Remotion code
|
|
96
|
+
res.streaming(`data: ${JSON.stringify({ type: 'status', step: 'generating' })}\n\n`);
|
|
97
|
+
req.logger.info(methodName, 'step: generate remotion code');
|
|
98
|
+
|
|
99
|
+
let codeResult = '';
|
|
100
|
+
await runAgents([
|
|
101
|
+
{
|
|
102
|
+
agentStartCallback: () => {
|
|
103
|
+
req.logger.info(methodName, 'step: code generation');
|
|
104
|
+
},
|
|
105
|
+
agentRequestOptions: {
|
|
106
|
+
llm,
|
|
107
|
+
modelName,
|
|
108
|
+
thinking,
|
|
109
|
+
get messages() {
|
|
110
|
+
const codePrompt =
|
|
111
|
+
decision.action === 'modify'
|
|
112
|
+
? prompts.MODIFY_CODE_PROMPT.replace('{currentCode}', currentCode).replace(
|
|
113
|
+
'{description}',
|
|
114
|
+
decision.description,
|
|
115
|
+
)
|
|
116
|
+
: prompts.GENERATE_CODE_PROMPT.replace('{description}', decision.description);
|
|
117
|
+
return [{ role: 'user', content: codePrompt }];
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
agentEndCallback: (result) => {
|
|
121
|
+
codeResult = result;
|
|
122
|
+
req.logger.info(methodName, 'codeResult', String(codeResult).slice(0, 200) + '...');
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
// Clean code (remove possible markdown markers)
|
|
128
|
+
let cleanCode = String(codeResult).trim();
|
|
129
|
+
if (cleanCode.startsWith('```')) {
|
|
130
|
+
cleanCode = cleanCode.replace(/^```[\w]*\n?/, '').replace(/\n?```$/, '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const duration = Date.now() - startTime;
|
|
134
|
+
chatFeishuMsg(req, `code-${cleanCode.slice(0, 100)}`);
|
|
135
|
+
res.streaming(`data: ${JSON.stringify({ type: 'code', code: cleanCode, duration })}\n\n`);
|
|
136
|
+
} else {
|
|
137
|
+
res.streaming(
|
|
138
|
+
`data: ${JSON.stringify({ type: 'message', content: decision.message || 'Could you please say that again?' })}\n\n`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
res.streamingEnd();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
req.logger.error(methodName, 'error', error);
|
|
145
|
+
errorFeishuMsgWithReq(req, error.message);
|
|
146
|
+
const userMessage = error.message?.includes('Cannot parse JSON')
|
|
147
|
+
? 'Failed to process request, please describe your needs again'
|
|
148
|
+
: 'Service temporarily unavailable, please try again later';
|
|
149
|
+
res.streaming(`data: ${JSON.stringify({ type: 'error', message: userMessage })}\n\n`);
|
|
150
|
+
res.streamingEnd();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// qiao
|
|
2
|
+
const { userCheck } = require('qiao-z-service');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* checkUserAuth
|
|
6
|
+
* @param {*} req
|
|
7
|
+
* @param {*} res
|
|
8
|
+
* @returns
|
|
9
|
+
*/
|
|
10
|
+
exports.checkUserAuth = async function (req, res) {
|
|
11
|
+
const userCheckRes = await userCheck(global.QZ_CONFIG.user, {
|
|
12
|
+
userid: req.headers.userid,
|
|
13
|
+
usertoken: req.headers.usertoken,
|
|
14
|
+
paths: JSON.stringify(global.QZ_CONFIG.paths),
|
|
15
|
+
path: req.url.pathname,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// pass
|
|
19
|
+
if (userCheckRes && userCheckRes.type === 'success') return true;
|
|
20
|
+
|
|
21
|
+
// r
|
|
22
|
+
res.json(userCheckRes);
|
|
23
|
+
};
|
package/server/util/feishu.js
CHANGED
|
@@ -11,15 +11,48 @@ exports.feishuMsg = (msg) => {
|
|
|
11
11
|
feishuBot({
|
|
12
12
|
url: global.QZ_CONFIG.feishu.url,
|
|
13
13
|
feishuUrl: global.QZ_CONFIG.feishu.feishuUrl,
|
|
14
|
-
feishuMsg:
|
|
14
|
+
feishuMsg: msg,
|
|
15
15
|
});
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
// is bot
|
|
19
|
+
function isBot(req) {
|
|
20
|
+
const ua = req.useragent;
|
|
21
|
+
const hasOSName = ua && ua.os && ua.os.name;
|
|
22
|
+
const isGoogleBot = ua && ua.browser && ua.browser.name === 'Googlebot';
|
|
23
|
+
const isMetaBot = ua && ua.browser && ua.browser.name === 'meta-externalagent';
|
|
24
|
+
const isSafariBot = ua && ua.engine && ua.engine.name === 'WebKit' && ua.engine.version === '605.1.15';
|
|
25
|
+
return !hasOSName || isGoogleBot || isMetaBot || isSafariBot;
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
/**
|
|
19
|
-
* errorFeishuMsg
|
|
29
|
+
* errorFeishuMsg (for task/cron usage - no req)
|
|
20
30
|
* @param {*} msg
|
|
21
|
-
* @returns
|
|
22
31
|
*/
|
|
23
32
|
exports.errorFeishuMsg = (msg) => {
|
|
24
|
-
exports.feishuMsg(
|
|
33
|
+
exports.feishuMsg(`[Alert] Service error: ${msg}, check logs.`);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* errorFeishuMsgWithReq (for request handler usage)
|
|
38
|
+
* @param {*} req
|
|
39
|
+
* @param {*} msg
|
|
40
|
+
*/
|
|
41
|
+
exports.errorFeishuMsgWithReq = (req, msg) => {
|
|
42
|
+
if (isBot(req)) return;
|
|
43
|
+
const uaJson = JSON.stringify(req.useragent || {});
|
|
44
|
+
exports.feishuMsg(`[Alert] Service error: ${msg}, check logs. UA: ${uaJson}`);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* chatFeishuMsg
|
|
49
|
+
* @param {*} req
|
|
50
|
+
* @param {*} msg
|
|
51
|
+
*/
|
|
52
|
+
exports.chatFeishuMsg = (req, msg) => {
|
|
53
|
+
if (isBot(req)) return;
|
|
54
|
+
const uaJson = JSON.stringify(req.useragent || {});
|
|
55
|
+
const userid = req.headers.userid;
|
|
56
|
+
const finalMsg = `[Chat] /remotion-agent\nuserid:${userid}\nua:\n${uaJson}\nmsg:\n${msg}`;
|
|
57
|
+
exports.feishuMsg(finalMsg);
|
|
25
58
|
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remotion Agent Prompts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
/**
|
|
7
|
+
* Agent decision prompt
|
|
8
|
+
*/
|
|
9
|
+
AGENT_PROMPT: `You are a Remotion video creation assistant that helps users create animated videos using React code.
|
|
10
|
+
|
|
11
|
+
## Capabilities
|
|
12
|
+
1. Generate Remotion video code (React components) based on user descriptions
|
|
13
|
+
2. Modify existing video code based on user requests
|
|
14
|
+
3. Answer questions about Remotion video creation
|
|
15
|
+
|
|
16
|
+
## Decision Rules
|
|
17
|
+
After receiving a user message, respond with JSON only:
|
|
18
|
+
|
|
19
|
+
1. Generate new video (user wants to create a new animation/video with a clear enough description):
|
|
20
|
+
{"action":"generate","description":"Detailed description of video content, animation effects, colors, style, etc."}
|
|
21
|
+
|
|
22
|
+
2. Modify existing video (user wants to change the current preview video):
|
|
23
|
+
{"action":"modify","description":"Specific changes to make"}
|
|
24
|
+
|
|
25
|
+
3. Text reply (follow-up questions, confirmations, guidance, answering questions):
|
|
26
|
+
{"action":"reply","message":"Your reply content"}
|
|
27
|
+
|
|
28
|
+
4. Completely unrelated to video creation:
|
|
29
|
+
{"action":"irrelevant"}
|
|
30
|
+
|
|
31
|
+
## Decision Criteria
|
|
32
|
+
- User describes a desired video effect -> generate
|
|
33
|
+
- User says "make me a video" without specifics -> reply to ask what effect they want
|
|
34
|
+
- User asks to change colors/animation/text/layout etc. -> modify
|
|
35
|
+
- User asks Remotion-related questions -> reply with answer
|
|
36
|
+
- User chats about unrelated topics -> irrelevant
|
|
37
|
+
|
|
38
|
+
## Reply Style
|
|
39
|
+
- Be friendly and natural when asking follow-up questions
|
|
40
|
+
- For generate/modify, the description should be detailed, including animation effects, colors, layout, etc.
|
|
41
|
+
|
|
42
|
+
Strict requirements:
|
|
43
|
+
1. Your reply must be and can only be a valid JSON object
|
|
44
|
+
2. Do not include any other text, explanations, or markdown markers`,
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate Remotion code
|
|
48
|
+
*/
|
|
49
|
+
GENERATE_CODE_PROMPT: `You are a Remotion video code expert. Generate a complete Remotion React component based on the user's requirements.
|
|
50
|
+
|
|
51
|
+
User requirement: {description}
|
|
52
|
+
|
|
53
|
+
## Code Requirements
|
|
54
|
+
|
|
55
|
+
1. Must define a React component named \`MyComposition\`
|
|
56
|
+
2. The following variables are auto-injected, do NOT import them:
|
|
57
|
+
- React
|
|
58
|
+
- AbsoluteFill
|
|
59
|
+
- useCurrentFrame
|
|
60
|
+
- interpolate
|
|
61
|
+
- useVideoConfig
|
|
62
|
+
3. Do not write any import or export statements
|
|
63
|
+
4. Video parameters: 1280x720, 30fps, 120 frames (4 seconds)
|
|
64
|
+
5. Use interpolate to create smooth animations
|
|
65
|
+
6. Use inline styles, do not use CSS files
|
|
66
|
+
|
|
67
|
+
## Code Template
|
|
68
|
+
|
|
69
|
+
const MyComposition = () => {
|
|
70
|
+
const frame = useCurrentFrame();
|
|
71
|
+
const { width, height } = useVideoConfig();
|
|
72
|
+
|
|
73
|
+
// Use interpolate for animations
|
|
74
|
+
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: 'clamp' });
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<AbsoluteFill style={{ backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
|
|
78
|
+
<div style={{ opacity }}>
|
|
79
|
+
{/* Your content */}
|
|
80
|
+
</div>
|
|
81
|
+
</AbsoluteFill>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
## Common Animation Techniques
|
|
86
|
+
- Fade in: interpolate(frame, [start, end], [0, 1], { extrapolateRight: 'clamp' })
|
|
87
|
+
- Scale: interpolate(frame, [start, end], [0.5, 1], { extrapolateRight: 'clamp' })
|
|
88
|
+
- Slide in: interpolate(frame, [start, end], [-100, 0], { extrapolateRight: 'clamp' })
|
|
89
|
+
- Rotate: interpolate(frame, [0, 120], [0, 360])
|
|
90
|
+
- Bounce: can use spring() but requires import, use interpolate to simulate here
|
|
91
|
+
|
|
92
|
+
Only reply with code, do not include \`\`\` markers or any explanatory text.`,
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Modify Remotion code
|
|
96
|
+
*/
|
|
97
|
+
MODIFY_CODE_PROMPT: `You are a Remotion video code expert. Modify the existing code based on user requirements.
|
|
98
|
+
|
|
99
|
+
Current code:
|
|
100
|
+
{currentCode}
|
|
101
|
+
|
|
102
|
+
User requirement: {description}
|
|
103
|
+
|
|
104
|
+
## Modification Rules
|
|
105
|
+
1. Keep the component name as \`MyComposition\`
|
|
106
|
+
2. Do not add import or export statements
|
|
107
|
+
3. The following variables are auto-injected: React, AbsoluteFill, useCurrentFrame, interpolate, useVideoConfig
|
|
108
|
+
4. Only modify what the user requested, keep the rest unchanged
|
|
109
|
+
5. Ensure the modified code is complete and runnable
|
|
110
|
+
|
|
111
|
+
Only reply with the complete modified code, do not include \`\`\` markers or any explanatory text.`,
|
|
112
|
+
};
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025 qiaowenbin<uikoo9@qq.com>
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
// supabase
|
|
2
|
-
const { createClient } = require('@supabase/supabase-js');
|
|
3
|
-
const supabase = createClient(global.QZ_CONFIG.SUPABASE_URL, global.QZ_CONFIG.SUPABASE_SERVICE_KEY);
|
|
4
|
-
|
|
5
|
-
// logger
|
|
6
|
-
const Logger = require('qiao-log');
|
|
7
|
-
const logOptions = require('../log-options.js')();
|
|
8
|
-
const logger = Logger(logOptions);
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* fetchPendingWorks
|
|
12
|
-
* @returns
|
|
13
|
-
*/
|
|
14
|
-
exports.fetchPendingWorks = async () => {
|
|
15
|
-
const methodName = 'fetchPendingWorks';
|
|
16
|
-
|
|
17
|
-
try {
|
|
18
|
-
// query
|
|
19
|
-
const { data, error } = await supabase.rpc('get_pending_works_locked', {
|
|
20
|
-
max_count: 1,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
// check
|
|
24
|
-
if (error) {
|
|
25
|
-
logger.error(methodName, 'Error fetching pending works:', error);
|
|
26
|
-
return [];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// r
|
|
30
|
-
return data || [];
|
|
31
|
-
} catch (error) {
|
|
32
|
-
logger.error(methodName, 'Unexpected error fetching works:', error);
|
|
33
|
-
return [];
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* 更新作品渲染状态
|
|
39
|
-
*/
|
|
40
|
-
exports.updateRenderStatus = async (workId, renderStatus, updates = {}) => {
|
|
41
|
-
const methodName = 'updateRenderStatus';
|
|
42
|
-
try {
|
|
43
|
-
const { error } = await supabase
|
|
44
|
-
.from('works')
|
|
45
|
-
.update({
|
|
46
|
-
render_status: renderStatus,
|
|
47
|
-
updated_at: new Date().toISOString(),
|
|
48
|
-
...updates,
|
|
49
|
-
})
|
|
50
|
-
.eq('id', workId);
|
|
51
|
-
|
|
52
|
-
if (error) {
|
|
53
|
-
logger.error(methodName, `Error updating work ${workId} to ${renderStatus}:`, error);
|
|
54
|
-
}
|
|
55
|
-
} catch (error) {
|
|
56
|
-
logger.error(methodName, 'Unexpected error updating render status:', error);
|
|
57
|
-
}
|
|
58
|
-
};
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
// model
|
|
2
|
-
const { fetchPendingWorks } = require('../model/RemotionModel.js');
|
|
3
|
-
|
|
4
|
-
// go
|
|
5
|
-
const { processWork } = require('../util/work.js');
|
|
6
|
-
|
|
7
|
-
// logger
|
|
8
|
-
const Logger = require('qiao-log');
|
|
9
|
-
const logOptions = require('../log-options.js')();
|
|
10
|
-
const logger = Logger(logOptions);
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* genVideo
|
|
14
|
-
*/
|
|
15
|
-
exports.genVideo = async () => {
|
|
16
|
-
const methodName = 'genVideo';
|
|
17
|
-
|
|
18
|
-
// go
|
|
19
|
-
logger.info(methodName, 'start');
|
|
20
|
-
|
|
21
|
-
// rows
|
|
22
|
-
const rows = await fetchPendingWorks();
|
|
23
|
-
if (!rows || !rows.length) return;
|
|
24
|
-
|
|
25
|
-
// work
|
|
26
|
-
const work = rows[0];
|
|
27
|
-
await processWork(work);
|
|
28
|
-
|
|
29
|
-
// end
|
|
30
|
-
logger.info(methodName, 'end');
|
|
31
|
-
};
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
// service
|
|
2
|
-
const { genVideo } = require('../service/RemotionService.js');
|
|
3
|
-
|
|
4
|
-
// logger
|
|
5
|
-
const Logger = require('qiao-log');
|
|
6
|
-
const logOptions = require('../log-options.js')();
|
|
7
|
-
const logger = Logger(logOptions);
|
|
8
|
-
|
|
9
|
-
// RemotionTask.js
|
|
10
|
-
exports.runAndInit = true;
|
|
11
|
-
exports.time = '* 1 * * *';
|
|
12
|
-
|
|
13
|
-
// tick
|
|
14
|
-
let start = false;
|
|
15
|
-
exports.tick = async () => {
|
|
16
|
-
logger.info('start', start);
|
|
17
|
-
if (start) return;
|
|
18
|
-
|
|
19
|
-
start = true;
|
|
20
|
-
logger.info('start', start);
|
|
21
|
-
|
|
22
|
-
await genVideo();
|
|
23
|
-
|
|
24
|
-
start = false;
|
|
25
|
-
logger.info('start', start);
|
|
26
|
-
};
|
package/server/util/renderer.js
DELETED
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
// fs
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
|
|
5
|
-
// remotion
|
|
6
|
-
const { bundle } = require('@remotion/bundler');
|
|
7
|
-
const { renderMedia, selectComposition } = require('@remotion/renderer');
|
|
8
|
-
|
|
9
|
-
// logger
|
|
10
|
-
const Logger = require('qiao-log');
|
|
11
|
-
const logOptions = require('../log-options.js')();
|
|
12
|
-
const logger = Logger(logOptions);
|
|
13
|
-
|
|
14
|
-
// 浏览器配置 - 使用固定的全局路径,避免每次重新下载
|
|
15
|
-
const BROWSER_EXECUTABLE_PATH = path.join(
|
|
16
|
-
'/home/ubuntu/remotions/browser',
|
|
17
|
-
'chrome-headless-shell',
|
|
18
|
-
'linux-arm64',
|
|
19
|
-
'chrome-headless-shell-linux-arm64',
|
|
20
|
-
'headless_shell',
|
|
21
|
-
);
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* 初始化浏览器 - 如果全局浏览器不存在,从 node_modules 复制
|
|
25
|
-
*/
|
|
26
|
-
function ensureBrowserExists() {
|
|
27
|
-
const methodName = 'ensureBrowserExists';
|
|
28
|
-
|
|
29
|
-
// 如果全局浏览器已存在,直接返回
|
|
30
|
-
if (fs.existsSync(BROWSER_EXECUTABLE_PATH)) {
|
|
31
|
-
logger.info(methodName, 'Browser already exists at:', BROWSER_EXECUTABLE_PATH);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
logger.info(methodName, 'Global browser not found, attempting to copy from node_modules...');
|
|
36
|
-
|
|
37
|
-
// 查找 node_modules 中的浏览器
|
|
38
|
-
const nodeModulesBrowserPath = path.join(__dirname, '..', '..', 'node_modules', '.remotion', 'chrome-headless-shell');
|
|
39
|
-
|
|
40
|
-
if (fs.existsSync(nodeModulesBrowserPath)) {
|
|
41
|
-
// 创建目标目录
|
|
42
|
-
const globalBrowserDir = path.join('/home/ubuntu/remotions/browser');
|
|
43
|
-
fs.mkdirSync(globalBrowserDir, { recursive: true });
|
|
44
|
-
|
|
45
|
-
// 复制浏览器文件
|
|
46
|
-
logger.info(methodName, 'Copying browser from:', nodeModulesBrowserPath);
|
|
47
|
-
logger.info(methodName, 'Copying browser to:', globalBrowserDir);
|
|
48
|
-
|
|
49
|
-
fs.cpSync(nodeModulesBrowserPath, path.join(globalBrowserDir, 'chrome-headless-shell'), {
|
|
50
|
-
recursive: true,
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
logger.info(methodName, 'Browser copied successfully to:', BROWSER_EXECUTABLE_PATH);
|
|
54
|
-
} else {
|
|
55
|
-
logger.warn(methodName, 'Browser not found in node_modules, will download on first use');
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* 渲染 Remotion 视频
|
|
61
|
-
* @param {Object} options
|
|
62
|
-
* @param {string} options.sourceCode - 用户的 React 代码
|
|
63
|
-
* @param {string} options.outputPath - 输出视频路径
|
|
64
|
-
* @param {number} options.width - 视频宽度
|
|
65
|
-
* @param {number} options.height - 视频高度
|
|
66
|
-
* @param {number} options.fps - 帧率
|
|
67
|
-
*/
|
|
68
|
-
exports.renderVideo = async ({ sourceCode, outputPath, width, height, fps }) => {
|
|
69
|
-
const methodName = 'renderVideo';
|
|
70
|
-
|
|
71
|
-
// const
|
|
72
|
-
const tempDir = path.join('/tmp', 'remotion-render', Date.now().toString());
|
|
73
|
-
const entryPoint = path.join(tempDir, 'src', 'Root.jsx');
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
// 0. 确保浏览器存在(首次运行时自动复制到全局目录)
|
|
77
|
-
ensureBrowserExists();
|
|
78
|
-
|
|
79
|
-
// 1. 创建临时项目结构
|
|
80
|
-
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
|
|
81
|
-
|
|
82
|
-
// 2. 写入用户代码
|
|
83
|
-
const wrappedCode = wrapUserCode(sourceCode);
|
|
84
|
-
fs.writeFileSync(entryPoint, wrappedCode);
|
|
85
|
-
|
|
86
|
-
// 3. 创建 package.json
|
|
87
|
-
const packageJson = {
|
|
88
|
-
name: 'temp-remotion-project',
|
|
89
|
-
version: '1.0.0',
|
|
90
|
-
dependencies: {
|
|
91
|
-
react: '^18.2.0',
|
|
92
|
-
'react-dom': '^18.2.0',
|
|
93
|
-
remotion: '^4.0.0',
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(packageJson, null, 2));
|
|
97
|
-
|
|
98
|
-
// 4. 创建 remotion.config.js (可选但推荐)
|
|
99
|
-
const remotionConfig = `
|
|
100
|
-
module.exports = {
|
|
101
|
-
// Remotion 配置
|
|
102
|
-
};
|
|
103
|
-
`;
|
|
104
|
-
fs.writeFileSync(path.join(tempDir, 'remotion.config.js'), remotionConfig);
|
|
105
|
-
|
|
106
|
-
// 5. Bundle 代码
|
|
107
|
-
logger.info(methodName, 'Bundling code...');
|
|
108
|
-
const bundleLocation = await bundle({
|
|
109
|
-
entryPoint,
|
|
110
|
-
webpackOverride: (config) => config,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// 6. 获取 composition
|
|
114
|
-
logger.info(methodName, 'Getting composition...');
|
|
115
|
-
const composition = await selectComposition({
|
|
116
|
-
serveUrl: bundleLocation,
|
|
117
|
-
id: 'MyComposition', // 默认 composition ID
|
|
118
|
-
inputProps: {},
|
|
119
|
-
browserExecutable: fs.existsSync(BROWSER_EXECUTABLE_PATH) ? BROWSER_EXECUTABLE_PATH : undefined,
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// 7. 渲染视频
|
|
123
|
-
logger.info(methodName, 'Rendering frames...');
|
|
124
|
-
await renderMedia({
|
|
125
|
-
composition: {
|
|
126
|
-
...composition,
|
|
127
|
-
width: width,
|
|
128
|
-
height: height,
|
|
129
|
-
fps: fps,
|
|
130
|
-
durationInFrames: composition.durationInFrames || 150, // 默认5秒
|
|
131
|
-
},
|
|
132
|
-
serveUrl: bundleLocation,
|
|
133
|
-
codec: 'h264',
|
|
134
|
-
outputLocation: outputPath,
|
|
135
|
-
inputProps: {},
|
|
136
|
-
browserExecutable: fs.existsSync(BROWSER_EXECUTABLE_PATH) ? BROWSER_EXECUTABLE_PATH : undefined,
|
|
137
|
-
onProgress: ({ progress }) => {
|
|
138
|
-
if (progress % 0.1 < 0.01) {
|
|
139
|
-
// 每10%打印一次
|
|
140
|
-
logger.info(`Render progress: ${(progress * 100).toFixed(1)}%`);
|
|
141
|
-
}
|
|
142
|
-
},
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
logger.info(methodName, 'Render completed');
|
|
146
|
-
} finally {
|
|
147
|
-
// 清理临时目录
|
|
148
|
-
if (fs.existsSync(tempDir)) {
|
|
149
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* 包装用户代码为 Remotion 项目
|
|
156
|
-
*/
|
|
157
|
-
function wrapUserCode(sourceCode) {
|
|
158
|
-
return `
|
|
159
|
-
import React from 'react';
|
|
160
|
-
import { Composition, registerRoot, AbsoluteFill, useCurrentFrame, interpolate, useVideoConfig } from 'remotion';
|
|
161
|
-
|
|
162
|
-
// 用户代码
|
|
163
|
-
${sourceCode}
|
|
164
|
-
|
|
165
|
-
// Root 组件 - Remotion 入口
|
|
166
|
-
const RemotionRoot = () => {
|
|
167
|
-
return (
|
|
168
|
-
<>
|
|
169
|
-
<Composition
|
|
170
|
-
id="MyComposition"
|
|
171
|
-
component={MyComposition}
|
|
172
|
-
durationInFrames={150}
|
|
173
|
-
fps={30}
|
|
174
|
-
width={1920}
|
|
175
|
-
height={1080}
|
|
176
|
-
/>
|
|
177
|
-
</>
|
|
178
|
-
);
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
// 注册 Root 组件(Remotion 4.x 要求)
|
|
182
|
-
registerRoot(RemotionRoot);
|
|
183
|
-
`;
|
|
184
|
-
}
|
package/server/util/uploader.js
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
// fs
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
|
|
4
|
-
// s3
|
|
5
|
-
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
6
|
-
const s3Client = new S3Client({
|
|
7
|
-
region: 'auto',
|
|
8
|
-
endpoint: global.QZ_CONFIG.R2_ENDPOINT,
|
|
9
|
-
credentials: {
|
|
10
|
-
accessKeyId: global.QZ_CONFIG.R2_ACCESS_KEY_ID,
|
|
11
|
-
secretAccessKey: global.QZ_CONFIG.R2_SECRET_ACCESS_KEY,
|
|
12
|
-
},
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
// logger
|
|
16
|
-
const Logger = require('qiao-log');
|
|
17
|
-
const logOptions = require('../log-options.js')();
|
|
18
|
-
const logger = Logger(logOptions);
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* 上传文件到 Cloudflare R2
|
|
22
|
-
* @param {Object} options
|
|
23
|
-
* @param {string} options.filePath - 本地文件路径
|
|
24
|
-
* @param {string} options.workId - 作品 ID
|
|
25
|
-
* @returns {Promise<string>} 文件的��开 URL
|
|
26
|
-
*/
|
|
27
|
-
exports.uploadToR2 = async ({ filePath, workId }) => {
|
|
28
|
-
const methodName = 'uploadToR2';
|
|
29
|
-
|
|
30
|
-
// const
|
|
31
|
-
const fileName = `${workId}.mp4`;
|
|
32
|
-
const fileContent = fs.readFileSync(filePath);
|
|
33
|
-
|
|
34
|
-
// command
|
|
35
|
-
const command = new PutObjectCommand({
|
|
36
|
-
Bucket: global.QZ_CONFIG.R2_BUCKET_NAME,
|
|
37
|
-
Key: fileName,
|
|
38
|
-
Body: fileContent,
|
|
39
|
-
ContentType: 'video/mp4',
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
// go
|
|
43
|
-
try {
|
|
44
|
-
await s3Client.send(command);
|
|
45
|
-
|
|
46
|
-
// 返回公开 URL
|
|
47
|
-
return `${global.QZ_CONFIG.NEXT_PUBLIC_R2_PUBLIC_URL}/${fileName}`;
|
|
48
|
-
} catch (error) {
|
|
49
|
-
logger.error(methodName, 'Error uploading to R2:', error);
|
|
50
|
-
}
|
|
51
|
-
};
|
package/server/util/work.js
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
// fs
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
|
|
5
|
-
// renderer
|
|
6
|
-
const { renderVideo } = require('./renderer.js');
|
|
7
|
-
|
|
8
|
-
// upload
|
|
9
|
-
const { uploadToR2 } = require('./uploader.js');
|
|
10
|
-
|
|
11
|
-
// model
|
|
12
|
-
const { updateRenderStatus } = require('../model/RemotionModel.js');
|
|
13
|
-
|
|
14
|
-
// feishu
|
|
15
|
-
const { feishuMsg, errorFeishuMsg } = require('../util/feishu.js');
|
|
16
|
-
|
|
17
|
-
// logger
|
|
18
|
-
const Logger = require('qiao-log');
|
|
19
|
-
const logOptions = require('../log-options.js')();
|
|
20
|
-
const logger = Logger(logOptions);
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* processWork
|
|
24
|
-
* @param {*} work
|
|
25
|
-
*/
|
|
26
|
-
exports.processWork = async (work) => {
|
|
27
|
-
const methodName = 'processWork';
|
|
28
|
-
|
|
29
|
-
// const
|
|
30
|
-
const workId = work.id;
|
|
31
|
-
const outputPath = path.join(global.QZ_CONFIG.OUTPUT_DIR, `${workId}.mp4`);
|
|
32
|
-
feishuMsg(`${workId} start`);
|
|
33
|
-
logger.info(methodName, `\n[${new Date().toISOString()}] Processing work: ${workId}`);
|
|
34
|
-
logger.info(methodName, `Title: ${work.title}`);
|
|
35
|
-
logger.info(methodName, `Status: ${work.status}, Render Status: ${work.render_status}`);
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
// 1. 渲染视频
|
|
39
|
-
logger.info(methodName, 'Rendering video...');
|
|
40
|
-
await renderVideo({
|
|
41
|
-
sourceCode: work.source_code,
|
|
42
|
-
outputPath,
|
|
43
|
-
width: work.width || 1920,
|
|
44
|
-
height: work.height || 1080,
|
|
45
|
-
fps: work.fps || 30,
|
|
46
|
-
});
|
|
47
|
-
feishuMsg(`${workId} render ok`);
|
|
48
|
-
logger.info(methodName, 'Video rendered successfully');
|
|
49
|
-
|
|
50
|
-
// 2. 上传到 R2
|
|
51
|
-
logger.info(methodName, 'Uploading to R2...');
|
|
52
|
-
const videoUrl = await uploadToR2({
|
|
53
|
-
filePath: outputPath,
|
|
54
|
-
workId: workId,
|
|
55
|
-
});
|
|
56
|
-
feishuMsg(`${workId} upload ok, ${videoUrl}`);
|
|
57
|
-
logger.info(methodName, 'Video uploaded:', videoUrl);
|
|
58
|
-
|
|
59
|
-
// 3. 获取视频信息
|
|
60
|
-
fs.statSync(outputPath);
|
|
61
|
-
const durationInFrames = Math.round(5 * (work.fps || 30)); // 暂时硬编码为5秒
|
|
62
|
-
|
|
63
|
-
// 4. 更新数据库为渲染完成
|
|
64
|
-
await updateRenderStatus(workId, 'completed', {
|
|
65
|
-
video_url: videoUrl,
|
|
66
|
-
render_completed_at: new Date().toISOString(),
|
|
67
|
-
duration_in_frames: durationInFrames,
|
|
68
|
-
duration_seconds: durationInFrames / (work.fps || 30),
|
|
69
|
-
});
|
|
70
|
-
feishuMsg(`${workId} db ok`);
|
|
71
|
-
logger.info(methodName, `✅ Work ${workId} completed successfully`);
|
|
72
|
-
logger.info(methodName, ` Status: ${work.status}, Render Status: completed`);
|
|
73
|
-
|
|
74
|
-
// 5. 清理本地文件
|
|
75
|
-
fs.unlinkSync(outputPath);
|
|
76
|
-
feishuMsg(`${workId} clear ok`);
|
|
77
|
-
} catch (error) {
|
|
78
|
-
errorFeishuMsg(`${workId} \n ${error}`);
|
|
79
|
-
logger.error(methodName, `❌ Error processing work ${workId}:`, error);
|
|
80
|
-
|
|
81
|
-
// 更新为渲染失败状态
|
|
82
|
-
await updateRenderStatus(workId, 'failed', {
|
|
83
|
-
render_error: error.message,
|
|
84
|
-
render_completed_at: new Date().toISOString(),
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
// 清理可能存在的文件
|
|
88
|
-
if (fs.existsSync(outputPath)) {
|
|
89
|
-
fs.unlinkSync(outputPath);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
};
|