@shun-js/remotion-server 0.6.8 → 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 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 cron
20
- options.cron = require('qiao-timer');
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.6.8",
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.989.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
- "@supabase/supabase-js": "^2.95.3",
26
- "qiao-log": "^5.1.9",
27
- "qiao-timer": "^5.8.4",
28
- "qiao-z": "^5.8.9"
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": "3c64e232fe2242f4306dd9ce1102c70f00acf75d"
36
+ "gitHead": "d68cc4aa2cbfbe63c5db319956fd39e9a42694ca"
35
37
  }
@@ -0,0 +1,12 @@
1
+ // service
2
+ const service = require('../service/LLMService.js');
3
+
4
+ /**
5
+ * controller
6
+ */
7
+ module.exports = (app) => {
8
+ // remotion agent
9
+ app.post('/remotion-agent', (req, res) => {
10
+ service.remotionAgent(req, res);
11
+ });
12
+ };
@@ -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
+ };
@@ -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: `【通知】${msg}`,
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(`【通知】服务异常,${msg},请查看日志。`);
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
- };
@@ -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
- }
@@ -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
- };
@@ -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
- };