Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified .github/hooks/post-edit-invalidate.sh
100644 → 100755
Empty file.
Empty file modified .github/hooks/pre-amend-block.sh
100644 → 100755
Empty file.
Empty file modified .github/hooks/pre-commit-block.sh
100644 → 100755
Empty file.
Empty file modified .github/hooks/pre-force-push-block.sh
100644 → 100755
Empty file.
Empty file modified .github/hooks/pre-layer-import.sh
100644 → 100755
Empty file.
Empty file modified .github/hooks/pre-layer-mock.sh
100644 → 100755
Empty file.
Empty file modified .github/hooks/pre-push-block.sh
100644 → 100755
Empty file.
Empty file modified .github/hooks/pre-reexport-block.sh
100644 → 100755
Empty file.
63 changes: 51 additions & 12 deletions src/L3-services/azureStorage/azureStorageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ export interface ContentRecord {
publishedAt: string
}

export interface UploadedRawVideo {
blobPath: string
url: string
}

export interface UploadedContentAsset {
itemId: string
clipType: 'video' | 'short' | 'medium-clip'
ideaIds: string[]
blobBasePath: string
cloudUrl?: string
thumbnailUrl?: string
clipSlug?: string
}

export async function uploadVideoFile(
localPath: string,
blobPath: string,
Expand Down Expand Up @@ -78,11 +93,11 @@ export async function uploadRawVideo(
duration?: number
size: number
},
): Promise<string> {
): Promise<UploadedRawVideo> {
const blobPath = `raw/${runId}-${metadata.originalFilename}`

logger.info(`Uploading raw video to Azure: ${blobPath}`)
await blobClient.uploadFile(blobPath, localPath, 'video/mp4')
const url = await blobClient.uploadFile(blobPath, localPath, 'video/mp4')

await tableClient.upsertEntity(VIDEOS_TABLE, 'video', runId, {
originalFilename: metadata.originalFilename,
Expand All @@ -98,7 +113,7 @@ export async function uploadRawVideo(
} satisfies VideoRecord)

logger.info(`Created video record: ${runId}`)
return blobPath
return { blobPath, url }
}

export async function uploadContentItem(
Expand All @@ -107,16 +122,17 @@ export async function uploadContentItem(
videoSlug: string,
runId: string,
metadata?: Partial<ContentRecord>,
): Promise<string> {
): Promise<UploadedContentAsset> {
const blobBasePath = `content/${itemId}/`
const uploadedUrls = new Map<string, string>()

// Upload all files in the item directory
const files = await readdir(localItemDir)
for (const file of files) {
const localFilePath = join(localItemDir, file)
const blobPath = `${blobBasePath}${file}`
const contentType = getContentType(file)
await blobClient.uploadFile(blobPath, localFilePath, contentType)
uploadedUrls.set(file, await blobClient.uploadFile(blobPath, localFilePath, contentType))
}

// Read metadata.json if exists to populate table record
Expand All @@ -141,10 +157,19 @@ export async function uploadContentItem(
// Determine media file
const mediaFilename = files.find(f => f.startsWith('media.')) || ''
const thumbnailFilename = files.find(f => f.startsWith('thumbnail.')) || ''
const clipType = String(itemMetadata.clipType || metadata?.clipType || '')
const ideaIds = Array.isArray(itemMetadata.ideaIds)
? (itemMetadata.ideaIds as string[]).map((id) => String(id)).filter(Boolean)
: typeof metadata?.ideaIds === 'string'
? metadata.ideaIds.split(',').map((id) => id.trim()).filter(Boolean)
: []
const clipSlug = typeof itemMetadata.sourceClip === 'string' && itemMetadata.sourceClip.trim().length > 0
? basename(itemMetadata.sourceClip)
: undefined

const record: ContentRecord = {
platform: String(itemMetadata.platform || metadata?.platform || ''),
clipType: String(itemMetadata.clipType || metadata?.clipType || ''),
clipType,
status: metadata?.status || 'pending_review',
blobBasePath,
mediaType: String(itemMetadata.mediaType || metadata?.mediaType || 'video'),
Expand All @@ -157,7 +182,7 @@ export async function uploadContentItem(
publishedUrl: String(itemMetadata.publishedUrl || metadata?.publishedUrl || ''),
sourceVideoRunId: runId,
thumbnailFilename,
ideaIds: Array.isArray(itemMetadata.ideaIds) ? (itemMetadata.ideaIds as string[]).join(',') : (metadata?.ideaIds || ''),
ideaIds: ideaIds.join(','),
createdAt: String(itemMetadata.createdAt || new Date().toISOString()),
reviewedAt: String(itemMetadata.reviewedAt || metadata?.reviewedAt || ''),
publishedAt: String(itemMetadata.publishedAt || metadata?.publishedAt || ''),
Expand All @@ -166,23 +191,32 @@ export async function uploadContentItem(
await tableClient.upsertEntity(CONTENT_TABLE, videoSlug, itemId, record)
logger.info(`Uploaded content item: ${itemId} (${record.platform}/${record.clipType}) — blob + table record created`)

return blobBasePath
return {
itemId,
clipType: isClipType(clipType) ? clipType : 'video',
ideaIds,
blobBasePath,
cloudUrl: mediaFilename ? uploadedUrls.get(mediaFilename) : undefined,
thumbnailUrl: thumbnailFilename ? uploadedUrls.get(thumbnailFilename) : undefined,
clipSlug,
}
}

export async function uploadPublishQueue(
publishQueueDir: string,
videoSlug: string,
runId: string,
): Promise<{ uploaded: number; errors: string[] }> {
): Promise<{ uploaded: number; errors: string[]; assets: UploadedContentAsset[] }> {
const errors: string[] = []
const assets: UploadedContentAsset[] = []
let uploaded = 0

let items: string[]
try {
items = await readdir(publishQueueDir)
} catch {
logger.warn(`Publish queue directory not found: ${publishQueueDir}`)
return { uploaded: 0, errors: ['Publish queue directory not found'] }
return { uploaded: 0, errors: ['Publish queue directory not found'], assets: [] }
}

for (const itemId of items) {
Expand All @@ -204,7 +238,8 @@ export async function uploadPublishQueue(
}

try {
await uploadContentItem(itemDir, itemId, videoSlug, runId)
const uploadedAsset = await uploadContentItem(itemDir, itemId, videoSlug, runId)
assets.push(uploadedAsset)
uploaded++
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error)
Expand All @@ -219,7 +254,7 @@ export async function uploadPublishQueue(
})

logger.info(`Uploaded ${uploaded} content items to Azure (${errors.length} errors)`)
return { uploaded, errors }
return { uploaded, errors, assets }
}

export async function migrateLocalContent(
Expand Down Expand Up @@ -390,3 +425,7 @@ function extractVideoSlug(itemId: string): string {
}
return itemId
}

function isClipType(value: string): value is UploadedContentAsset['clipType'] {
return value === 'video' || value === 'short' || value === 'medium-clip'
}
121 changes: 114 additions & 7 deletions src/L3-services/ideaService/ideaService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ const ideaStatuses = new Set<IdeaStatus>(['draft', 'ready', 'recorded', 'publish

type IdeaPriority = IdeaFilters['priority']

interface CloudAssetRecord {
assetKey: string
clipType: IdeaPublishRecord['clipType']
sourceVideoSlug: string
clipSlug?: string
cloudUrl?: string
thumbnailUrl?: string
uploadedAt: string
}

type ParsedIdeaComment =
| IdeaCommentData
| { type: 'cloud-asset'; asset: CloudAssetRecord }

interface IdeaBodyData {
hook: string
audience: string
Expand Down Expand Up @@ -215,7 +229,7 @@ function formatIdeaComment(data: IdeaCommentData): string {
}

function formatPublishRecordComment(record: IdeaPublishRecord): string {
return [
const lines = [
'Published content recorded for this idea.',
'',
`- Clip type: ${record.clipType}`,
Expand All @@ -224,9 +238,14 @@ function formatPublishRecordComment(record: IdeaPublishRecord): string {
`- Published at: ${record.publishedAt}`,
`- Late post ID: ${record.latePostId}`,
`- Late URL: ${record.lateUrl}`,
'',
formatIdeaComment({ type: 'publish-record', record }),
].join('\n')
]

if (record.publishedUrl) {
lines.push(`- Published URL: ${record.publishedUrl}`)
}

lines.push('', formatIdeaComment({ type: 'publish-record', record }))
return lines.join('\n')
}

function formatVideoLinkComment(videoSlug: string, linkedAt: string): string {
Expand All @@ -240,7 +259,44 @@ function formatVideoLinkComment(videoSlug: string, linkedAt: string): string {
].join('\n')
}

function parseIdeaComment(commentBody: string): IdeaCommentData | null {
function formatCloudAssetComment(asset: CloudAssetRecord): string {
const lines = [
'Cloud media uploaded for this idea.',
'',
`- Asset key: ${asset.assetKey}`,
`- Clip type: ${asset.clipType}`,
`- Source video: ${asset.sourceVideoSlug}`,
]

if (asset.clipSlug) {
lines.push(`- Clip slug: ${asset.clipSlug}`)
}
if (asset.cloudUrl) {
lines.push(`- Cloud URL: ${asset.cloudUrl}`)
}
if (asset.thumbnailUrl) {
lines.push(`- Thumbnail URL: ${asset.thumbnailUrl}`)
}

lines.push(
`- Uploaded at: ${asset.uploadedAt}`,
'',
[
COMMENT_MARKER,
'```json',
JSON.stringify({ type: 'cloud-asset', asset }, null, 2),
'```',
].join('\n'),
)

if (asset.thumbnailUrl) {
lines.push('', `![${asset.clipSlug ?? asset.sourceVideoSlug} thumbnail](${asset.thumbnailUrl})`)
}

return lines.join('\n')
}

function parseIdeaComment(commentBody: string): ParsedIdeaComment | null {
const markerIndex = commentBody.indexOf(COMMENT_MARKER)
if (markerIndex === -1) {
return null
Expand All @@ -254,9 +310,10 @@ function parseIdeaComment(commentBody: string): IdeaCommentData | null {
}

try {
const parsed = JSON.parse(jsonText) as Partial<IdeaCommentData> & {
const parsed = JSON.parse(jsonText) as {
type?: string
record?: Partial<IdeaPublishRecord>
asset?: Partial<CloudAssetRecord>
videoSlug?: unknown
linkedAt?: unknown
}
Expand Down Expand Up @@ -289,6 +346,34 @@ function parseIdeaComment(commentBody: string): IdeaCommentData | null {
publishedAt: record.publishedAt,
latePostId: record.latePostId,
lateUrl: record.lateUrl,
publishedUrl: typeof record.publishedUrl === 'string' ? record.publishedUrl : undefined,
},
}
}
}

if (parsed.type === 'cloud-asset' && parsed.asset) {
const asset = parsed.asset
if (
typeof asset.assetKey === 'string'
&& typeof asset.clipType === 'string'
&& (asset.clipType === 'video' || asset.clipType === 'short' || asset.clipType === 'medium-clip')
&& typeof asset.sourceVideoSlug === 'string'
&& typeof asset.uploadedAt === 'string'
&& (asset.clipSlug === undefined || typeof asset.clipSlug === 'string')
&& (asset.cloudUrl === undefined || typeof asset.cloudUrl === 'string')
&& (asset.thumbnailUrl === undefined || typeof asset.thumbnailUrl === 'string')
) {
return {
type: 'cloud-asset',
asset: {
assetKey: asset.assetKey,
clipType: asset.clipType,
sourceVideoSlug: asset.sourceVideoSlug,
clipSlug: asset.clipSlug,
cloudUrl: asset.cloudUrl,
thumbnailUrl: asset.thumbnailUrl,
uploadedAt: asset.uploadedAt,
},
}
}
Expand Down Expand Up @@ -362,7 +447,9 @@ function mapIssueToIdea(issue: GitHubIssue, comments: GitHubComment[]): Idea {
continue
}

sourceVideoSlug = parsedComment.videoSlug
if (parsedComment.type === 'video-link') {
sourceVideoSlug = parsedComment.videoSlug
}
}

return {
Expand Down Expand Up @@ -611,6 +698,26 @@ export async function recordPublish(issueNumber: number, record: IdeaPublishReco
}
}

export async function recordCloudAsset(issueNumber: number, asset: CloudAssetRecord): Promise<void> {
const client = getGitHubClient()

try {
const comments = await client.listComments(issueNumber)
const hasDuplicate = comments.some((comment) => {
const parsedComment = parseIdeaComment(comment.body)
return parsedComment?.type === 'cloud-asset' && parsedComment.asset.assetKey === asset.assetKey
})

if (!hasDuplicate) {
await client.addComment(issueNumber, formatCloudAssetComment(asset))
}
} catch (error: unknown) {
const message = getErrorMessage(error)
logger.error(`[IdeaService] Failed to record cloud asset for idea #${issueNumber}: ${message}`)
throw new Error(`Failed to record cloud asset for idea #${issueNumber}: ${message}`)
}
}

export async function getPublishHistory(issueNumber: number): Promise<IdeaPublishRecord[]> {
const client = getGitHubClient()

Expand Down
1 change: 1 addition & 0 deletions src/L3-services/postStore/postStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ export async function approveItem(
publishedAt: now,
latePostId: item.metadata.latePostId ?? '',
lateUrl: item.metadata.publishedUrl || (item.metadata.latePostId ? `https://app.late.co/dashboard/post/${item.metadata.latePostId}` : ''),
publishedUrl: item.metadata.publishedUrl ?? undefined,
})
}
} catch (err: unknown) {
Expand Down
15 changes: 13 additions & 2 deletions src/L4-agents/cloudStorage/cloudStorageOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,28 @@ export async function uploadPipelineResults(
duration?: number
size: number
},
): Promise<{ runId: string; videoUploaded: boolean; contentUploaded: number; errors: string[] }> {
): Promise<{
runId: string
videoUploaded: boolean
contentUploaded: number
errors: string[]
videoUrl?: string
assets: azureStorageService.UploadedContentAsset[]
}> {
const runId = azureStorageService.getRunId()
let videoUrl: string | undefined

logger.info(`Cloud upload starting (runId: ${runId})`)

// Upload raw video
let videoUploaded = false
try {
await azureStorageService.uploadRawVideo(inputVideoPath, runId, {
const uploadedVideo = await azureStorageService.uploadRawVideo(inputVideoPath, runId, {
...metadata,
slug: videoSlug,
})
videoUploaded = true
videoUrl = uploadedVideo.url
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error)
logger.error(`Failed to upload raw video: ${msg}`)
Expand All @@ -44,6 +53,8 @@ export async function uploadPipelineResults(
videoUploaded,
contentUploaded: result.uploaded,
errors: result.errors,
videoUrl,
assets: result.assets,
}
}

Expand Down
Loading