feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -3,7 +3,6 @@
|
|||
// Windows: SoX 직접 spawn (-t waveaudio). 기타: node-record-lpcm16.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import path from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { spawn, type ChildProcess } from 'child_process'
|
||||
import type { Readable } from 'stream'
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// 3초 청크 기반 스트리밍 전사. 싱글톤 + EventEmitter 패턴.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { app } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getAudioCaptureService, calculateRMS } from './AudioCaptureService'
|
||||
import { getSoundEffectService } from './SoundEffectService'
|
||||
|
|
@ -485,7 +486,7 @@ class CaptionService extends EventEmitter {
|
|||
llmModel: null,
|
||||
sttLatencyMs: null,
|
||||
llmLatencyMs: null,
|
||||
appVersion: '1.0.0',
|
||||
appVersion: app.getVersion(),
|
||||
})
|
||||
logger.info(
|
||||
`캡션 세션 저장: ${this._segments.length}개 세그먼트, ${wordCount}단어, ${Math.round(totalDurationMs / 1000)}초`,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { getLogger } from './LoggerService';
|
||||
import { getCloudSyncService } from './CloudSyncService';
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors';
|
||||
import { D3ROCloudDriver } from './stt/drivers/D3ROCloudDriver';
|
||||
|
||||
export interface TranscriptionSegment {
|
||||
readonly text: string;
|
||||
|
|
@ -28,18 +28,10 @@ const logger = getLogger('CloudSTTService');
|
|||
|
||||
class CloudSTTService extends EventEmitter {
|
||||
private _disposed = false;
|
||||
private readonly _driver = new D3ROCloudDriver();
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this._disposed) return;
|
||||
const cloud = getCloudSyncService();
|
||||
if (!cloud.isAuthenticated()) {
|
||||
// Auto-authenticate anonymously if zero-configuration is required
|
||||
try {
|
||||
await cloud.signInAnonymously();
|
||||
} catch (e) {
|
||||
logger.warn('Failed to sign in anonymously', e);
|
||||
}
|
||||
}
|
||||
logger.info('CloudSTTService initialized');
|
||||
}
|
||||
|
||||
|
|
@ -47,36 +39,7 @@ class CloudSTTService extends EventEmitter {
|
|||
if (this._disposed) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'CloudSTTService disposed');
|
||||
}
|
||||
const cloud = getCloudSyncService();
|
||||
|
||||
// Instead of using formData, we can send base64 or binary depending on edge function support.
|
||||
// Assuming 'stt-proxy' edge function accepts base64 audio in JSON for simplicity, or multipart.
|
||||
const base64Audio = audioBuffer.toString('base64');
|
||||
|
||||
const { data, error } = await cloud.invokeFunction('stt-proxy', {
|
||||
audio: base64Audio,
|
||||
language: options?.language,
|
||||
initial_prompt: options?.initialPrompt
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `STT failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const result = data as { text?: unknown; segments?: TranscriptionSegment[]; language?: string; duration?: number; processingTime?: number } | null;
|
||||
if (!result || typeof result.text !== 'string') {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'STT returned malformed payload');
|
||||
}
|
||||
if (!result.text.trim()) {
|
||||
throw new D3ROError(ErrorCode.STTNoAudioData, 'STT returned empty transcript');
|
||||
}
|
||||
return {
|
||||
text: result.text,
|
||||
segments: result.segments || [],
|
||||
language: result.language || 'ko',
|
||||
duration: result.duration || 0,
|
||||
processingTime: result.processingTime || 0,
|
||||
};
|
||||
return this._driver.transcribe(audioBuffer, options);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -380,7 +380,11 @@ class CloudSyncService extends EventEmitter {
|
|||
* Phase 3.2: Supabase Edge Function 호출 — 클라이언트가 auth 헤더를 올바르게 처리.
|
||||
* raw fetch 대신 이걸 사용해야 gateway 레벨 401 방지.
|
||||
*/
|
||||
async invokeFunction(name: string, body: Record<string, unknown>): Promise<{ data: unknown; error: { message: string } | null }> {
|
||||
async invokeFunction(
|
||||
name: string,
|
||||
body: Record<string, unknown> | FormData,
|
||||
options?: { signal?: AbortSignal; timeoutMs?: number }
|
||||
): Promise<{ data: unknown; error: { message: string } | null }> {
|
||||
if (!this._client) {
|
||||
return { data: null, error: { message: 'Supabase client not initialized' } }
|
||||
}
|
||||
|
|
@ -395,6 +399,8 @@ class CloudSyncService extends EventEmitter {
|
|||
const { data, error } = await this._client.functions.invoke(name, {
|
||||
body,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: options?.signal,
|
||||
timeout: options?.timeoutMs
|
||||
})
|
||||
|
||||
if (error) {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
|||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type {
|
||||
DictationTemplate,
|
||||
TemplateField,
|
||||
TemplateSessionState,
|
||||
TemplateSessionInfo,
|
||||
TemplateFieldCompletedEvent,
|
||||
TemplateSessionCompletedEvent,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import path from 'path'
|
|||
import fs from 'fs'
|
||||
import { app } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getLocalSTTService } from './LocalSTTService'
|
||||
import { getSTTManager } from './stt/STTManager'
|
||||
import { getHistoryService } from './HistoryService'
|
||||
import { configGet } from './ConfigService'
|
||||
|
|
|
|||
|
|
@ -23,6 +23,23 @@ import { verifySignedLicenseKey, createDefaultTrialPayload } from '@d3ro/core/ut
|
|||
|
||||
const logger = getLogger('license')
|
||||
|
||||
function resolveLicenseVerificationConfig(): {
|
||||
publicKeyPem: string | undefined
|
||||
allowDevelopmentKeys: boolean
|
||||
environment: string | undefined
|
||||
} {
|
||||
const environment = process.env.NODE_ENV
|
||||
const allowDevelopmentKeys =
|
||||
environment === 'test' ||
|
||||
(environment === 'development' && process.env.D3RO_ALLOW_LEGACY_DEV_LICENSES === 'true')
|
||||
const configuredPublicKey = process.env.D3RO_LICENSE_PUBLIC_KEY?.trim()
|
||||
return {
|
||||
publicKeyPem: configuredPublicKey?.replace(/\\n/g, '\n'),
|
||||
allowDevelopmentKeys,
|
||||
environment,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 머신 ID 생성 ─────────────────────────────────────────
|
||||
function generateMachineId(): string {
|
||||
const raw = `${os.hostname()}-${os.cpus()[0]?.model ?? 'unknown'}-${os.platform()}-${os.arch()}`
|
||||
|
|
@ -108,9 +125,6 @@ const TIER_ORDER: Record<LicenseTier, number> = {
|
|||
/** 오프라인 유예 기간: 30일 */
|
||||
const OFFLINE_GRACE_PERIOD_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
/** 온라인 재검증 주기: 30일 */
|
||||
const REVERIFY_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
function tierAtLeast(current: LicenseTier, required: LicenseTier): boolean {
|
||||
return TIER_ORDER[current] >= TIER_ORDER[required]
|
||||
}
|
||||
|
|
@ -174,8 +188,6 @@ class LicenseService extends EventEmitter {
|
|||
const storedTrialExpiresAt = this._readStoredField<number>('licenseTrialExpiresAt')
|
||||
const storedExpiresAt = this._readStoredField<number>('licenseExpiresAt')
|
||||
const storedCustomerEmail = this._readStoredField<string>('licenseCustomerEmail')
|
||||
const trialEverStarted = this._readStoredField<boolean>('licenseTrialEverStarted')
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
if (storedTier && storedTier !== 'free') {
|
||||
|
|
@ -410,9 +422,9 @@ class LicenseService extends EventEmitter {
|
|||
}
|
||||
|
||||
/**
|
||||
* 라이센스 키 활성화 (Ed25519 암호화 서명 검증 + 레거시 호환).
|
||||
* D3RO-LIC-xxx (Ed25519 서명 키) 또는 D3RO-PRO-xxx (개발자 키)
|
||||
* 프로덕션 결제는 Payple/Stripe 웹 결제 → CloudSync 티어 갱신 또는 라이센스 키 입력
|
||||
* 라이센스 키 활성화 (Ed25519 암호화 서명 검증).
|
||||
* 운영은 D3RO_LICENSE_PUBLIC_KEY가 반드시 필요하고, 고정 개발 fixture는 test 또는
|
||||
* 명시적으로 opt-in한 development 환경에서만 허용한다.
|
||||
*/
|
||||
async activate(key: string): Promise<ActivateLicenseResult> {
|
||||
const trimmedKey = key.trim()
|
||||
|
|
@ -420,7 +432,13 @@ class LicenseService extends EventEmitter {
|
|||
return { success: false, tier: 'free', message: 'License key is empty' }
|
||||
}
|
||||
|
||||
const verification = verifySignedLicenseKey(trimmedKey, this._info.machineId)
|
||||
const verificationConfig = resolveLicenseVerificationConfig()
|
||||
const verification = verifySignedLicenseKey(
|
||||
trimmedKey,
|
||||
this._info.machineId,
|
||||
verificationConfig.publicKeyPem,
|
||||
verificationConfig,
|
||||
)
|
||||
if (!verification.valid) {
|
||||
return { success: false, tier: 'free', message: verification.message }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
|
|||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import {
|
||||
parseMinutes,
|
||||
buildExportMarkdown,
|
||||
markdownToSimpleHtml,
|
||||
formatTime,
|
||||
|
|
@ -45,15 +44,6 @@ const logger = getLogger('MeetingModeService')
|
|||
|
||||
let isShowingMeetingSaveDialog = false
|
||||
|
||||
interface MeetingModeServiceEvents {
|
||||
'state-changed': (state: MeetingModeState) => void
|
||||
'segment': (segment: CaptionSegment) => void
|
||||
'memo-added': (memo: MeetingMemo) => void
|
||||
'processing-progress': (progress: MeetingProcessingProgress) => void
|
||||
'session-completed': (detail: MeetingSessionDetail) => void
|
||||
'error': (error: D3ROError) => void
|
||||
}
|
||||
|
||||
class MeetingModeService extends EventEmitter {
|
||||
private _state: MeetingModeState = 'idle'
|
||||
private _sessionId: string | null = null
|
||||
|
|
@ -1232,7 +1222,7 @@ ${speakerHint}
|
|||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Malgun Gothic', sans-serif; margin: 40px; color: #222; }
|
||||
h1 { font-size: 22px; border-bottom: 2px solid #f25b29; padding-bottom: 8px; }
|
||||
h1 { font-size: 22px; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
|
||||
h2 { font-size: 16px; color: #444; margin-top: 24px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ class MemoService {
|
|||
const db = getDatabase()
|
||||
|
||||
// 태그별 히스토리 조회
|
||||
let tagFilter = params.tag
|
||||
const tagFilter = params.tag
|
||||
? eq(memoTags.tag, params.tag.trim().toLowerCase())
|
||||
: undefined
|
||||
|
||||
|
|
|
|||
|
|
@ -3,21 +3,11 @@
|
|||
// 온라인 모드 사용 시 필수 인증(JWT Bearer)을 통해 서버로 AI 요청 전달.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet, configSet } from './ConfigService'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { LLMAction } from '@d3ro/core/types'
|
||||
import { resolveSystemPrompt } from './llm-prompts'
|
||||
|
||||
const logger = getLogger('OnlineLLMService')
|
||||
|
||||
interface OnlineGenerateOptions {
|
||||
model?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
systemPrompt?: string
|
||||
}
|
||||
|
||||
interface OnlineGenerateResponse {
|
||||
text: string
|
||||
model: string
|
||||
|
|
|
|||
|
|
@ -23,13 +23,6 @@ const logger = getLogger('PremiumLLMService')
|
|||
// 내부 타입
|
||||
// ============================================================
|
||||
|
||||
interface PremiumGenerateOptions {
|
||||
model?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
systemPrompt?: string
|
||||
}
|
||||
|
||||
/** Ollama-style 메시지 → Claude Messages 변환용 */
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
|
|
@ -55,17 +48,6 @@ interface ClaudeMessageResponse {
|
|||
usage: { input_tokens: number; output_tokens: number }
|
||||
}
|
||||
|
||||
/** llm-proxy 에러 응답 */
|
||||
interface LlmProxyErrorResponse {
|
||||
error: string
|
||||
current?: number
|
||||
limit?: number
|
||||
tier?: 'free' | 'pro' | 'pro_plus'
|
||||
overage_credits?: number
|
||||
allowed?: string[]
|
||||
requested?: string
|
||||
}
|
||||
|
||||
export interface QuotaUsageSnapshot {
|
||||
tier: 'free' | 'pro' | 'pro_plus'
|
||||
current: number
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
import { EventEmitter } from 'events'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getPremiumLLMService } from './PremiumLLMService'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// src/main/services/UpdateService.ts
|
||||
// electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter.
|
||||
//
|
||||
// feed: D3RO Official Release Feed `https://d3ro.chanpaca.net/releases/1.0.0`
|
||||
// feed: public GitLab Generic Registry `d3ro-voice/latest`
|
||||
// 기능:
|
||||
// 1. 사용자 인가 기반 다운로드 (autoDownload=false)
|
||||
// 2. 이번 버전 건너뛰기 (Skip This Version) 지원
|
||||
|
|
@ -172,7 +172,7 @@ class UpdateService extends EventEmitter {
|
|||
/** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */
|
||||
private async _promptUserConsent(
|
||||
version: string,
|
||||
releaseNotes?: string | any[]
|
||||
releaseNotes?: string | ReadonlyArray<{ version: string; note: string | null }> | null
|
||||
): Promise<void> {
|
||||
if (this._promptShown || this._downloading) return
|
||||
this._promptShown = true
|
||||
|
|
@ -262,4 +262,3 @@ export function getUpdateService(): UpdateService {
|
|||
}
|
||||
|
||||
export { UpdateService }
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { exec } from 'child_process'
|
|||
import { shell } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getPremiumLLMService } from './PremiumLLMService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import {
|
|||
hideRecordingTip,
|
||||
updateRecordingTipState,
|
||||
sendAudioLevelToTip,
|
||||
sendPartialTranscriptToTip,
|
||||
showResultPopup,
|
||||
} from '../windows/WindowManager'
|
||||
import type { ScreenContext } from '@d3ro/core/types'
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import type {
|
|||
AdImpressionEvent,
|
||||
AdRewardResult,
|
||||
AdRevenueStats,
|
||||
AdNetworkId,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter } from './BaseAdAdapter'
|
||||
import { EthicalAdsAdapter } from './EthicalAdsAdapter'
|
||||
|
|
@ -29,10 +28,13 @@ export class AdMediationEngine {
|
|||
private adapters: Map<string, IAdNetworkAdapter> = new Map()
|
||||
private config: AdMediationConfig
|
||||
private impressionHistory: AdImpressionEvent[] = []
|
||||
private lastRewardTimestamp = 0
|
||||
private deliveredCreatives = new Map<string, AdCreativePayload>()
|
||||
private clickedCreatives = new Set<string>()
|
||||
|
||||
private constructor() {
|
||||
// Register all 10+ Production Ad Adapters
|
||||
// Provider shells stay registered for explicit configuration diagnostics,
|
||||
// but are disabled until an official SDK or authenticated decision API is
|
||||
// integrated. No adapter may fabricate a bid or creative.
|
||||
const adapterList: IAdNetworkAdapter[] = [
|
||||
new DirectHouseSponsorAdapter(),
|
||||
new PlaywireAdapter(),
|
||||
|
|
@ -54,14 +56,14 @@ export class AdMediationEngine {
|
|||
networks: adapterList.map((a, idx) => ({
|
||||
id: a.networkId,
|
||||
name: a.networkName,
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
priority: idx + 1,
|
||||
floorEcpm: a.defaultFloorEcpm,
|
||||
adapterType: 'rest_json',
|
||||
})),
|
||||
rewardTokensAmount: 50,
|
||||
rewardCooldownSeconds: 60,
|
||||
houseAdFallback: true,
|
||||
houseAdFallback: false,
|
||||
headerBiddingTimeoutMs: 800,
|
||||
defaultFloorEcpm: 2.0,
|
||||
}
|
||||
|
|
@ -75,14 +77,55 @@ export class AdMediationEngine {
|
|||
}
|
||||
|
||||
public getConfig(): AdMediationConfig {
|
||||
return { ...this.config }
|
||||
return {
|
||||
...this.config,
|
||||
networks: this.config.networks.map((network) => ({ ...network })),
|
||||
}
|
||||
}
|
||||
|
||||
public setConfig(newConfig: Partial<AdMediationConfig>): AdMediationConfig {
|
||||
this.config = { ...this.config, ...newConfig }
|
||||
if (newConfig.rewardTokensAmount !== undefined && (!Number.isFinite(newConfig.rewardTokensAmount) || newConfig.rewardTokensAmount < 0)) {
|
||||
throw new Error('rewardTokensAmount must be a non-negative number')
|
||||
}
|
||||
if (newConfig.rewardCooldownSeconds !== undefined && (!Number.isFinite(newConfig.rewardCooldownSeconds) || newConfig.rewardCooldownSeconds < 0)) {
|
||||
throw new Error('rewardCooldownSeconds must be a non-negative number')
|
||||
}
|
||||
if (newConfig.defaultFloorEcpm !== undefined && (!Number.isFinite(newConfig.defaultFloorEcpm) || newConfig.defaultFloorEcpm < 0)) {
|
||||
throw new Error('defaultFloorEcpm must be a non-negative number')
|
||||
}
|
||||
if (newConfig.headerBiddingTimeoutMs !== undefined && (!Number.isFinite(newConfig.headerBiddingTimeoutMs) || newConfig.headerBiddingTimeoutMs <= 0)) {
|
||||
throw new Error('headerBiddingTimeoutMs must be a positive number')
|
||||
}
|
||||
this.config = {
|
||||
...this.config,
|
||||
...newConfig,
|
||||
networks: newConfig.networks?.map((network) => ({ ...network }))
|
||||
?? this.config.networks.map((network) => ({ ...network })),
|
||||
}
|
||||
return this.getConfig()
|
||||
}
|
||||
|
||||
private isSafeCreative(
|
||||
adapter: IAdNetworkAdapter,
|
||||
request: AdMediationAuctionRequest,
|
||||
bidEcpm: number,
|
||||
creative: AdCreativePayload,
|
||||
): boolean {
|
||||
if (
|
||||
!Number.isFinite(bidEcpm)
|
||||
|| bidEcpm <= 0
|
||||
|| !creative.id.trim()
|
||||
|| creative.networkId !== adapter.networkId
|
||||
|| creative.format !== request.format
|
||||
) return false
|
||||
|
||||
try {
|
||||
return new URL(creative.clickUrl).protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute real-time Header Bidding Auction across all enabled ad networks
|
||||
*/
|
||||
|
|
@ -93,7 +136,7 @@ export class AdMediationEngine {
|
|||
|
||||
const enabledAdapters = Array.from(this.adapters.values()).filter((adapter) => {
|
||||
const netConfig = this.config.networks.find((n) => n.id === adapter.networkId)
|
||||
return (netConfig ? netConfig.enabled : true) && adapter.supportedFormats.includes(request.format)
|
||||
return netConfig?.enabled === true && adapter.supportedFormats.includes(request.format)
|
||||
})
|
||||
|
||||
// Query all participating demand sources in parallel with timeout
|
||||
|
|
@ -107,11 +150,36 @@ export class AdMediationEngine {
|
|||
),
|
||||
])
|
||||
|
||||
if (
|
||||
bidResult.hasBid
|
||||
&& (
|
||||
!bidResult.creative
|
||||
|| !this.isSafeCreative(adapter, request, bidResult.bidEcpm, bidResult.creative)
|
||||
)
|
||||
) {
|
||||
return {
|
||||
networkId: adapter.networkId,
|
||||
networkName: adapter.networkName,
|
||||
bidEcpm: 0,
|
||||
creative: undefined,
|
||||
latencyMs: Date.now() - adapterStart,
|
||||
status: 'error' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const creative = bidResult.hasBid && bidResult.creative
|
||||
? {
|
||||
...bidResult.creative,
|
||||
networkName: adapter.networkName,
|
||||
bidEcpm: bidResult.bidEcpm,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
networkId: adapter.networkId,
|
||||
networkName: adapter.networkName,
|
||||
bidEcpm: bidResult.hasBid ? bidResult.bidEcpm : 0,
|
||||
creative: bidResult.creative,
|
||||
creative,
|
||||
latencyMs: Date.now() - adapterStart,
|
||||
status: (bidResult.hasBid ? 'bid' : 'no_bid') as 'bid' | 'no_bid',
|
||||
}
|
||||
|
|
@ -134,22 +202,23 @@ export class AdMediationEngine {
|
|||
.filter((b) => b.status === 'bid' && b.creative && b.bidEcpm >= floorEcpm)
|
||||
.sort((a, b) => b.bidEcpm - a.bidEcpm)
|
||||
|
||||
let winningCreative: AdCreativePayload
|
||||
|
||||
if (validBids.length > 0 && validBids[0].creative) {
|
||||
winningCreative = validBids[0].creative
|
||||
} else {
|
||||
// Fallback to Direct House Sponsor
|
||||
const houseAdapter = this.adapters.get('direct_sponsor') || new DirectHouseSponsorAdapter()
|
||||
const fallbackBid = await houseAdapter.requestBid(request)
|
||||
winningCreative = fallbackBid.creative!
|
||||
const winningCreative = validBids[0]?.creative ?? null
|
||||
if (winningCreative !== null) {
|
||||
this.deliveredCreatives.set(winningCreative.id, winningCreative)
|
||||
if (this.deliveredCreatives.size > 256) {
|
||||
const oldest = this.deliveredCreatives.keys().next().value as string | undefined
|
||||
if (oldest !== undefined) {
|
||||
this.deliveredCreatives.delete(oldest)
|
||||
this.clickedCreatives.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalLatency = Date.now() - auctionStart
|
||||
|
||||
return {
|
||||
winner: winningCreative,
|
||||
winningBidEcpm: winningCreative.bidEcpm,
|
||||
winningBidEcpm: winningCreative?.bidEcpm ?? 0,
|
||||
participatingBids: bidResults.map((b) => ({
|
||||
networkId: b.networkId,
|
||||
networkName: b.networkName,
|
||||
|
|
@ -163,8 +232,18 @@ export class AdMediationEngine {
|
|||
}
|
||||
|
||||
public recordImpression(event: Omit<AdImpressionEvent, 'timestamp'>): void {
|
||||
const delivered = this.deliveredCreatives.get(event.adId)
|
||||
if (
|
||||
delivered === undefined
|
||||
|| delivered.networkId !== event.network
|
||||
|| delivered.format !== event.format
|
||||
|| this.impressionHistory.some((candidate) => candidate.adId === event.adId)
|
||||
) return
|
||||
|
||||
const fullEvent: AdImpressionEvent = {
|
||||
...event,
|
||||
networkName: delivered.networkName,
|
||||
earnedEcpm: delivered.bidEcpm,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
this.impressionHistory.push(fullEvent)
|
||||
|
|
@ -175,10 +254,19 @@ export class AdMediationEngine {
|
|||
}
|
||||
|
||||
// Register into Settlement Ledger
|
||||
getAdSettlementService().recordImpression(event.network, event.earnedEcpm || 3.5)
|
||||
getAdSettlementService().recordImpression(event.network, delivered.bidEcpm)
|
||||
}
|
||||
|
||||
public recordClick(adId: string, networkId: string): void {
|
||||
const delivered = this.deliveredCreatives.get(adId)
|
||||
const impressed = this.impressionHistory.some((candidate) => candidate.adId === adId)
|
||||
if (
|
||||
delivered === undefined
|
||||
|| delivered.networkId !== networkId
|
||||
|| !impressed
|
||||
|| this.clickedCreatives.has(adId)
|
||||
) return
|
||||
this.clickedCreatives.add(adId)
|
||||
const adapter = this.adapters.get(networkId)
|
||||
if (adapter) {
|
||||
adapter.reportClick(adId).catch(() => {})
|
||||
|
|
@ -187,39 +275,19 @@ export class AdMediationEngine {
|
|||
}
|
||||
|
||||
public async claimReward(adId: string, networkId: string): Promise<AdRewardResult> {
|
||||
const now = Date.now()
|
||||
const cooldownMs = this.config.rewardCooldownSeconds * 1000
|
||||
|
||||
if (now - this.lastRewardTimestamp < cooldownMs) {
|
||||
const waitSeconds = Math.ceil((cooldownMs - (now - this.lastRewardTimestamp)) / 1000)
|
||||
return {
|
||||
success: false,
|
||||
tokensAdded: 0,
|
||||
newTotalQuota: 0,
|
||||
nextAvailableAt: now + waitSeconds * 1000,
|
||||
}
|
||||
}
|
||||
|
||||
const adapter = this.adapters.get(networkId)
|
||||
let tokenAmount = this.config.rewardTokensAmount
|
||||
|
||||
if (adapter && adapter.reportRewardCompletion) {
|
||||
const res = await adapter.reportRewardCompletion(adId)
|
||||
if (res.success && res.tokenReward) tokenAmount = res.tokenReward
|
||||
}
|
||||
|
||||
this.lastRewardTimestamp = now
|
||||
getAdSettlementService().recordCompletion(networkId)
|
||||
|
||||
// Desktop mediation has no server-verified completion/nonce ledger. A
|
||||
// timer, renderer-supplied ID, or adapter callback is not entitlement
|
||||
// proof, so rewards remain unavailable until that boundary exists.
|
||||
void adId
|
||||
void networkId
|
||||
return {
|
||||
success: true,
|
||||
tokensAdded: tokenAmount,
|
||||
newTotalQuota: 100 + tokenAmount, // Demo / actual license service quota boost
|
||||
rewardId: `rew_${Date.now()}`,
|
||||
success: false,
|
||||
tokensAdded: 0,
|
||||
newTotalQuota: 0,
|
||||
}
|
||||
}
|
||||
|
||||
public getRevenueStats(period = '2026-08'): AdRevenueStats {
|
||||
public getRevenueStats(period = new Date().toISOString().slice(0, 7)): AdRevenueStats {
|
||||
return getAdSettlementService().getRevenueStats(period)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,23 +2,22 @@
|
|||
// Ad Revenue Settlement, Tax Withholding & Payout Ledger Service
|
||||
|
||||
import type {
|
||||
AdSettlementRecord,
|
||||
AdRevenueStats,
|
||||
AdSettlementRecord,
|
||||
PublisherAccountConfig,
|
||||
AdNetworkId,
|
||||
} from '@d3ro/core/types'
|
||||
|
||||
export class AdSettlementService {
|
||||
private static instance: AdSettlementService | null = null
|
||||
|
||||
private publisherAccount: PublisherAccountConfig = {
|
||||
accountEmail: 'yunchanpaca@gmail.com',
|
||||
beneficiaryName: 'D3RO Voice AI',
|
||||
payoutBank: 'KB국민은행 (Kookmin Bank)',
|
||||
payoutAccountNumber: '928702-00-184920',
|
||||
taxRegistrationNumber: '120-88-01923',
|
||||
paypalEmail: 'yunchanpaca@gmail.com',
|
||||
networksConfigured: 10,
|
||||
accountEmail: '',
|
||||
beneficiaryName: '',
|
||||
payoutBank: '',
|
||||
payoutAccountNumber: '',
|
||||
taxRegistrationNumber: '',
|
||||
paypalEmail: '',
|
||||
networksConfigured: 0,
|
||||
}
|
||||
|
||||
// Network counters for current cycle
|
||||
|
|
@ -27,11 +26,7 @@ export class AdSettlementService {
|
|||
{ impressions: number; clicks: number; completions: number; grossUsd: number }
|
||||
> = new Map()
|
||||
|
||||
private settlements: AdSettlementRecord[] = []
|
||||
|
||||
private constructor() {
|
||||
this.seedInitialSettlementHistory()
|
||||
}
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): AdSettlementService {
|
||||
if (!AdSettlementService.instance) {
|
||||
|
|
@ -40,51 +35,8 @@ export class AdSettlementService {
|
|||
return AdSettlementService.instance
|
||||
}
|
||||
|
||||
private seedInitialSettlementHistory(): void {
|
||||
const networks: Array<{ id: AdNetworkId; name: string; imp: number; ecpm: number }> = [
|
||||
{ id: 'direct_sponsor', name: 'Direct House Sponsor (Cursor/Notion)', imp: 84000, ecpm: 15.2 },
|
||||
{ id: 'playwire', name: 'Playwire RAMP Desktop Header Bidding', imp: 62000, ecpm: 8.4 },
|
||||
{ id: 'applovin_max', name: 'AppLovin MAX In-App Bidding', imp: 48000, ecpm: 7.8 },
|
||||
{ id: 'unity_ads', name: 'Unity LevelPlay Rewarded Video', imp: 45000, ecpm: 9.1 },
|
||||
{ id: 'ethical_ads', name: 'EthicalAds Privacy-First Dev Network', imp: 38000, ecpm: 3.8 },
|
||||
{ id: 'carbon_ads', name: 'Carbon Ads (BuySellAds)', imp: 31000, ecpm: 4.2 },
|
||||
{ id: 'google_ad_manager', name: 'Google Ad Manager 360', imp: 29000, ecpm: 3.5 },
|
||||
{ id: 'mintegral', name: 'Mintegral Global Video Network', imp: 22000, ecpm: 6.2 },
|
||||
{ id: 'inmobi', name: 'InMobi Exchange', imp: 19000, ecpm: 3.4 },
|
||||
{ id: 'pubmatic', name: 'PubMatic OpenWrap SSP', imp: 15000, ecpm: 3.6 },
|
||||
]
|
||||
|
||||
for (const net of networks) {
|
||||
const grossUsd = (net.imp / 1000) * net.ecpm
|
||||
const withholdingRate = 0.033 // 3.3% Korean Business Tax Withholding
|
||||
const netUsd = parseFloat((grossUsd * (1 - withholdingRate)).toFixed(2))
|
||||
const exchangeRate = 1350
|
||||
const netKrw = Math.round(netUsd * exchangeRate)
|
||||
|
||||
this.settlements.push({
|
||||
id: `stl_202607_${net.id}`,
|
||||
cycleMonth: '2026-07',
|
||||
networkId: net.id,
|
||||
networkName: net.name,
|
||||
impressions: net.imp,
|
||||
clicks: Math.round(net.imp * 0.032),
|
||||
completions: Math.round(net.imp * 0.15),
|
||||
avgEcpm: net.ecpm,
|
||||
grossRevenueUsd: parseFloat(grossUsd.toFixed(2)),
|
||||
withholdingTaxRate: withholdingRate,
|
||||
netRevenueUsd: netUsd,
|
||||
exchangeRateKrw: exchangeRate,
|
||||
netPayoutKrw: netKrw,
|
||||
payoutStatus: 'settled',
|
||||
paymentMethod: 'bank_wire_krw',
|
||||
beneficiaryAccount: this.publisherAccount.payoutAccountNumber,
|
||||
settledAt: Date.now() - 1000 * 60 * 60 * 24 * 10,
|
||||
invoiceNumber: `INV-202607-${net.id.toUpperCase().slice(0, 4)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public recordImpression(networkId: string, earnedEcpm: number): void {
|
||||
if (!networkId || !Number.isFinite(earnedEcpm) || earnedEcpm <= 0) return
|
||||
const cur = this.networkCounters.get(networkId) || { impressions: 0, clicks: 0, completions: 0, grossUsd: 0 }
|
||||
cur.impressions += 1
|
||||
cur.grossUsd += earnedEcpm / 1000
|
||||
|
|
@ -92,13 +44,15 @@ export class AdSettlementService {
|
|||
}
|
||||
|
||||
public recordClick(networkId: string): void {
|
||||
const cur = this.networkCounters.get(networkId) || { impressions: 0, clicks: 0, completions: 0, grossUsd: 0 }
|
||||
const cur = this.networkCounters.get(networkId)
|
||||
if (!cur || cur.clicks >= cur.impressions) return
|
||||
cur.clicks += 1
|
||||
this.networkCounters.set(networkId, cur)
|
||||
}
|
||||
|
||||
public recordCompletion(networkId: string): void {
|
||||
const cur = this.networkCounters.get(networkId) || { impressions: 0, clicks: 0, completions: 0, grossUsd: 0 }
|
||||
const cur = this.networkCounters.get(networkId)
|
||||
if (!cur || cur.completions >= cur.impressions) return
|
||||
cur.completions += 1
|
||||
this.networkCounters.set(networkId, cur)
|
||||
}
|
||||
|
|
@ -112,28 +66,32 @@ export class AdSettlementService {
|
|||
return this.getPublisherAccount()
|
||||
}
|
||||
|
||||
public getRevenueStats(period = '2026-08'): AdRevenueStats {
|
||||
public getRevenueStats(period = new Date().toISOString().slice(0, 7)): AdRevenueStats {
|
||||
let totalImp = 0
|
||||
let totalClicks = 0
|
||||
let totalCompletions = 0
|
||||
let totalGrossUsd = 0
|
||||
|
||||
for (const record of this.settlements) {
|
||||
totalImp += record.impressions
|
||||
totalClicks += record.clicks
|
||||
totalCompletions += record.completions
|
||||
totalGrossUsd += record.grossRevenueUsd
|
||||
for (const counters of this.networkCounters.values()) {
|
||||
totalImp += counters.impressions
|
||||
totalClicks += counters.clicks
|
||||
totalCompletions += counters.completions
|
||||
totalGrossUsd += counters.grossUsd
|
||||
}
|
||||
|
||||
const avgEcpm = totalImp > 0 ? (totalGrossUsd / totalImp) * 1000 : 5.84
|
||||
|
||||
const networkBreakdown = this.settlements.map((s) => ({
|
||||
network: s.networkName,
|
||||
impressions: s.impressions,
|
||||
revenueUsd: s.grossRevenueUsd,
|
||||
ecpm: s.avgEcpm,
|
||||
fillRate: 98.4,
|
||||
}))
|
||||
const avgEcpm = totalImp > 0 ? (totalGrossUsd / totalImp) * 1000 : 0
|
||||
const networkBreakdown = Array.from(this.networkCounters.entries()).map(
|
||||
([network, counters]) => ({
|
||||
network,
|
||||
impressions: counters.impressions,
|
||||
revenueUsd: parseFloat(counters.grossUsd.toFixed(6)),
|
||||
ecpm: counters.impressions > 0
|
||||
? parseFloat(((counters.grossUsd / counters.impressions) * 1000).toFixed(2))
|
||||
: 0,
|
||||
// The desktop shell has no authoritative request/no-fill ledger yet.
|
||||
fillRate: 0,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
period,
|
||||
|
|
@ -142,24 +100,15 @@ export class AdSettlementService {
|
|||
totalCompletions: totalCompletions,
|
||||
totalRevenueUsd: parseFloat(totalGrossUsd.toFixed(2)),
|
||||
avgEcpm: parseFloat(avgEcpm.toFixed(2)),
|
||||
fillRatePercent: 98.6,
|
||||
fillRatePercent: 0,
|
||||
networkBreakdown,
|
||||
settlements: [...this.settlements],
|
||||
settlements: [],
|
||||
}
|
||||
}
|
||||
|
||||
public requestPayout(settlementId: string): { success: boolean; message: string; settlement?: AdSettlementRecord } {
|
||||
const found = this.settlements.find((s) => s.id === settlementId)
|
||||
if (!found) {
|
||||
return { success: false, message: 'Settlement record not found.' }
|
||||
}
|
||||
found.payoutStatus = 'paid'
|
||||
found.settledAt = Date.now()
|
||||
return {
|
||||
success: true,
|
||||
message: `정산금 ₩${found.netPayoutKrw.toLocaleString()}이 ${this.publisherAccount.payoutBank} (${this.publisherAccount.payoutAccountNumber})으로 성공적으로 입금 신청되었습니다. (원천징수 영수증 발급 완료)`,
|
||||
settlement: found,
|
||||
}
|
||||
void settlementId
|
||||
return { success: false, message: 'external_settlement_not_configured' }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,58 +1,12 @@
|
|||
// apps/desktop/src/main/services/ads/AppLovinAdapter.ts
|
||||
// AppLovin MAX Programmatic Bidding Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class AppLovinAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'applovin_max'
|
||||
readonly networkName = 'AppLovin MAX (Real-Time In-App Bidding)'
|
||||
readonly supportedFormats: AdFormat[] = ['rewarded_video', 'banner_dock', 'export_sponsor']
|
||||
readonly defaultFloorEcpm = 5.5
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
const baseEcpm = request.format === 'rewarded_video' ? 11.5 : 4.5
|
||||
const ecpm = baseEcpm + Math.random() * 5.0 // Competitive bid
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `max_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'Grammarly AI — Write with Confidence Across All Apps',
|
||||
description: 'Real-time AI suggestions, tone adjustments, and grammar correction.',
|
||||
ctaText: 'Get Grammarly Free',
|
||||
clickUrl: 'https://grammarly.com?utm_source=applovin',
|
||||
sponsorTag: 'AppLovin MAX',
|
||||
advertiserName: 'Grammarly',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
rewardTokens: request.format === 'rewarded_video' ? 50 : undefined,
|
||||
durationSeconds: 15,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 60,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class AppLovinAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super(
|
||||
'applovin_max',
|
||||
'AppLovin MAX',
|
||||
['rewarded_video', 'banner_dock', 'export_sponsor'],
|
||||
5.5,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,7 @@
|
|||
// apps/desktop/src/main/services/ads/CarbonAdsAdapter.ts
|
||||
// BuySellAds / Carbon Ads Curated Tech Single-Unit Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class CarbonAdsAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'carbon_ads'
|
||||
readonly networkName = 'Carbon Ads (BuySellAds Tech Network)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'sidebar_sponsor_card' as any]
|
||||
readonly defaultFloorEcpm = 3.5
|
||||
|
||||
private placement = 'd3rovoice'
|
||||
|
||||
async init(config?: { placement?: string }): Promise<void> {
|
||||
if (config?.placement) this.placement = config.placement
|
||||
}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
const ecpm = 3.8 + Math.random() * 2.2 // $3.80 - $6.00 eCPM
|
||||
const creative: AdCreativePayload = {
|
||||
id: `carbon_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'Linear — The issue tracking tool you will actually love',
|
||||
description: 'Streamline software projects, sprints, tasks, and bug tracking at high speed.',
|
||||
ctaText: 'Try Linear',
|
||||
iconUrl: 'https://cdn.carbonads.com/carbon_linear_logo.png',
|
||||
clickUrl: 'https://linear.app?ref=carbon',
|
||||
sponsorTag: 'Carbon Ads',
|
||||
advertiserName: 'Linear',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 52,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {
|
||||
// Carbon impression beacon
|
||||
}
|
||||
|
||||
async reportClick(adId: string): Promise<void> {
|
||||
// Carbon click beacon
|
||||
export class CarbonAdsAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super('carbon_ads', 'Carbon Ads', ['banner_dock'], 3.5)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,113 +1,12 @@
|
|||
// apps/desktop/src/main/services/ads/DirectHouseSponsorAdapter.ts
|
||||
// Direct House Sponsor Engine (Highest margin, premium AI/developer partnerships)
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
interface HouseSponsorCreative {
|
||||
title: string
|
||||
description: string
|
||||
ctaText: string
|
||||
clickUrl: string
|
||||
sponsorTag: string
|
||||
advertiserName: string
|
||||
bidEcpm: number
|
||||
format: AdFormat
|
||||
iconUrl?: string
|
||||
}
|
||||
|
||||
export class DirectHouseSponsorAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'direct_sponsor'
|
||||
readonly networkName = 'Direct House Sponsor Engine (100% Margin)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video', 'export_sponsor']
|
||||
readonly defaultFloorEcpm = 12.0
|
||||
|
||||
private sponsors: HouseSponsorCreative[] = [
|
||||
{
|
||||
title: 'Cursor AI — Next-Gen AI Code Editor',
|
||||
description: 'Build software with intelligent voice agents & lightning-speed code search.',
|
||||
ctaText: 'Learn More',
|
||||
clickUrl: 'https://cursor.com',
|
||||
sponsorTag: 'Direct Partner',
|
||||
advertiserName: 'Cursor AI',
|
||||
bidEcpm: 15.5,
|
||||
format: 'banner_dock',
|
||||
},
|
||||
{
|
||||
title: 'ElevenLabs — Human-like Voice AI & Speech Synthesis',
|
||||
description: 'Industry-leading emotional AI voices for creators, developers, and games.',
|
||||
ctaText: 'Try Voice AI',
|
||||
clickUrl: 'https://elevenlabs.io',
|
||||
sponsorTag: 'Direct Partner',
|
||||
advertiserName: 'ElevenLabs',
|
||||
bidEcpm: 18.0,
|
||||
format: 'rewarded_video',
|
||||
},
|
||||
{
|
||||
title: 'Perplexity Pro — Where Knowledge Begins',
|
||||
description: 'Instant answers with citations, source tracking, and multi-model research.',
|
||||
ctaText: 'Try Perplexity',
|
||||
clickUrl: 'https://perplexity.ai',
|
||||
sponsorTag: 'Direct Partner',
|
||||
advertiserName: 'Perplexity AI',
|
||||
bidEcpm: 14.2,
|
||||
format: 'banner_dock',
|
||||
},
|
||||
{
|
||||
title: 'Notion AI — Connected Workspace for Documents & Notes',
|
||||
description: 'Summarize meeting audio, manage tasks, and organize thoughts in one canvas.',
|
||||
ctaText: 'Get Notion Free',
|
||||
clickUrl: 'https://notion.so',
|
||||
sponsorTag: 'Direct Partner',
|
||||
advertiserName: 'Notion Labs',
|
||||
bidEcpm: 13.5,
|
||||
format: 'export_sponsor',
|
||||
},
|
||||
]
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
const matching = this.sponsors.filter((s) => s.format === request.format)
|
||||
if (matching.length === 0) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 2 }
|
||||
}
|
||||
|
||||
// Pick rotating sponsor
|
||||
const picked = matching[Math.floor(Math.random() * matching.length)]
|
||||
const creative: AdCreativePayload = {
|
||||
id: `house_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: picked.title,
|
||||
description: picked.description,
|
||||
ctaText: picked.ctaText,
|
||||
clickUrl: picked.clickUrl,
|
||||
sponsorTag: picked.sponsorTag,
|
||||
advertiserName: picked.advertiserName,
|
||||
bidEcpm: picked.bidEcpm,
|
||||
format: picked.format,
|
||||
rewardTokens: picked.format === 'rewarded_video' ? 50 : undefined,
|
||||
durationSeconds: picked.format === 'rewarded_video' ? 15 : undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 8, // Near zero latency
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class DirectHouseSponsorAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super(
|
||||
'direct_sponsor',
|
||||
'Direct House Sponsor',
|
||||
['banner_dock', 'rewarded_video', 'export_sponsor'],
|
||||
12,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,7 @@
|
|||
// apps/desktop/src/main/services/ads/EthicalAdsAdapter.ts
|
||||
// Privacy-First Developer Native Ad Network Adapter (REST Decision API /api/v1/decision/)
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class EthicalAdsAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'ethical_ads'
|
||||
readonly networkName = 'EthicalAds (Privacy-First Dev Network)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'export_sponsor']
|
||||
readonly defaultFloorEcpm = 3.2
|
||||
|
||||
private publisherId = 'd3ro-voice'
|
||||
|
||||
async init(config?: { publisherId?: string }): Promise<void> {
|
||||
if (config?.publisherId) this.publisherId = config.publisherId
|
||||
}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
try {
|
||||
// EthicalAds developer ads simulation & real JSON endpoint fallback
|
||||
const ecpm = 3.2 + Math.random() * 1.5 // $3.20 - $4.70 eCPM
|
||||
const creative: AdCreativePayload = {
|
||||
id: `ea_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'MongoDB Atlas — The Multi-Cloud Developer Data Platform',
|
||||
description: 'Build fast with automated scaling, vector search, and global clusters.',
|
||||
ctaText: 'Deploy Free',
|
||||
iconUrl: 'https://media.ethicalads.io/media/images/2024/02/mongodb_icon.png',
|
||||
clickUrl: 'https://www.mongodb.com/cloud/atlas/register?utm_source=ethicalads',
|
||||
sponsorTag: 'EthicalAd • Privacy Verified',
|
||||
advertiserName: 'MongoDB',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 45,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
hasBid: false,
|
||||
bidEcpm: 0,
|
||||
latencyMs: Date.now() - startTime,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {
|
||||
// console.log(`[EthicalAds] Impression recorded for ${adId}`)
|
||||
}
|
||||
|
||||
async reportClick(adId: string): Promise<void> {
|
||||
// console.log(`[EthicalAds] Click recorded for ${adId}`)
|
||||
export class EthicalAdsAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super('ethical_ads', 'EthicalAds', ['banner_dock', 'export_sponsor'], 3.2)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,12 @@
|
|||
// apps/desktop/src/main/services/ads/GoogleAdManagerAdapter.ts
|
||||
// Google Ad Manager 360 / AdMob Universal Global Demand Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class GoogleAdManagerAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'google_ad_manager'
|
||||
readonly networkName = 'Google Ad Manager 360 (Global Demand)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video', 'export_sponsor']
|
||||
readonly defaultFloorEcpm = 2.0
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
// High 99%+ fill rate, stable eCPM
|
||||
const baseEcpm = request.format === 'rewarded_video' ? 7.2 : 3.0
|
||||
const ecpm = baseEcpm + Math.random() * 2.0
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `gam_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'Google Cloud Vertex AI — Build & Scale Generative AI Apps',
|
||||
description: 'Access Gemini 1.5 Pro, customized embeddings, and enterprise search.',
|
||||
ctaText: 'Explore Cloud',
|
||||
clickUrl: 'https://cloud.google.com/vertex-ai',
|
||||
sponsorTag: 'Google Ad Manager',
|
||||
advertiserName: 'Google Cloud',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
rewardTokens: request.format === 'rewarded_video' ? 50 : undefined,
|
||||
durationSeconds: 15,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 40,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class GoogleAdManagerAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super(
|
||||
'google_ad_manager',
|
||||
'Google Ad Manager',
|
||||
['banner_dock', 'rewarded_video', 'export_sponsor'],
|
||||
2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,58 +1,7 @@
|
|||
// apps/desktop/src/main/services/ads/InMobiAdapter.ts
|
||||
// InMobi Programmatic Demand & Mobile/Hybrid Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class InMobiAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'inmobi'
|
||||
readonly networkName = 'InMobi (Programmatic Exchange)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video']
|
||||
readonly defaultFloorEcpm = 2.8
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
const baseEcpm = request.format === 'rewarded_video' ? 6.8 : 3.4
|
||||
const ecpm = baseEcpm + Math.random() * 2.2
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `inmobi_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'NordVPN — Secure Your Data with Next-Gen Encryption',
|
||||
description: 'Ultra-fast VPN protection across all your desktop and mobile devices.',
|
||||
ctaText: 'Get 70% Off',
|
||||
clickUrl: 'https://nordvpn.com?utm_source=inmobi',
|
||||
sponsorTag: 'InMobi Exchange',
|
||||
advertiserName: 'Nord Security',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
rewardTokens: 50,
|
||||
durationSeconds: 15,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 50,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class InMobiAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super('inmobi', 'InMobi', ['banner_dock', 'rewarded_video'], 2.8)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,58 +1,7 @@
|
|||
// apps/desktop/src/main/services/ads/MintegralAdapter.ts
|
||||
// Mintegral Global / APAC Rewarded Video & Interstitial Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class MintegralAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'mintegral'
|
||||
readonly networkName = 'Mintegral (APAC & Global Video Network)'
|
||||
readonly supportedFormats: AdFormat[] = ['rewarded_video', 'banner_dock']
|
||||
readonly defaultFloorEcpm = 4.0
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
const baseEcpm = request.format === 'rewarded_video' ? 8.8 : 3.8
|
||||
const ecpm = baseEcpm + Math.random() * 3.2
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `mintegral_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'Canva Pro — Design Anything with Team Collaboration',
|
||||
description: 'Create presentations, graphics, and video with easy AI magic tools.',
|
||||
ctaText: 'Try Canva Free',
|
||||
clickUrl: 'https://canva.com?ref=mintegral',
|
||||
sponsorTag: 'Mintegral Video',
|
||||
advertiserName: 'Canva',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
rewardTokens: 50,
|
||||
durationSeconds: 15,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 52,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class MintegralAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super('mintegral', 'Mintegral', ['rewarded_video', 'banner_dock'], 4)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,12 @@
|
|||
// apps/desktop/src/main/services/ads/PlaywireAdapter.ts
|
||||
// Playwire Desktop Application Programmatic Header Bidding Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class PlaywireAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'playwire'
|
||||
readonly networkName = 'Playwire RAMP (Desktop Header Bidding)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video', 'export_sponsor']
|
||||
readonly defaultFloorEcpm = 4.5
|
||||
|
||||
async init(): Promise<void> {
|
||||
// Initialize Playwire RAMP desktop runtime
|
||||
}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
// Playwire high-tier programmatic bidding: $4.50 - $11.00 eCPM
|
||||
const baseEcpm = request.format === 'rewarded_video' ? 8.5 : 4.8
|
||||
const ecpm = baseEcpm + Math.random() * 3.5
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `playwire_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'AWS Cloud — Scalable AI & Machine Learning Infrastructure',
|
||||
description: 'Train models and deploy high-performance applications on AWS Bedrock.',
|
||||
ctaText: 'Start Free Trial',
|
||||
clickUrl: 'https://aws.amazon.com/free/?utm_source=playwire',
|
||||
sponsorTag: 'Playwire Programmatic',
|
||||
advertiserName: 'Amazon Web Services',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
rewardTokens: request.format === 'rewarded_video' ? 50 : undefined,
|
||||
durationSeconds: request.format === 'rewarded_video' ? 15 : undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 68,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class PlaywireAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super(
|
||||
'playwire',
|
||||
'Playwire RAMP',
|
||||
['banner_dock', 'rewarded_video', 'export_sponsor'],
|
||||
4.5,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,7 @@
|
|||
// apps/desktop/src/main/services/ads/PubMaticAdapter.ts
|
||||
// PubMatic OpenWrap Header Bidding SSP Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class PubMaticAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'pubmatic'
|
||||
readonly networkName = 'PubMatic OpenWrap (Enterprise SSP)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'export_sponsor']
|
||||
readonly defaultFloorEcpm = 3.0
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
const ecpm = 3.6 + Math.random() * 2.5
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `pubmatic_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'Datadog — Cloud Monitoring, APM & Security in One Platform',
|
||||
description: 'See metrics, traces, and logs from your entire technology stack.',
|
||||
ctaText: 'Start Monitoring',
|
||||
clickUrl: 'https://datadoghq.com?utm_source=pubmatic',
|
||||
sponsorTag: 'PubMatic OpenWrap',
|
||||
advertiserName: 'Datadog',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 58,
|
||||
}
|
||||
export class PubMaticAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super('pubmatic', 'PubMatic OpenWrap', ['banner_dock', 'export_sponsor'], 3)
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
}
|
||||
|
|
|
|||
37
apps/desktop/src/main/services/ads/UnavailableAdAdapter.ts
Normal file
37
apps/desktop/src/main/services/ads/UnavailableAdAdapter.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { AdFormat, AdMediationAuctionRequest, AdNetworkId } from '@d3ro/core/types'
|
||||
import type { AdBidResponse, IAdNetworkAdapter } from './BaseAdAdapter'
|
||||
|
||||
/**
|
||||
* Fail-closed boundary for providers whose official desktop SDK or
|
||||
* authenticated decision endpoint is not integrated. Demo creatives are not
|
||||
* ads, so this adapter deliberately returns no bid and never grants rewards.
|
||||
*/
|
||||
export class UnavailableAdAdapter implements IAdNetworkAdapter {
|
||||
constructor(
|
||||
readonly networkId: AdNetworkId,
|
||||
readonly networkName: string,
|
||||
readonly supportedFormats: AdFormat[],
|
||||
readonly defaultFloorEcpm: number,
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(_request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
return {
|
||||
hasBid: false,
|
||||
bidEcpm: 0,
|
||||
latencyMs: 0,
|
||||
error: 'provider_not_integrated',
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(_adId: string): Promise<void> {}
|
||||
|
||||
async reportClick(_adId: string): Promise<void> {}
|
||||
|
||||
async reportRewardCompletion(
|
||||
_adId: string,
|
||||
): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: false, tokenReward: 0 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +1,7 @@
|
|||
// apps/desktop/src/main/services/ads/UnityAdsAdapter.ts
|
||||
// Unity Ads / Unity LevelPlay Rewarded Video Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class UnityAdsAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'unity_ads'
|
||||
readonly networkName = 'Unity LevelPlay (Rewarded Video & Bidding)'
|
||||
readonly supportedFormats: AdFormat[] = ['rewarded_video', 'banner_dock']
|
||||
readonly defaultFloorEcpm = 6.0
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
const baseEcpm = request.format === 'rewarded_video' ? 10.2 : 4.0
|
||||
const ecpm = baseEcpm + Math.random() * 4.0 // High yield rewarded video
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `unity_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'Unity Engine — Create & Grow Real-Time 3D Experiences',
|
||||
description: 'The industry-standard game engine for multi-platform interactive applications.',
|
||||
ctaText: 'Download Unity',
|
||||
clickUrl: 'https://unity.com/download',
|
||||
sponsorTag: 'Unity Ads',
|
||||
advertiserName: 'Unity Technologies',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
rewardTokens: 50,
|
||||
durationSeconds: 15,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 55,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class UnityAdsAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super('unity_ads', 'Unity LevelPlay', ['rewarded_video', 'banner_dock'], 6)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import type {
|
|||
TestSTTConnectionResult,
|
||||
STTStatus,
|
||||
} from '@d3ro/core/types'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { ISTTDriver } from './types'
|
||||
import { OpenAIDriver } from './drivers/OpenAIDriver'
|
||||
import { GroqDriver } from './drivers/GroqDriver'
|
||||
|
|
|
|||
|
|
@ -1,147 +1,173 @@
|
|||
// apps/desktop/src/main/services/stt/drivers/D3ROCloudDriver.ts
|
||||
// D3RO Voice Cloud STT Gateway 드라이버
|
||||
// 사용자는 별도 API 키 설정 없이 D3RO 클라우드 서비스를 통해 관리자 설정 프로바이더로 전사 처리
|
||||
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { ISTTDriver } from '../types'
|
||||
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
|
||||
import type { STTProviderConfig } from '@d3ro/core/types'
|
||||
import { pcmToWav, createProbeWav } from '../audio-utils'
|
||||
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
|
||||
import { getCloudSyncService } from '../../CloudSyncService'
|
||||
import { getLogger } from '../../LoggerService'
|
||||
import { configGet } from '../../ConfigService'
|
||||
import { pcmToWav } from '../audio-utils'
|
||||
import type { ISTTDriver } from '../types'
|
||||
|
||||
const logger = getLogger('D3ROCloudDriver')
|
||||
|
||||
export interface CloudSttGateway {
|
||||
getAccessToken(): Promise<string | null>
|
||||
getSupabaseUrl(): string | null
|
||||
getAnonKey(): string | null
|
||||
}
|
||||
|
||||
interface CloudSttPayload {
|
||||
transcript?: unknown
|
||||
confidence?: unknown
|
||||
language_code?: unknown
|
||||
duration_seconds?: unknown
|
||||
provider?: unknown
|
||||
}
|
||||
|
||||
function parseCloudSttPayload(value: unknown): {
|
||||
transcript: string
|
||||
confidence: number
|
||||
language: string
|
||||
duration: number
|
||||
} {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 응답이 올바르지 않습니다.')
|
||||
}
|
||||
const payload = value as CloudSttPayload
|
||||
if (typeof payload.transcript === 'string' && !payload.transcript.trim()) {
|
||||
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
|
||||
}
|
||||
if (
|
||||
typeof payload.transcript !== 'string'
|
||||
|| !payload.transcript.trim()
|
||||
|| payload.transcript.length > 1_000_000
|
||||
|| typeof payload.confidence !== 'number'
|
||||
|| !Number.isFinite(payload.confidence)
|
||||
|| payload.confidence < 0
|
||||
|| payload.confidence > 1
|
||||
|| typeof payload.language_code !== 'string'
|
||||
|| !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/.test(payload.language_code)
|
||||
|| typeof payload.duration_seconds !== 'number'
|
||||
|| !Number.isFinite(payload.duration_seconds)
|
||||
|| payload.duration_seconds < 0
|
||||
|| payload.duration_seconds > 24 * 60 * 60
|
||||
|| typeof payload.provider !== 'string'
|
||||
|| !/^[a-z0-9._-]{1,64}$/.test(payload.provider)
|
||||
) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 응답이 올바르지 않습니다.')
|
||||
}
|
||||
return {
|
||||
transcript: payload.transcript.trim(),
|
||||
confidence: payload.confidence,
|
||||
language: payload.language_code,
|
||||
duration: payload.duration_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
function edgeEndpoint(supabaseUrl: string): string {
|
||||
try {
|
||||
const endpoint = new URL('/functions/v1/stt-proxy', supabaseUrl)
|
||||
const localHttp = endpoint.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(endpoint.hostname)
|
||||
if ((endpoint.protocol !== 'https:' && !localHttp) || endpoint.username || endpoint.password) {
|
||||
throw new Error('invalid endpoint')
|
||||
}
|
||||
return endpoint.toString()
|
||||
} catch {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 서버 설정이 올바르지 않습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
export class D3ROCloudDriver implements ISTTDriver {
|
||||
readonly id = 'd3ro-cloud' as const
|
||||
readonly name = 'D3RO Cloud STT (Managed)'
|
||||
|
||||
constructor(private readonly cloud: CloudSttGateway = getCloudSyncService()) {}
|
||||
|
||||
async transcribe(
|
||||
audioBuffer: Buffer,
|
||||
options?: TranscribeOptions,
|
||||
config?: STTProviderConfig
|
||||
config?: STTProviderConfig,
|
||||
): Promise<TranscriptionResult> {
|
||||
const apiBase = (config?.baseUrl || configGet('cloudApiUrl') || process.env.D3RO_API_URL || 'http://localhost:5000').replace(/\/+$/, '')
|
||||
const endpoint = `${apiBase}/api/stt/transcribe`
|
||||
const startTime = Date.now()
|
||||
void config
|
||||
if (!Buffer.isBuffer(audioBuffer) || audioBuffer.length < 1) {
|
||||
throw new D3ROError(ErrorCode.STTNoAudioData, '전사할 오디오가 없습니다.')
|
||||
}
|
||||
const token = await this.cloud.getAccessToken()
|
||||
const supabaseUrl = this.cloud.getSupabaseUrl()
|
||||
const anonKey = this.cloud.getAnonKey()
|
||||
if (!token || !supabaseUrl || !anonKey) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT는 로그인과 Supabase 설정이 필요합니다.')
|
||||
}
|
||||
|
||||
const wavBuffer = pcmToWav(audioBuffer, 16000, 1, 16)
|
||||
const arrayBuf = wavBuffer.buffer.slice(
|
||||
const arrayBuffer = wavBuffer.buffer.slice(
|
||||
wavBuffer.byteOffset,
|
||||
wavBuffer.byteOffset + wavBuffer.byteLength
|
||||
wavBuffer.byteOffset + wavBuffer.byteLength,
|
||||
) as ArrayBuffer
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', new Blob([arrayBuf], { type: 'audio/wav' }), 'recording.wav')
|
||||
|
||||
formData.append('audio', new Blob([arrayBuffer], { type: 'audio/wav' }), 'recording.wav')
|
||||
if (options?.language && options.language !== 'auto') {
|
||||
formData.append('language', options.language)
|
||||
}
|
||||
if (options?.initialPrompt) {
|
||||
formData.append('prompt', options.initialPrompt)
|
||||
}
|
||||
if (config?.modelId && config.modelId !== 'default') {
|
||||
formData.append('model', config.modelId)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
const token = config?.apiKey || (configGet('cloudAuthToken') as string | undefined)
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
formData.append('language_code', options.language)
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
logger.info(`Sending audio to D3RO Cloud STT Gateway: ${endpoint}`)
|
||||
const response = await fetch(endpoint, {
|
||||
logger.info('Sending audio to authenticated D3RO Cloud STT Edge gateway')
|
||||
const response = await fetch(edgeEndpoint(supabaseUrl), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
apikey: anonKey,
|
||||
},
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text()
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 오류 (${response.status}): ${errorBody}`)
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 요청 실패 (${response.status})`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
text?: string
|
||||
language?: string
|
||||
durationSeconds?: number
|
||||
provider?: string
|
||||
latencyMs?: number
|
||||
}
|
||||
|
||||
const rawText = data.text?.trim() ?? ''
|
||||
if (!rawText) {
|
||||
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
|
||||
}
|
||||
|
||||
const processingTime = Date.now() - startTime
|
||||
const duration = data.durationSeconds || Math.round(audioBuffer.length / 2 / 16000)
|
||||
|
||||
const result = parseCloudSttPayload(await response.json().catch(() => null))
|
||||
return {
|
||||
text: rawText,
|
||||
language: data.language || options?.language || 'ko',
|
||||
duration,
|
||||
processingTime,
|
||||
segments: [
|
||||
{
|
||||
text: rawText,
|
||||
start: 0,
|
||||
end: duration,
|
||||
confidence: 0.98,
|
||||
},
|
||||
],
|
||||
text: result.transcript,
|
||||
language: result.language,
|
||||
duration: result.duration,
|
||||
processingTime: Date.now() - startedAt,
|
||||
segments: [{
|
||||
text: result.transcript,
|
||||
start: 0,
|
||||
end: result.duration,
|
||||
confidence: result.confidence,
|
||||
}],
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof D3ROError) throw err
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
logger.error('D3RO Cloud driver transcribe error:', msg)
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 전사 실패: ${msg}`)
|
||||
} catch (error) {
|
||||
if (error instanceof D3ROError) throw error
|
||||
logger.error('D3RO Cloud driver transcribe error')
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 전사에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
|
||||
const apiBase = (config.baseUrl || configGet('cloudApiUrl') || 'http://localhost:5000').replace(/\/+$/, '')
|
||||
const endpoint = `${apiBase}/api/stt/test`
|
||||
|
||||
const startTime = Date.now()
|
||||
async testConnection(_config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
if (config.apiKey) {
|
||||
headers.Authorization = `Bearer ${config.apiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
const token = await this.cloud.getAccessToken()
|
||||
const supabaseUrl = this.cloud.getSupabaseUrl()
|
||||
const anonKey = this.cloud.getAnonKey()
|
||||
if (!token || !supabaseUrl || !anonKey) throw new Error('로그인 또는 Supabase 설정이 없습니다.')
|
||||
const response = await fetch(edgeEndpoint(supabaseUrl), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
headers: { Authorization: `Bearer ${token}`, apikey: anonKey },
|
||||
body: new FormData(),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
|
||||
const latencyMs = Date.now() - startTime
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
latencyMs: data.latencyMs || latencyMs,
|
||||
message: data.message || `D3RO Cloud STT 연결 성공 (${latencyMs}ms)`,
|
||||
}
|
||||
const latencyMs = Date.now() - startedAt
|
||||
// The empty authenticated probe must be rejected before provider work,
|
||||
// proving reachability without consuming quota or incurring provider cost.
|
||||
if ([400, 413, 415].includes(response.status)) {
|
||||
return { success: true, latencyMs, message: `D3RO Cloud STT 인증 경로 준비됨 (${latencyMs}ms)` }
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
return { success: false, latencyMs, message: `D3RO Cloud 연결 실패 (HTTP ${response.status})` }
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs,
|
||||
message: `D3RO Cloud 연결 실패 (HTTP ${response.status}): ${text.slice(0, 100)}`,
|
||||
}
|
||||
} catch (err) {
|
||||
const latencyMs = Date.now() - startTime
|
||||
return {
|
||||
success: false,
|
||||
latencyMs,
|
||||
message: `D3RO Cloud 연결 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
message: 'D3RO Cloud 연결 또는 인증에 실패했습니다.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue