@bernierllc/content-type-video 1.0.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.
@@ -0,0 +1,31 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ module.exports = {
10
+ preset: 'ts-jest',
11
+ testEnvironment: 'node',
12
+ roots: ['<rootDir>/__tests__'],
13
+ testMatch: ['**/__tests__/**/*.test.ts'],
14
+ collectCoverageFrom: [
15
+ 'src/**/*.ts',
16
+ '!src/**/*.d.ts',
17
+ '!src/index.ts'
18
+ ],
19
+ coverageThreshold: {
20
+ global: {
21
+ branches: 75,
22
+ functions: 90,
23
+ lines: 90,
24
+ statements: 90
25
+ }
26
+ },
27
+ moduleFileExtensions: ['ts', 'js', 'json'],
28
+ transform: {
29
+ '^.+\\.ts$': 'ts-jest'
30
+ }
31
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@bernierllc/content-type-video",
3
+ "version": "1.0.0",
4
+ "description": "Base video content type with file upload, local storage, and streaming URL generation",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "test": "jest --watch",
10
+ "test:run": "jest",
11
+ "test:coverage": "jest --coverage",
12
+ "lint": "eslint src --ext .ts",
13
+ "clean": "rm -rf dist"
14
+ },
15
+ "keywords": [
16
+ "content-type",
17
+ "video",
18
+ "media",
19
+ "upload",
20
+ "streaming",
21
+ "storage",
22
+ "metadata",
23
+ "bernierllc"
24
+ ],
25
+ "author": "Bernier LLC",
26
+ "license": "SEE LICENSE IN LICENSE",
27
+ "dependencies": {
28
+ "@bernierllc/content-type-registry": "^1.0.3",
29
+ "zod": "^3.22.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/jest": "^29.0.0",
33
+ "@types/node": "^20.0.0",
34
+ "@typescript-eslint/eslint-plugin": "^6.0.0",
35
+ "@typescript-eslint/parser": "^6.0.0",
36
+ "eslint": "^8.0.0",
37
+ "jest": "^29.0.0",
38
+ "ts-jest": "^29.0.0",
39
+ "typescript": "^5.0.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@bernierllc/neverhub-adapter": "^1.0.0"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "@bernierllc/neverhub-adapter": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "bernierllc": {
53
+ "integration": {
54
+ "neverhub": "optional",
55
+ "neveradmin": "not-applicable",
56
+ "logger": "planned"
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,142 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ import { contentTypeRegistry, BaseContentType } from '@bernierllc/content-type-registry';
10
+ import * as path from 'path';
11
+ import * as fs from 'fs';
12
+ import {
13
+ VideoContentData,
14
+ VideoContentTypeConfig,
15
+ VideoContentResult,
16
+ VideoMetadataSchema,
17
+ } from './types';
18
+ import { VideoStorage } from './storage/VideoStorage';
19
+ import { LocalVideoStorage } from './storage/LocalVideoStorage';
20
+ import { loadConfig } from './config';
21
+
22
+ /**
23
+ * Video content type with file upload, local storage, and streaming URL generation
24
+ */
25
+ export class VideoContentType {
26
+ public readonly typeName = 'video';
27
+ public readonly version = '1.0.0';
28
+ private config: VideoContentTypeConfig;
29
+ private storage: VideoStorage;
30
+
31
+ constructor(config: Partial<VideoContentTypeConfig> = {}) {
32
+ this.config = loadConfig(config);
33
+ this.storage = new LocalVideoStorage(this.config);
34
+ }
35
+
36
+ /**
37
+ * Register video content type with registry
38
+ */
39
+ async register(): Promise<void> {
40
+ contentTypeRegistry.register({
41
+ id: 'video',
42
+ name: 'Video',
43
+ description: 'Base video content type with file upload and streaming',
44
+ baseType: BaseContentType.VIDEO,
45
+ icon: 'video',
46
+ schema: VideoMetadataSchema,
47
+ editor: this.config.editor,
48
+ storage: this.config.storage,
49
+ publishing: this.config.publishing,
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Validate video file
55
+ */
56
+ async validate(file: Buffer | string, filename: string): Promise<VideoContentResult<void>> {
57
+ // Check for zero-byte files
58
+ const fileSize = Buffer.isBuffer(file) ? file.length : fs.statSync(file).size;
59
+ if (fileSize === 0) {
60
+ return {
61
+ success: false,
62
+ error: 'File is empty (0 bytes)',
63
+ };
64
+ }
65
+
66
+ // Validate file format
67
+ const extension = path.extname(filename).slice(1).toLowerCase();
68
+ const allowedFormats = this.config.uploadOptions?.allowedFormats || ['mp4', 'webm', 'mov', 'avi'];
69
+
70
+ if (!allowedFormats.includes(extension)) {
71
+ return {
72
+ success: false,
73
+ error: `File format ${extension} not allowed. Allowed: ${allowedFormats.join(', ')}`,
74
+ };
75
+ }
76
+
77
+ // Validate file size
78
+ const maxSize = this.config.uploadOptions?.maxSizeBytes || 500 * 1024 * 1024;
79
+ if (fileSize > maxSize) {
80
+ return {
81
+ success: false,
82
+ error: `File size ${fileSize} exceeds maximum ${maxSize}`,
83
+ };
84
+ }
85
+
86
+ return { success: true };
87
+ }
88
+
89
+ /**
90
+ * Upload video file and extract metadata
91
+ */
92
+ async upload(
93
+ file: Buffer | string,
94
+ filename: string,
95
+ uploadedBy?: string
96
+ ): Promise<VideoContentResult<VideoContentData>> {
97
+ try {
98
+ const validation = await this.validate(file, filename);
99
+ if (!validation.success) {
100
+ return { success: false, error: validation.error || 'Validation failed' };
101
+ }
102
+
103
+ return await this.storage.save(file, filename, uploadedBy);
104
+ } catch (error) {
105
+ return {
106
+ success: false,
107
+ error: error instanceof Error ? error.message : 'Unknown error during video upload',
108
+ };
109
+ }
110
+ }
111
+
112
+ async get(localPath: string): Promise<Buffer | null> {
113
+ return this.storage.get(localPath);
114
+ }
115
+
116
+ async delete(localPath: string): Promise<boolean> {
117
+ return this.storage.delete(localPath);
118
+ }
119
+
120
+ async exists(localPath: string): Promise<boolean> {
121
+ return this.storage.exists(localPath);
122
+ }
123
+
124
+ getStreamingUrl(localPath: string): string {
125
+ return this.storage.getStreamingUrl(localPath);
126
+ }
127
+
128
+ /**
129
+ * Validate video content data
130
+ */
131
+ validateContent(data: VideoContentData): VideoContentResult<VideoContentData> {
132
+ try {
133
+ VideoMetadataSchema.parse(data.metadata);
134
+ return { success: true, data };
135
+ } catch (error) {
136
+ return {
137
+ success: false,
138
+ error: error instanceof Error ? error.message : 'Validation failed',
139
+ };
140
+ }
141
+ }
142
+ }
package/src/config.ts ADDED
@@ -0,0 +1,63 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ import { VideoContentTypeConfig } from './types';
10
+
11
+ /**
12
+ * Load configuration from environment variables with fallback to defaults
13
+ */
14
+ export function loadConfig(config: Partial<VideoContentTypeConfig> = {}): VideoContentTypeConfig {
15
+ const maxSizeBytes = config.uploadOptions?.maxSizeBytes
16
+ || (process.env.VIDEO_MAX_SIZE_BYTES ? parseInt(process.env.VIDEO_MAX_SIZE_BYTES, 10) : undefined)
17
+ || 500 * 1024 * 1024; // 500MB default
18
+
19
+ const storageBasePath = config.uploadOptions?.storageBasePath
20
+ || process.env.VIDEO_STORAGE_BASE_PATH
21
+ || './storage/videos';
22
+
23
+ const streamingBaseUrl = config.streamingBaseUrl
24
+ || process.env.VIDEO_STREAMING_BASE_URL
25
+ || '/api/video/stream';
26
+
27
+ const allowedFormats = config.uploadOptions?.allowedFormats
28
+ || (process.env.VIDEO_ALLOWED_FORMATS ? process.env.VIDEO_ALLOWED_FORMATS.split(',').map(f => f.trim()) : undefined)
29
+ || ['mp4', 'webm', 'mov', 'avi'];
30
+
31
+ const generateThumbnail = config.uploadOptions?.generateThumbnail
32
+ !== undefined ? config.uploadOptions.generateThumbnail
33
+ : (process.env.VIDEO_GENERATE_THUMBNAIL === 'true' || true);
34
+
35
+ const extractMetadata = config.uploadOptions?.extractMetadata
36
+ !== undefined ? config.uploadOptions.extractMetadata
37
+ : (process.env.VIDEO_EXTRACT_METADATA === 'true' || true);
38
+
39
+ return {
40
+ baseType: 'video',
41
+ editor: config.editor || {
42
+ component: 'file-upload-with-player',
43
+ settings: {},
44
+ },
45
+ storage: config.storage || {
46
+ method: 'file',
47
+ settings: {},
48
+ },
49
+ publishing: config.publishing || {
50
+ methods: ['streaming-url'],
51
+ defaultMethod: 'streaming-url',
52
+ settings: {},
53
+ },
54
+ uploadOptions: {
55
+ maxSizeBytes,
56
+ allowedFormats,
57
+ generateThumbnail,
58
+ extractMetadata,
59
+ storageBasePath,
60
+ },
61
+ streamingBaseUrl,
62
+ };
63
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ export * from './types';
10
+ export * from './VideoContentType';
11
+ export * from './config';
12
+ export * from './storage/VideoStorage';
13
+ export * from './storage/LocalVideoStorage';
14
+ export { VideoContentType } from './VideoContentType';
@@ -0,0 +1,134 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ import * as fs from 'fs/promises';
10
+ import * as fsSync from 'fs';
11
+ import * as path from 'path';
12
+ import { VideoStorage } from './VideoStorage';
13
+ import { VideoContentResult, VideoContentData, VideoMetadata, VideoMetadataSchema, VideoContentTypeConfig } from '../types';
14
+
15
+ /**
16
+ * Local file system storage implementation for videos
17
+ */
18
+ export class LocalVideoStorage implements VideoStorage {
19
+ private storageBasePath: string;
20
+ private streamingBaseUrl: string;
21
+
22
+ constructor(config: VideoContentTypeConfig) {
23
+ this.storageBasePath = config.uploadOptions?.storageBasePath || './storage/videos';
24
+ this.streamingBaseUrl = config.streamingBaseUrl || '/api/video/stream';
25
+ }
26
+
27
+ async save(file: Buffer | string, filename: string, uploadedBy?: string): Promise<VideoContentResult<VideoContentData>> {
28
+ try {
29
+ // Ensure storage directory exists
30
+ await fs.mkdir(this.storageBasePath, { recursive: true });
31
+
32
+ // Generate unique filename
33
+ const timestamp = Date.now();
34
+ const sanitizedFilename = filename.replace(/[^a-zA-Z0-9.-]/g, '_');
35
+ const uniqueFilename = `${timestamp}-${sanitizedFilename}`;
36
+
37
+ // Save file
38
+ const filePath = path.join(this.storageBasePath, uniqueFilename);
39
+
40
+ if (Buffer.isBuffer(file)) {
41
+ await fs.writeFile(filePath, file);
42
+ } else {
43
+ await fs.copyFile(file, filePath);
44
+ }
45
+
46
+ // Extract metadata
47
+ const metadata = await this.extractMetadata(filePath);
48
+
49
+ // Generate streaming URL
50
+ const streamingUrl = this.getStreamingUrl(filePath);
51
+
52
+ const contentData: VideoContentData = {
53
+ filePath,
54
+ streamingUrl,
55
+ metadata,
56
+ uploadedAt: new Date(),
57
+ uploadedBy,
58
+ };
59
+
60
+ return {
61
+ success: true,
62
+ data: contentData,
63
+ };
64
+ } catch (error) {
65
+ return {
66
+ success: false,
67
+ error: error instanceof Error ? error.message : 'Unknown save error',
68
+ };
69
+ }
70
+ }
71
+
72
+ async get(localPath: string): Promise<Buffer | null> {
73
+ try {
74
+ return await fs.readFile(localPath);
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ async delete(localPath: string): Promise<boolean> {
81
+ try {
82
+ await fs.unlink(localPath);
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ async exists(localPath: string): Promise<boolean> {
90
+ try {
91
+ await fs.access(localPath);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ getStreamingUrl(localPath: string): string {
99
+ const relativePath = path.relative(this.storageBasePath, localPath);
100
+ return `${this.streamingBaseUrl}/${encodeURIComponent(relativePath)}`;
101
+ }
102
+
103
+ /**
104
+ * Extract video metadata
105
+ * Note: In production, this would use ffmpeg/ffprobe for real metadata extraction
106
+ */
107
+ private async extractMetadata(filePath: string): Promise<VideoMetadata> {
108
+ const stats = fsSync.statSync(filePath);
109
+
110
+ // Basic metadata - in production, use ffmpeg/ffprobe for real extraction
111
+ const metadata: VideoMetadata = {
112
+ filename: path.basename(filePath),
113
+ mimeType: this.getMimeTypeFromExtension(filePath),
114
+ size: stats.size,
115
+ duration: 60, // Placeholder - would extract from video
116
+ width: 1920, // Placeholder - would extract from video
117
+ height: 1080, // Placeholder - would extract from video
118
+ fps: 30, // Placeholder - would extract from video
119
+ };
120
+
121
+ return VideoMetadataSchema.parse(metadata);
122
+ }
123
+
124
+ private getMimeTypeFromExtension(filePath: string): string {
125
+ const ext = path.extname(filePath).slice(1).toLowerCase();
126
+ const mimeTypes: Record<string, string> = {
127
+ mp4: 'video/mp4',
128
+ webm: 'video/webm',
129
+ mov: 'video/quicktime',
130
+ avi: 'video/x-msvideo',
131
+ };
132
+ return mimeTypes[ext] || 'video/mp4';
133
+ }
134
+ }
@@ -0,0 +1,20 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ import { VideoContentResult, VideoContentData } from '../types';
10
+
11
+ /**
12
+ * Video storage interface
13
+ */
14
+ export interface VideoStorage {
15
+ save(file: Buffer | string, filename: string, uploadedBy?: string): Promise<VideoContentResult<VideoContentData>>;
16
+ get(localPath: string): Promise<Buffer | null>;
17
+ delete(localPath: string): Promise<boolean>;
18
+ exists(localPath: string): Promise<boolean>;
19
+ getStreamingUrl(localPath: string): string;
20
+ }
package/src/types.ts ADDED
@@ -0,0 +1,99 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ import { z } from 'zod';
10
+
11
+ /**
12
+ * Video metadata schema
13
+ */
14
+ export const VideoMetadataSchema = z.object({
15
+ filename: z.string(),
16
+ mimeType: z.string().regex(/^video\//),
17
+ size: z.number().positive(),
18
+ duration: z.number().positive(), // seconds
19
+ width: z.number().int().positive(),
20
+ height: z.number().int().positive(),
21
+ fps: z.number().positive(),
22
+ thumbnail: z.string().optional(), // base64 or URL to thumbnail
23
+ bitrate: z.number().positive().optional(),
24
+ codec: z.string().optional()
25
+ });
26
+
27
+ export type VideoMetadata = z.infer<typeof VideoMetadataSchema>;
28
+
29
+ /**
30
+ * Video content data
31
+ */
32
+ export interface VideoContentData {
33
+ filePath: string; // Local file path
34
+ streamingUrl: string; // Generated streaming URL
35
+ metadata: VideoMetadata;
36
+ uploadedAt: Date;
37
+ uploadedBy?: string | undefined;
38
+ }
39
+
40
+ /**
41
+ * Video upload options
42
+ */
43
+ export interface VideoUploadOptions {
44
+ maxSizeBytes?: number; // Default: 500MB
45
+ allowedFormats?: string[]; // Default: ['mp4', 'webm', 'mov', 'avi']
46
+ generateThumbnail?: boolean; // Default: true
47
+ extractMetadata?: boolean; // Default: true
48
+ storageBasePath?: string; // Default: './storage/videos'
49
+ }
50
+
51
+ /**
52
+ * Video content type configuration
53
+ */
54
+ export interface VideoContentTypeConfig {
55
+ baseType: 'video';
56
+ editor: {
57
+ component: string;
58
+ settings?: Record<string, unknown>;
59
+ };
60
+ storage: {
61
+ method: string;
62
+ settings?: Record<string, unknown>;
63
+ };
64
+ publishing: {
65
+ methods: string[];
66
+ defaultMethod?: string;
67
+ settings?: Record<string, unknown>;
68
+ };
69
+ uploadOptions?: VideoUploadOptions;
70
+ streamingBaseUrl?: string;
71
+ }
72
+
73
+ /**
74
+ * Result type for video operations
75
+ */
76
+ export interface VideoContentResult<T = unknown> {
77
+ success: boolean;
78
+ data?: T;
79
+ error?: string;
80
+ }
81
+
82
+ /**
83
+ * Video content error codes
84
+ */
85
+ export type VideoContentErrorCode =
86
+ | 'INVALID_FORMAT'
87
+ | 'FILE_TOO_LARGE'
88
+ | 'UPLOAD_FAILED'
89
+ | 'METADATA_EXTRACTION_FAILED'
90
+ | 'VALIDATION_FAILED';
91
+
92
+ /**
93
+ * Video content error
94
+ */
95
+ export interface VideoContentError {
96
+ code: VideoContentErrorCode;
97
+ message: string;
98
+ details?: unknown;
99
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "sourceMap": true,
9
+ "noEmit": false
10
+ },
11
+ "include": [
12
+ "src/**/*"
13
+ ],
14
+ "exclude": [
15
+ "node_modules",
16
+ "dist",
17
+ "**/*.test.ts",
18
+ "**/*.test.tsx"
19
+ ]
20
+ }