@ascegu/teamily 1.0.28 → 1.0.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ascegu/teamily",
3
- "version": "1.0.28",
3
+ "version": "1.0.30",
4
4
  "description": "OpenClaw Teamily channel plugin - Team instant messaging server integration",
5
5
  "keywords": [
6
6
  "channel",
package/src/monitor.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  TeamilyAudioContent,
8
8
  } from "./types.js";
9
9
  import { CONTENT_TYPES, SESSION_TYPES, isGroupSession } from "./types.js";
10
+ import { extractVideoSnapshot, parseVideoMeta } from "./upload.js";
10
11
 
11
12
  export type TeamilyMessageHandler = (message: TeamilyMessage) => Promise<void> | void;
12
13
  export type TeamilyConnectionState = "connecting" | "connected" | "disconnected" | "error";
@@ -300,26 +301,32 @@ export class TeamilyMonitor {
300
301
  ): Promise<string> {
301
302
  const sdk = this.requireSdk();
302
303
  const videoFile = new File([new Uint8Array(buffer)], fileName, { type: contentType });
303
- // snapshotFile is required by the SDK; an empty file causes the upload to hang.
304
- // Use a minimal valid 1x1 JPEG so the SDK upload succeeds.
305
- const snapshotFile = new File([new Uint8Array(MINIMAL_JPEG)], "snapshot.jpg", {
304
+
305
+ // Extract real duration/dimensions from the MP4 container so clients
306
+ // can play the video inline instead of showing a broken player.
307
+ const meta = parseVideoMeta(buffer);
308
+
309
+ // Try to extract a real first-frame snapshot via ffmpeg; fall back to
310
+ // a minimal 1x1 JPEG placeholder if ffmpeg is unavailable.
311
+ const snapshotBuf = (await extractVideoSnapshot(buffer)) ?? Buffer.from(MINIMAL_JPEG);
312
+ const snapshotFile = new File([new Uint8Array(snapshotBuf)], "snapshot.jpg", {
306
313
  type: "image/jpeg",
307
314
  });
308
- // SDK expects a bare extension ("mp4"), not a MIME type ("video/mp4").
315
+
309
316
  const videoExt = fileName.split(".").pop()?.toLowerCase() || "mp4";
310
317
  const created = await sdk.createVideoMessageByFile({
311
318
  videoPath: "",
312
- duration: 0,
319
+ duration: meta.duration,
313
320
  videoType: videoExt,
314
321
  snapshotPath: "",
315
322
  videoUUID: crypto.randomUUID(),
316
323
  videoUrl: "",
317
324
  videoSize: buffer.length,
318
325
  snapshotUUID: crypto.randomUUID(),
319
- snapshotSize: MINIMAL_JPEG.length,
326
+ snapshotSize: snapshotBuf.length,
320
327
  snapshotUrl: "",
321
- snapshotWidth: 1,
322
- snapshotHeight: 1,
328
+ snapshotWidth: meta.width || 1,
329
+ snapshotHeight: meta.height || 1,
323
330
  videoFile,
324
331
  snapshotFile,
325
332
  });
package/src/upload.ts CHANGED
@@ -117,3 +117,149 @@ export function guessContentType(filePath: string): string {
117
117
  };
118
118
  return map[ext] ?? "application/octet-stream";
119
119
  }
120
+
121
+ // ---- Video snapshot extraction via ffmpeg ----
122
+
123
+ /**
124
+ * Extract the first video frame as a JPEG thumbnail using ffmpeg.
125
+ * Returns null if ffmpeg is unavailable or extraction fails.
126
+ * The thumbnail is scaled down to fit within maxDim (default 320px).
127
+ */
128
+ export async function extractVideoSnapshot(
129
+ videoBuf: Buffer,
130
+ maxDim = 320,
131
+ ): Promise<Buffer | null> {
132
+ try {
133
+ const { execFile } = await import("node:child_process");
134
+ const { promisify } = await import("node:util");
135
+ const { writeFile, readFile, unlink, mkdtemp } = await import("node:fs/promises");
136
+ const { tmpdir } = await import("node:os");
137
+ const execFileAsync = promisify(execFile);
138
+
139
+ const dir = await mkdtemp(path.join(tmpdir(), "oc-snap-"));
140
+ const inPath = path.join(dir, "input.mp4");
141
+ const outPath = path.join(dir, "snapshot.jpg");
142
+
143
+ try {
144
+ await writeFile(inPath, videoBuf);
145
+ await execFileAsync("ffmpeg", [
146
+ "-i", inPath,
147
+ "-vframes", "1",
148
+ "-vf", `scale='min(${maxDim},iw)':'-1'`,
149
+ "-q:v", "6",
150
+ "-y", outPath,
151
+ ], { timeout: 10_000 });
152
+ return await readFile(outPath);
153
+ } finally {
154
+ await unlink(inPath).catch(() => {});
155
+ await unlink(outPath).catch(() => {});
156
+ const { rmdir } = await import("node:fs/promises");
157
+ await rmdir(dir).catch(() => {});
158
+ }
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ // ---- MP4 metadata extraction (no external dependencies) ----
165
+
166
+ export interface VideoMeta {
167
+ duration: number; // seconds
168
+ width: number;
169
+ height: number;
170
+ }
171
+
172
+ /**
173
+ * Parse MP4/MOV container to extract duration, width, and height.
174
+ * Returns zeroed metadata on parse failure (non-MP4, truncated, etc.).
175
+ */
176
+ export function parseVideoMeta(buf: Buffer): VideoMeta {
177
+ const fallback: VideoMeta = { duration: 0, width: 0, height: 0 };
178
+ try {
179
+ const moov = findBox(buf, 0, buf.length, "moov");
180
+ if (!moov) return fallback;
181
+
182
+ let duration = 0;
183
+ let width = 0;
184
+ let height = 0;
185
+
186
+ // mvhd → duration & timescale
187
+ const mvhd = findBox(buf, moov.dataStart, moov.end, "mvhd");
188
+ if (mvhd) {
189
+ const ver = buf[mvhd.dataStart];
190
+ if (ver === 0) {
191
+ const timescale = buf.readUInt32BE(mvhd.dataStart + 12);
192
+ const dur = buf.readUInt32BE(mvhd.dataStart + 16);
193
+ if (timescale > 0) duration = dur / timescale;
194
+ } else if (ver === 1) {
195
+ const timescale = buf.readUInt32BE(mvhd.dataStart + 20);
196
+ // 64-bit duration: read high and low 32-bit parts
197
+ const durHi = buf.readUInt32BE(mvhd.dataStart + 24);
198
+ const durLo = buf.readUInt32BE(mvhd.dataStart + 28);
199
+ const dur = durHi * 0x100000000 + durLo;
200
+ if (timescale > 0) duration = dur / timescale;
201
+ }
202
+ }
203
+
204
+ // trak → tkhd → width & height (use first video track)
205
+ let offset = moov.dataStart;
206
+ while (offset < moov.end) {
207
+ const trak = findBox(buf, offset, moov.end, "trak");
208
+ if (!trak) break;
209
+ const tkhd = findBox(buf, trak.dataStart, trak.end, "tkhd");
210
+ if (tkhd) {
211
+ const ver = buf[tkhd.dataStart];
212
+ // width/height are fixed-point 16.16 at the end of tkhd
213
+ const whOffset = ver === 0 ? tkhd.dataStart + 76 : tkhd.dataStart + 88;
214
+ if (whOffset + 8 <= tkhd.end) {
215
+ const w = buf.readUInt32BE(whOffset) >> 16;
216
+ const h = buf.readUInt32BE(whOffset + 4) >> 16;
217
+ if (w > 0 && h > 0) {
218
+ width = w;
219
+ height = h;
220
+ break; // first video track is enough
221
+ }
222
+ }
223
+ }
224
+ offset = trak.end;
225
+ }
226
+
227
+ return { duration: Math.round(duration), width, height };
228
+ } catch {
229
+ return fallback;
230
+ }
231
+ }
232
+
233
+ interface BoxInfo {
234
+ dataStart: number; // offset right after the 8-byte header
235
+ end: number; // first byte past this box
236
+ }
237
+
238
+ /** Find a box by type within [start, end) range. */
239
+ function findBox(
240
+ buf: Buffer,
241
+ start: number,
242
+ end: number,
243
+ type: string,
244
+ ): BoxInfo | null {
245
+ let offset = start;
246
+ while (offset + 8 <= end) {
247
+ let size = buf.readUInt32BE(offset);
248
+ const boxType = buf.toString("ascii", offset + 4, offset + 8);
249
+ let headerSize = 8;
250
+ if (size === 1 && offset + 16 <= end) {
251
+ // 64-bit extended size
252
+ const hi = buf.readUInt32BE(offset + 8);
253
+ const lo = buf.readUInt32BE(offset + 12);
254
+ size = hi * 0x100000000 + lo;
255
+ headerSize = 16;
256
+ }
257
+ if (size < headerSize) break; // invalid
258
+ const boxEnd = offset + size;
259
+ if (boxType === type) {
260
+ return { dataStart: offset + headerSize, end: Math.min(boxEnd, end) };
261
+ }
262
+ offset = boxEnd;
263
+ }
264
+ return null;
265
+ }