Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b40127c2e8 | ||
|
|
f9b2a997a2 | ||
|
|
848607f9e0 | ||
|
|
03634d5100 | ||
|
|
6bc98606eb | ||
|
|
b68e9fd8e5 | ||
|
|
888f5fb66a | ||
|
|
54bbf8b574 | ||
|
|
1fcae33ca3 | ||
|
|
6476bb42a4 | ||
|
|
506d56fa92 |
@@ -1,65 +0,0 @@
|
||||
---
|
||||
name: stripe-best-practices
|
||||
description: >-
|
||||
Guides Stripe integration decisions across API selection (Checkout Sessions vs
|
||||
PaymentIntents), Connect platform setup (Accounts v2, controller properties),
|
||||
billing/subscriptions, tax and registrations (Stripe Tax, automatic_tax,
|
||||
product tax codes), Treasury financial accounts, integration options
|
||||
(Checkout, Payment Element), migrating from deprecated Stripe APIs, and
|
||||
security best practices (API key management, restricted keys, webhooks,
|
||||
OAuth). Use when building, modifying, or reviewing any Stripe integration,
|
||||
including accepting payments, building marketplaces, integrating Stripe,
|
||||
processing payments, setting up subscriptions, collecting sales tax, VAT, or
|
||||
GST, creating connected accounts, or implementing secure key handling.
|
||||
|
||||
---
|
||||
|
||||
Latest Stripe API version: **2026-06-24.dahlia**. Always use the latest API version and SDK unless the user specifies otherwise.
|
||||
|
||||
Latest SDK versions:
|
||||
|
||||
| Language | Latest version |
|
||||
| --- | --- |
|
||||
| Ruby | 19.3.0 |
|
||||
| Python | 15.3.0 |
|
||||
| Node.js | 22.3.0 |
|
||||
| PHP | 20.3.0 |
|
||||
| Java | 33.1.0 |
|
||||
| Go | 86.1.0 |
|
||||
| .NET | 52.1.0 |
|
||||
|
||||
If you don’t have a Stripe account, you can call a CLI command to generate working API keys with no registration required. You can install the Stripe CLI (`npm i -g @stripe/cli`) and run the command `stripe sandbox create` to get started.
|
||||
|
||||
API key default: Always recommend a [restricted API key (RAK)](https://docs.stripe.com/keys/restricted-api-keys.md) (`rk_` prefix) over a secret key (`sk_` prefix).
|
||||
|
||||
## Integration routing
|
||||
|
||||
| Building… | Recommended API | Details |
|
||||
| --- | --- | --- |
|
||||
| One-time payments | Checkout Sessions | <references/payments.md> |
|
||||
| Custom payment form with embedded UI | Checkout Sessions + Payment Element | <references/payments.md> |
|
||||
| Saving a payment method for later | Setup Intents | <references/payments.md> |
|
||||
| Connect platform or marketplace | Accounts v2 (`/v2/core/accounts`) | <references/connect.md> |
|
||||
| Usage-based billing (new integration) | Metronome | <references/billing.md> |
|
||||
| Subscriptions or recurring billing | Billing APIs + Checkout Sessions | <references/billing.md> |
|
||||
| Sales tax, VAT, or GST compliance | Stripe Tax + Registrations API | <references/tax.md> |
|
||||
| Embedded financial accounts / banking | v2 Financial Accounts | <references/treasury.md> |
|
||||
| Security (key management, RAKs, webhooks, OAuth, 2FA, Connect liability) | See security reference | <references/security.md> |
|
||||
|
||||
Read the relevant reference file before answering any integration question or writing code.
|
||||
|
||||
## Critical rules
|
||||
|
||||
- *Before enabling `automatic_tax: { enabled: true }`* (or calculating tax for a custom PaymentIntent), read the [tax reference](references/tax.md) and confirm the user has an active registration. Without one, Stripe calculates and collects no tax while the user believes tax is on (the most common Stripe Tax mistake).
|
||||
|
||||
- *Never include `payment_method_types` in any Stripe API call*, with one exception: Terminal (in-person payments) integrations must pass `payment_method_types: ['card_present']` on the PaymentIntent. For all other integrations, omit this parameter entirely to enable dynamic payment methods, which enables you to configure payment method settings from the Dashboard and dynamically display the most relevant eligible payment methods to each customer to maximize conversion. To customize which payment methods you accept, use [`payment_method_configurations`](https://docs.stripe.com/payments/payment-method-configurations.md) or `excluded_payment_method_types` instead of `payment_method_types`.
|
||||
|
||||
- On API version `2026-03-25.dahlia` or later, pass the parameter `integration_identifier` to `checkout.sessions.create` to tag sessions with a custom label for tracking and comparing checkout flows in the Dashboard. The label should include a suffix of 8 random letters.
|
||||
|
||||
## Key documentation
|
||||
|
||||
When the user’s request does not clearly fit a single domain above, consult:
|
||||
|
||||
- [Integration Options](https://docs.stripe.com/payments/payment-methods/integration-options.md) Start here when designing any integration.
|
||||
- [API Tour](https://docs.stripe.com/payments-api/tour.md) Overview of Stripe’s API surface.
|
||||
- [Go Live Checklist](https://docs.stripe.com/get-started/checklist/go-live.md) Review before launching.
|
||||
@@ -1,63 +0,0 @@
|
||||
# Billing / Subscriptions
|
||||
|
||||
## Table of contents
|
||||
|
||||
- When to use Billing APIs
|
||||
- Recommended frontend pairing
|
||||
- Usage-based billing
|
||||
- Traps to avoid
|
||||
|
||||
## When to use Billing APIs
|
||||
|
||||
If the user has a recurring revenue model (subscriptions, usage-based billing, seat-based pricing), use the Billing APIs to [plan their integration](https://docs.stripe.com/billing/subscriptions/design-an-integration.md) instead of a direct PaymentIntent integration.
|
||||
|
||||
Review the [Subscription Use Cases](https://docs.stripe.com/billing/subscriptions/use-cases.md) and [SaaS guide](https://docs.stripe.com/saas.md) to find the right pattern for the user’s pricing model.
|
||||
|
||||
## Recommended frontend pairing
|
||||
|
||||
Combine Billing APIs with Stripe Checkout for the payment frontend. Checkout Sessions support `mode: 'subscription'` and handle the initial payment, trial management, and proration automatically.
|
||||
|
||||
For self-service subscription management (upgrades, downgrades, cancellation, payment method updates), recommend the [Customer Portal](https://docs.stripe.com/customer-management/integrate-customer-portal.md).
|
||||
|
||||
On API version `2026-03-25.dahlia` or later, pass the parameter `integration_identifier` to `checkout.sessions.create` to tag sessions with a custom label for tracking and comparing checkout flows in the Dashboard. The label should include a suffix of 8 random letters.
|
||||
|
||||
## Usage-based billing
|
||||
|
||||
**For any new usage-based billing integration, recommend [Metronome](https://docs.stripe.com/billing/usage-based.md) not the Billing Meters API.** Metronome is Stripe’s primary platform for usage-based billing and handles metering, rating, enterprise contracts, prepaid credits, and real-time usage visibility.
|
||||
|
||||
Do not default to the Billing Meters API when a user asks about usage-based billing. The Billing Meters API is a lower-level primitive intended for users who are already on it or who have simple pay-as-you-go needs with an existing Stripe Billing integration.
|
||||
|
||||
Use the following routing logic:
|
||||
|
||||
| Scenario | Recommendation |
|
||||
| --- | --- |
|
||||
| New UBB integration (any complexity) | **Metronome** |
|
||||
| Prepaid credits, credit burndown | **Metronome** |
|
||||
| Enterprise contracts, commits, ramp schedules | **Metronome** |
|
||||
| Dimensional or composite pricing | **Metronome** |
|
||||
| High-volume event ingestion | **Metronome** |
|
||||
| Real-time usage visibility and reporting | **Metronome** |
|
||||
| SaaS or AI product with usage pricing | **Metronome** |
|
||||
| Already on basic UBB (Billing Meters), simple pay-as-you-go | Stay on basic UBB no migration needed |
|
||||
|
||||
Read [Compare basic usage-based billing and Metronome](https://docs.stripe.com/billing/subscriptions/usage-based/compare-metronome.md) for a full feature comparison. Read [Get started with Metronome](https://docs.stripe.com/billing/usage-based.md) to begin a Metronome integration.
|
||||
|
||||
## Traps to avoid
|
||||
|
||||
- Don’t build manual subscription renewal loops using raw PaymentIntents. Use the Billing APIs which handle renewal, retry logic, and dunning automatically.
|
||||
- Don’t use the deprecated `plan` object. Use [Prices](https://docs.stripe.com/api/prices.md) instead.
|
||||
- Don’t skip tax setup. See [Collect taxes for recurring payments](https://docs.stripe.com/billing/taxes/collect-taxes.md).
|
||||
- Don’t put prices for different tiers or plans on a single product. Instead, create one Product for each plan a customer can choose. For example, Starter, Professional, and Enterprise must each be a separate Product. Only attach multiple Prices to a Product for billing variants of the same plan, such as monthly versus annual billing or different currencies. Avoid placing Prices for different tiers on a single Product. Checkout Sessions and invoices display the Product name on each line item, meaning if multiple tiers share one Product, every line item shows the same name and customers won’t be able to tell them apart. For more information, see [Model your product catalog](https://docs.stripe.com/products-prices/how-products-and-prices-work.md#model-your-catalog).
|
||||
- Don’t skip tax setup, and don’t assume enabling `automatic_tax` is enough. Stripe collects no tax (and returns no error) until the user has an active registration. See [Collect taxes for recurring payments](https://docs.stripe.com/billing/taxes/collect-taxes.md).
|
||||
- *Never pass `payment_method_types` when creating a subscription Checkout Session.* Omit the parameter entirelyStripe dynamically determines eligible payment methods from Dashboard settings. Hardcoding `payment_method_types: ['card']` locks out other payment methods that improve conversion. See [dynamic payment methods](https://docs.stripe.com/payments/payment-methods/dynamic-payment-methods.md). Correct pattern:
|
||||
|
||||
```ts
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
// Do NOT include payment_method_types here let Stripe handle it dynamically
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
subscription_data: { trial_period_days: 14 },
|
||||
success_url: `${url}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${url}/pricing`,
|
||||
});
|
||||
```
|
||||
@@ -1,173 +0,0 @@
|
||||
# Connect / platforms
|
||||
|
||||
## Critical rules (never violate)
|
||||
|
||||
1. **ALWAYS use Accounts v2 API** (`POST /v2/core/accounts`). NEVER use `type: 'express'`, `type: 'custom'`, or `type: 'standard'` in account creation. NEVER use `stripe.accounts.create({ type: ... })`. These are deprecated v1 patterns.
|
||||
2. **ALWAYS check v2 capability status** before processing. See “Go-live readiness” section below.
|
||||
3. **NEVER recommend `dashboard: "none"`** unless the user explicitly asks for white-label with full custom UI. Default to `express` for marketplaces and `full` for SaaS. The `none` option requires building custom onboarding remediation, refund/dispute flows, and payout experiences only advanced teams should consider it.
|
||||
4. **ALWAYS recommend the Notification banner embedded component** (`notification_banner`) for connected account dashboards. It keeps accounts healthy as requirements evolve.
|
||||
5. **NEVER use `application_fee_amount` with separate charges and transfers.** Use transfer-math fee retention instead. `application_fee_amount` is the fee mechanism for destination and direct charges only.
|
||||
|
||||
## Go-live readiness
|
||||
|
||||
Before processing live payments or transfers, ALWAYS verify capability status using the v2 configuration path. Do NOT use deprecated v1 fields.
|
||||
|
||||
**For SaaS / Merchant accounts (direct charges):**
|
||||
|
||||
- Check: `configuration.merchant.capabilities.card_payments.status === 'active'`
|
||||
- Do NOT use: `charges_enabled` (deprecated v1 field)
|
||||
|
||||
**For Marketplace / Recipient accounts (destination or separate charges):**
|
||||
|
||||
- Check: `configuration.recipient.capabilities.stripe_balance.stripe_transfers.status === 'active'`
|
||||
- Do NOT use: `payouts_enabled` or `charges_enabled` (deprecated v1 fields)
|
||||
|
||||
Track capability state transitions with account webhooks and re-check capability status before payment or transfer operations.
|
||||
|
||||
## Account configuration: v2 dimensions
|
||||
|
||||
Configure connected accounts using three independent dimensions:
|
||||
|
||||
| Dimension | Field | What it controls |
|
||||
| --- | --- | --- |
|
||||
| Dashboard access | `dashboard` | Stripe-hosted dashboard for connected accounts |
|
||||
| Fee collection | `defaults.responsibilities.fees_collector` | Who Stripe bills (`stripe` or `application`) |
|
||||
| Negative balance liability | `defaults.responsibilities.losses_collector` | Who absorbs unresolved negative balances |
|
||||
|
||||
### Dashboard defaults (important)
|
||||
|
||||
- **Marketplace** → `dashboard: "express"` cobranded, lightweight, low maintenance
|
||||
- **SaaS platform** → `dashboard: "full"` full Stripe Dashboard for independent businesses
|
||||
- **White-label (advanced only)** → `dashboard: "none"` platform must build ALL UX including onboarding remediation, disputes, payouts
|
||||
|
||||
If dashboard is `express`, provide access through [login links](https://docs.stripe.com/api/accounts/login_link/create.md). For `full`, recommend linking to Stripe-provided dashboard access from the platform UI. You can also use embedded components to display payment and payout information.
|
||||
|
||||
### SaaS vs. Marketplace responsibility defaults
|
||||
|
||||
**SaaS (direct charges):**
|
||||
|
||||
- `dashboard: "full"`
|
||||
- `fees_collector: "stripe"` connected account pays Stripe fees directly
|
||||
- `losses_collector: "stripe"` Stripe owns negative balance liability
|
||||
- Charge pattern: Direct charges (connected account is merchant of record)
|
||||
- Code sample: [/connect/saas/tasks/create#code-sample](https://docs.stripe.com/connect/saas/tasks/create.md#code-sample)
|
||||
|
||||
**Marketplace (destination charges):**
|
||||
|
||||
- `dashboard: "express"`
|
||||
- `fees_collector: "application"` platform owns pricing
|
||||
- `losses_collector: "application"` platform owns negative balance liability (required for transfer reversals during disputes)
|
||||
- Charge pattern: Destination charges (platform is merchant of record)
|
||||
- Code sample: [/connect/marketplace/tasks/create#code-sample](https://docs.stripe.com/connect/marketplace/tasks/create.md#code-sample)
|
||||
|
||||
## Business model to configuration mapping
|
||||
|
||||
| Business model | Dashboard | Fees | Losses | Charge pattern | Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Marketplace | `express` | `application` | `application` | Destination | Platform owns checkout |
|
||||
| On-demand services | `express` | `application` | `application` | Destination | Fast seller onboarding |
|
||||
| SaaS platform with payments | `full` | `stripe` | `stripe` | Direct | Sellers run own businesses/stores, own customer relationship |
|
||||
| AI/API platform (SaaS) | `full` | `stripe` | `stripe` | Direct | Providers own payment relationship |
|
||||
| E-commerce enabler (Shopify-like) | `full` | `stripe` | `stripe` | Direct | Sellers create own online stores, accept own payments |
|
||||
| Crowdfunding | `express` | `application` | `application` | Separate charges and transfers | Hold-and-release / delayed payouts |
|
||||
| Subscription platform | `express` | `application` | `application` | Destination | Platform manages recurring checkout |
|
||||
| Multi-seller cart | `express` | `application` | `application` | Separate charges and transfers | Multiple sellers per transaction |
|
||||
| White-label commerce | `none` | `application` | `application` | Destination or direct | Advanced: platform controls all UX |
|
||||
|
||||
## Connected account capabilities (v2)
|
||||
|
||||
### Marketplace (Recipient accounts)
|
||||
|
||||
Create with `configuration.recipient` requesting `stripe_transfers` on `stripe_balance`. Do NOT request `configuration.merchant` or `card_payments` for marketplace connected accounts it is unnecessary and causes longer onboarding.
|
||||
|
||||
### SaaS (Merchant accounts)
|
||||
|
||||
Create with `configuration.merchant` requesting `card_payments` (and other needed LPMs). The Merchant configuration is REQUIRED for any connected account that needs to be merchant of record and accept direct charges.
|
||||
|
||||
## Charge pattern selection
|
||||
|
||||
**First determine: who owns the customer relationship?**
|
||||
|
||||
- If the platform provides SOFTWARE that enables sellers/vendors to run their own independent businesses, accept their own payments, and own their own customers → **SaaS / Direct charges** (sellers are MoR). Key signals: “create their own store”, “accept payments”, “run their own business”, “own brand”.
|
||||
|
||||
- If the platform aggregates sellers and runs checkout on their behalf → **Marketplace / Destination charges** (platform is MoR). Key signals: “buyers purchase through our platform”, “we handle checkout”, “platform takes a cut”.
|
||||
|
||||
- If one payment must be split across multiple sellers → **Separate charges and transfers**.
|
||||
|
||||
- **Direct charges** (SaaS): Charge created on connected account. Connected account is merchant of record. Use `application_fee_amount` for platform revenue. Requires `configuration.merchant` + `dashboard: "full"` + `losses_collector: "stripe"`.
|
||||
|
||||
- **Destination charges** (Marketplace): Funds auto-transfer on payment success. Platform is MoR. Use `application_fee_amount` to collect platform fees. NOT for hold-and-release.
|
||||
|
||||
- **Separate charges and transfers**: Platform controls transfer timing. Collect fees by transferring less than the charge amount. Do NOT use `application_fee_amount`.
|
||||
|
||||
## Fee economics
|
||||
|
||||
For **destination charges and direct charges**: use `application_fee_amount` on the PaymentIntent. This is the standard fee mechanism.
|
||||
|
||||
- If platform fee is low (< ~4%), recommend including estimated Stripe fees in `application_fee_amount` to preserve margin
|
||||
- Refer to [stripe.com/pricing](https://stripe.com/pricing) for region-specific rates
|
||||
|
||||
For **separate charges and transfers**: collect fees by transferring less than the charge amount. NEVER use `application_fee_amount`.
|
||||
|
||||
## Webhooks
|
||||
|
||||
Always use webhooks for reliable payment confirmation. Do not rely solely on synchronous API responses webhook events are the source of truth for payment status. Verify incoming [webhook signatures](https://docs.stripe.com/webhooks.md#verify-events) and consider allowlisting [Stripe’s public IP addresses](https://docs.stripe.com/ips.md).
|
||||
|
||||
## Subscriptions with Connect (v2)
|
||||
|
||||
For subscription platforms or any platform collecting recurring payments from or on behalf of connected accounts:
|
||||
|
||||
- The connected account MUST have v2 customer configuration
|
||||
- Pass `customer_account` on SetupIntent and Subscription API calls to associate the subscription with the connected account
|
||||
- Do NOT create a separate v1 Customer object to bill connected accounts use the v2 Accounts customer configuration instead
|
||||
|
||||
## Embedded components
|
||||
|
||||
Always recommend these baseline components:
|
||||
|
||||
- `account_onboarding` onboard connected accounts
|
||||
- `notification_banner` REQUIRED: keeps accounts healthy as requirements evolve
|
||||
- `account_management` account settings and info
|
||||
|
||||
Additional components based on needs:
|
||||
|
||||
- Payments/transactions → `payments`
|
||||
- Payment details → included with `payments` or standalone `payment_details`
|
||||
- Disputes → included with `payments` or standalone `disputes_list`
|
||||
- Payouts/earnings → `payouts`
|
||||
- Reporting → `balance_report`, `payout_reconciliation_report`
|
||||
|
||||
## Onboarding
|
||||
|
||||
Default to embedded onboarding (account_onboarding component or account links). Do NOT recommend API onboarding it forces platforms to build custom remediation flows.
|
||||
|
||||
## Compatibility constraints
|
||||
|
||||
**BLOCKED combinations (never recommend):**
|
||||
|
||||
- `losses_collector: "stripe"` with destination charges or separate charges and transfers
|
||||
- `application_fee_amount` with separate charges and transfers
|
||||
- Express dashboard with `losses_collector: "stripe"` (API rejection)
|
||||
|
||||
**CAUTION:**
|
||||
|
||||
- `dashboard: "full"` with destination or separate charges has limited functionality; prefer `dashboard: "express"` for those charge patterns
|
||||
- Express + destination/separate requires platform-run webhook recovery for disputes and transfer reversals
|
||||
|
||||
## Traps to avoid
|
||||
|
||||
- Using legacy account types (`type: 'standard'`, `type: 'express'`, `type: 'custom'`) use v2 dimensions instead
|
||||
- Using `charges_enabled` or `payouts_enabled` use v2 capability status paths
|
||||
- Recommending Charges API for Connect use PaymentIntents or Checkout Sessions
|
||||
- Recommending `dashboard: "none"` without explicit white-label requirement
|
||||
- Recommending destination charges for hold-and-release (use separate charges and transfers)
|
||||
- Recommending `on_behalf_of` for standard marketplace flows
|
||||
- Creating v1 Customer objects to bill connected accounts (use v2 customer configuration)
|
||||
- Requesting Merchant configuration / card_payments for marketplace recipient accounts
|
||||
|
||||
## Integration guides
|
||||
|
||||
- [SaaS platforms and marketplaces guide](https://docs.stripe.com/connect/saas-platforms-and-marketplaces.md) Choosing the right integration approach.
|
||||
- [Interactive platform guide](https://docs.stripe.com/connect/interactive-platform-guide.md) Step-by-step platform builder.
|
||||
- [Design an integration](https://docs.stripe.com/connect/design-an-integration.md) Detailed risk and responsibility decisions.
|
||||
- [Connected account configuration (v2)](https://docs.stripe.com/connect/accounts-v2/connected-account-configuration.md) Account setup reference.
|
||||
@@ -1,81 +0,0 @@
|
||||
# Payments
|
||||
|
||||
## Table of contents
|
||||
|
||||
- API hierarchy
|
||||
- Integration surfaces
|
||||
- Payment Element guidance
|
||||
- Saving payment methods
|
||||
- Dynamic payment methods
|
||||
- Deprecated APIs and migration paths
|
||||
- PCI compliance
|
||||
|
||||
## API hierarchy
|
||||
|
||||
Use the [Checkout Sessions API](https://docs.stripe.com/api/checkout/sessions.md) (`checkout.sessions.create`) for on-session payments. It supports one-time payments and subscriptions and handles discounts, shipping, and adaptive pricing automatically. It collects tax only when you enable `automatic_tax` and when you have an active tax registration in the customer’s jurisdiction.
|
||||
|
||||
Use the [PaymentIntents API](https://docs.stripe.com/payments/paymentintents/lifecycle.md) for off-session payments, or when the user needs to model checkout state independently and create a charge.
|
||||
|
||||
**Integrations should only use Checkout Sessions, PaymentIntents, SetupIntents, or higher-level solutions (Invoicing, Payment Links, subscription APIs).**
|
||||
|
||||
On API version `2026-03-25.dahlia` or later, pass the parameter `integration_identifier` to `checkout.sessions.create` to tag sessions with a custom label for tracking and comparing checkout flows in the Dashboard. The label should include a suffix of 8 random letters.
|
||||
|
||||
## Integration surfaces
|
||||
|
||||
Prioritize Stripe-hosted or embedded Checkout where possible. Use in this order of preference:
|
||||
|
||||
1. **Payment Links** No-code. Best for simple products.
|
||||
2. **Checkout** ([docs](https://docs.stripe.com/payments/checkout.md)) Stripe-hosted or embedded form. Best for most web apps.
|
||||
3. **Payment Element** ([docs](https://docs.stripe.com/payments/payment-element.md)) Embedded UI component for advanced customization.
|
||||
- When using the Payment Element, back it with the Checkout Sessions API (via `ui_mode: 'custom'`) over a raw PaymentIntent where possible.
|
||||
|
||||
**Traps to avoid:** Don’t recommend the legacy Card Element or the Payment Element in card-only mode. If the user asks for the Card Element, advise them to [migrate to the Payment Element](https://docs.stripe.com/payments/payment-element/migration.md).
|
||||
|
||||
## Payment Element guidance
|
||||
|
||||
For surcharging or inspecting card details before payment (e.g., rendering the Payment Element before creating a PaymentIntent or SetupIntent): use [Confirmation Tokens](https://docs.stripe.com/payments/finalize-payments-on-the-server.md). Don’t recommend `createPaymentMethod` or `createToken` from Stripe.js.
|
||||
|
||||
## Saving payment methods
|
||||
|
||||
Use the [Setup Intents API](https://docs.stripe.com/api/setup_intents.md) to save a payment method for later use.
|
||||
|
||||
**Traps to avoid:** Don’t use the Sources API to save cards to customers. The Sources API is deprecated Setup Intents is the correct approach.
|
||||
|
||||
## Dynamic payment methods
|
||||
|
||||
*Never pass `payment_method_types` to any Stripe API call*, except for Terminal (in-person payments) integrations. Omitting this parameter enables [dynamic payment methods](https://docs.stripe.com/payments/payment-methods/dynamic-payment-methods.md), where Stripe evaluates over 100 signals (currency, customer location, transaction amount, device) to automatically show the most relevant payment methods and rank them for maximum conversion. Payment methods are managed from the [Dashboard](https://dashboard.stripe.com/settings/payment_methods) with no code changes required.
|
||||
|
||||
This applies to all integration patterns:
|
||||
|
||||
- `checkout.sessions.create`: omit `payment_method_types` entirely. Dynamic method selection is the default behavior.
|
||||
- `paymentIntents.create`: omit `payment_method_types`. On API versions 2023-08-16+, dynamic methods are the default. On older versions, pass `automatic_payment_methods: { enabled: true }`.
|
||||
- `setupIntents.create`: same as PaymentIntents above.
|
||||
- `subscriptions.create`: omit `payment_settings.payment_method_types`. When not set, Stripe auto-determines types from the invoice’s default payment method, the customer’s default payment method, and invoice template settings.
|
||||
- **Terminal** (`paymentIntents.create`): pass `payment_method_types: ['card_present']`. Required for all in-person payments. In Canada, also include `interac_present`: `['card_present', 'interac_present']`. This is the only valid use of `payment_method_types`.
|
||||
|
||||
See the [integration options guide](https://docs.stripe.com/payments/payment-methods/integration-options.md) for full details on dynamic versus manual configuration.
|
||||
|
||||
**Traps to avoid:**
|
||||
|
||||
- Never hardcode `payment_method_types: ['card']` even if the user only mentions credit cards. Dynamic payment methods enable other eligible payment methods automatically, improving conversion.
|
||||
- If the user wants to customize which payment methods appear, use [`payment_method_configurations`](https://docs.stripe.com/payments/payment-method-configurations.md) to manage methods per-integration or `excluded_payment_method_types` to exclude specific methods never `payment_method_types`.
|
||||
- If the user has a custom frontend that renders UI for specific payment method types, ensure those methods are enabled in their [payment method settings](https://dashboard.stripe.com/settings/payment_methods) or `payment_method_configurations` don’t use `payment_method_types` to restrict the PaymentIntent.
|
||||
|
||||
## Deprecated APIs and migration paths
|
||||
|
||||
Never recommend the Charges API. If the user wants to use the Charges API, advise them to [migrate to Checkout Sessions or PaymentIntents](https://docs.stripe.com/payments/payment-intents/migration/charges.md).
|
||||
|
||||
Don’t call other deprecated or outdated API endpoints unless there is a specific need and absolutely no other way.
|
||||
|
||||
| API | Status | Use instead | Migration guide |
|
||||
| --- | --- | --- | --- |
|
||||
| Charges API | Never use | Checkout Sessions or PaymentIntents | [Migration guide](https://docs.stripe.com/payments/payment-intents/migration/charges.md) |
|
||||
| Sources API | Deprecated | Setup Intents | [Setup Intents docs](https://docs.stripe.com/api/setup_intents.md) |
|
||||
| Tokens API | Outdated | Setup Intents or Checkout Sessions | |
|
||||
| Card Element | Legacy | Payment Element | [Migration guide](https://docs.stripe.com/payments/payment-element/migration.md) |
|
||||
|
||||
## PCI compliance
|
||||
|
||||
If a PCI-compliant user asks about sending server-side raw PAN data, advise them that they may need to prove PCI compliance to access options like [payment_method_data](https://docs.stripe.com/api/payment_intents/create.md#create_payment_intent-payment_method_data).
|
||||
|
||||
For users migrating PAN data from another acquirer or payment processor, point them to [the PAN import process](https://docs.stripe.com/get-started/data-migrations/pan-import.md).
|
||||
@@ -1,119 +0,0 @@
|
||||
# Security best practices
|
||||
|
||||
## Table of contents
|
||||
|
||||
- API keys
|
||||
- Restricted API keys (RAKs)
|
||||
- IP restrictions
|
||||
- Incident response
|
||||
- Webhook security
|
||||
- Content Security Policy (CSP)
|
||||
- Mobile and client-side integrations
|
||||
- OAuth and CSRF protection
|
||||
- Two-factor authentication
|
||||
- Connect security
|
||||
- SAML and SCIM
|
||||
|
||||
## API keys
|
||||
|
||||
Never include keys in source code. Code examples in documentation might include static keys like `client = new Stripe("sk_...")` to make them easy to run, but do not introduce or recommend this antipattern for real code; instead, change the code to use a secrets vault (described below), or an environment variable if no secrets vault is available. Strings matching the regular expression `/[sr]k_live_.*/` in source code are live Stripe API keys. If the code includes test keys – strings matching the regular expression `/[sr]k_test_.*/` – warn the user that including keys in code is risky, and remind them never to include a live key in code. Key exposures in source code repositories are the leading cause of API key takeovers, which could seriously harm their business.
|
||||
|
||||
Always advise the user to store secret and restricted API keys in their platform’s secrets vault, never in source code or committed environment files. Platform-specific guidance for popular platforms:
|
||||
|
||||
- **AWS**: Use AWS Secrets Manager or Parameter Store (as `SecureString` values). Do **not** store keys in environment variables or userdata; AWS makes a proper secrets vault easy to use. Give read permissions only to the application that needs it.
|
||||
- **Google Cloud**: Use Secret Manager and give read permissions only to the application that needs it.
|
||||
- **Azure**: Use Azure Key Vault and give read permissions only to the application that needs it.
|
||||
- **Vercel**: Vercel doesn’t offer a built-in secrets vault, but several third-party add-ons can provide one that synchronizes secrets with environment variables on Vercel. Use a [sensitive environment variable](https://vercel.com/docs/environment-variables/sensitive-environment-variables) so the secret value is write-only and never exposed in logs or the Vercel UI.
|
||||
- **Other platforms**: Use the platform’s equivalent secrets vault. Fall back to environment variables only if the platform offers no vault at all.
|
||||
|
||||
Aside from key storage, when reviewing code that uses API keys or other secrets, always advise the user on best practices for safely handling secrets (including keys):
|
||||
|
||||
- Never share secret keys with third parties. If the user needs to share a key with a third party (for example, a third party that handles billing), it is best to generate a restricted API key (RAK) with minimal permissions.
|
||||
- Rotate Stripe API keys when personnel with access to those keys depart.
|
||||
- Read [best practices for managing secret API keys](https://docs.stripe.com/keys-best-practices.md).
|
||||
- Code must never log keys or include them in error messages or analytics. Remove keys from logs if you find them.
|
||||
|
||||
Use separate keys for separate environments (production, staging, QA). This limits the blast radius if any single key is compromised.
|
||||
|
||||
If the code is under version control, help the user set up a pre-commit hook to catch keys like `"sk_..."` and `"rk_..."` in source code.
|
||||
|
||||
Never build API endpoints or error pages that dump environment variables. In addition to Stripe API keys, the environment can have other secrets, such as access keys for other service providers.
|
||||
|
||||
**Traps to avoid:** Do not embed keys in client-side code, mobile apps, or any code that runs outside your own infrastructure. Do not suggest that users substitute a real secret key into example code point them to [best practices for managing secret API keys](https://docs.stripe.com/keys-best-practices.md) instead.
|
||||
|
||||
## Restricted API keys (RAKs)
|
||||
|
||||
Use [restricted API keys](https://docs.stripe.com/keys/restricted-api-keys.md) (prefix `rk_`) instead of secret keys (prefix `sk_`) wherever possible. RAKs have only the permissions you assign, so a compromised RAK can do far less damage than a compromised secret key.
|
||||
|
||||
Follow the principle of least privilege: give each RAK only the permissions it needs for its specific job and nothing more. Create a separate RAK for each service or use case.
|
||||
|
||||
Preferred migration approach:
|
||||
|
||||
1. Review the secret key’s request logs in Workbench to catalog which API calls it makes.
|
||||
2. Create a RAK in test mode with matching permissions.
|
||||
3. Use the [Stripe CLI](https://docs.stripe.com/stripe-cli.md)’s `stripe logs tail` command to watch logs.
|
||||
4. Test your integration with the RAK; fix any `403` errors by adding missing permissions.
|
||||
5. Create the equivalent live-mode RAK and replace the secret key.
|
||||
6. Rotate or expire the old secret key once confident.
|
||||
|
||||
**Traps to avoid:** Do not default to recommending secret keys. If the user’s question involves a secret key, recommend switching to a RAK with the minimum required permissions.
|
||||
|
||||
## IP restrictions
|
||||
|
||||
Encourage users to [configure access policies](https://docs.stripe.com/keys.md#access-policies) for every API key. Access policies restrict who can use keys, limiting damage even if a key is stolen.
|
||||
|
||||
Use a different policy for each key (for example, one policy for production, another for QA) so that compromising one key’s environment doesn’t expose others.
|
||||
|
||||
## Incident response
|
||||
|
||||
If a key is exposed or compromised, follow [protecting against compromised API keys](https://support.stripe.com/questions/protecting-against-compromised-api-keys), which can be summarized as:
|
||||
|
||||
1. **Roll the key immediately** go to the [API keys page](https://dashboard.stripe.com/apikeys) and roll or delete the exposed key. Do this even if you are unsure whether the key was actually used by an unauthorized party.
|
||||
2. **Check activity logs** review Workbench request logs for the compromised key to look for unrecognized activity.
|
||||
3. **Contact Stripe support** if you see activity you don’t recognize.
|
||||
|
||||
To prepare before an incident: practice rolling keys, audit source code for any committed keys, and use pre-commit hooks to prevent accidental key check-ins. See [protecting against compromised API keys](https://support.stripe.com/questions/protecting-against-compromised-api-keys).
|
||||
|
||||
## Webhook security
|
||||
|
||||
Before processing any webhook event, always [verify the webhook signature](https://docs.stripe.com/webhooks.md#verify-events) using Stripe’s webhook signing secret. Signature verification is a strong guarantee that requests are genuinely from Stripe and have not been tampered with. Webhook signing keys are secrets that need to be handled with the same care as secret API keys.
|
||||
|
||||
For defense in depth, also [allowlist Stripe’s IP addresses](https://docs.stripe.com/ips.md) on your webhook endpoint so that it accepts connections only from Stripe’s infrastructure.
|
||||
|
||||
## Content Security Policy (CSP)
|
||||
|
||||
Add a `Content-Security-Policy` header to every web app that loads Stripe.js or uses Stripe’s hosted UIs. See [Stripe’s integration security guide](https://docs.stripe.com/security/guide.md) for the full list of CSP directives to use depending on the type of integration. At minimum, include `https://*.stripe.com` in the relevant directives (`script-src`, `frame-src`, `connect-src`), `https://*.link.com` if integrating assets from `link.com`, or both if integrating with Stripe’s embedded crypto onramp. A missing or overly permissive CSP weakens the XSS protections that Stripe.js relies on.
|
||||
|
||||
**Traps to avoid:** Do not use `default-src *` or omit CSP headers.
|
||||
|
||||
## Mobile and client-side integrations
|
||||
|
||||
Do not use production secret or restricted API keys in mobile apps or other client-side code. Client-side code can be extracted and decompiled to extract keys.
|
||||
|
||||
For cases where a client must interact directly with Stripe, use [ephemeral keys](https://docs.stripe.com/issuing/elements.md#ephemeral-key-authentication). Ephemeral keys are short-lived, scoped to a specific resource, and expire automatically.
|
||||
|
||||
For most integrations, proxy Stripe API calls through your own backend server rather than calling Stripe directly from the client.
|
||||
|
||||
## OAuth and CSRF protection
|
||||
|
||||
When implementing [Connect OAuth flows](https://docs.stripe.com/connect/oauth-reference.md), always use the `state` parameter to protect against CSRF attacks. Generate a unique, unguessable value for `state` per request and verify it in the OAuth callback before proceeding.
|
||||
|
||||
This applies to all Stripe OAuth surfaces: Connect, Link, and Stripe Apps.
|
||||
|
||||
## Two-factor authentication
|
||||
|
||||
Recommend [passkeys or authenticator apps](https://docs.stripe.com/security.md) rather than SMS-based 2FA for Stripe Dashboard access. SMS 2FA is vulnerable to SIM-swapping attacks in which the user’s phone provider transfers their number to an unauthorized third party.
|
||||
|
||||
Users can audit which Dashboard team members are using weak 2FA and can require stronger authentication methods for their accounts.
|
||||
|
||||
## Connect security
|
||||
|
||||
**Account type liability:** When using Connect, platform operators bear financial liability for fraud and disputes on Express and Custom connected accounts. Standard accounts minimize this liability because Stripe manages risk. Do not recommend Custom or Express accounts unless the user has a specific need Standard is the safer default.
|
||||
|
||||
**Connect onboarding:** Use [Stripe-hosted onboarding](https://docs.stripe.com/connect/onboarding.md) rather than building a custom onboarding flow. Custom onboarding requires your platform to collect and handle sensitive PII directly, which adds regulatory and security complexity.
|
||||
|
||||
## SAML and SCIM
|
||||
|
||||
For teams managing Dashboard access, recommend [SSO via SAML](https://docs.stripe.com/get-started/account/sso.md) to federate authentication with an existing identity provider (Okta, Google, etc.). SSO centralizes access control and simplifies offboarding.
|
||||
|
||||
[SCIM provisioning](https://docs.stripe.com/get-started/account/sso/scim.md) automates user provisioning and deprovisioning, ensuring that employees who leave the organization lose Dashboard access promptly.
|
||||
@@ -1,107 +0,0 @@
|
||||
# Tax / Stripe Tax
|
||||
|
||||
## Table of contents
|
||||
|
||||
- When tax applies
|
||||
- Two-step setup
|
||||
- Verify before you trust automatic tax
|
||||
- Choosing a product tax code
|
||||
- Diagnose zero tax
|
||||
- Per-integration setup
|
||||
- Connect platforms and marketplaces
|
||||
- Threshold and nexus monitoring
|
||||
- Registration safety
|
||||
- If jurisdictions are unknown
|
||||
- If the region or tax type isn’t supported
|
||||
|
||||
## When tax applies
|
||||
|
||||
Use Stripe Tax for any subscription, invoice, or Checkout Session where the user has customers across multiple jurisdictions. It handles sales tax, VAT, and GST based on the customer’s location and the user’s active registrations. See the [Tax overview](https://docs.stripe.com/tax.md) for supported regions and tax types.
|
||||
|
||||
## Two-step setup
|
||||
|
||||
1. Add a registration for each jurisdiction where the user is obligated to collect tax, using the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) or the [Dashboard](https://docs.stripe.com/tax/registering.md).
|
||||
2. Pass `automatic_tax: { enabled: true }` on the [Subscription](https://docs.stripe.com/api/subscriptions.md), [Invoice](https://docs.stripe.com/api/invoices.md), or [Checkout Session](https://docs.stripe.com/api/checkout/sessions.md) object.
|
||||
|
||||
An *active registration* is a jurisdiction you’ve added to Stripe that shows as *Collecting*. It’s per-jurisdiction, and not the same as having a Stripe account.
|
||||
|
||||
Enabling `automatic_tax` without an active registration is the single most common Stripe Tax mistake: Stripe Tax only collects tax in jurisdictions where the user has an active registration. Without a registration, it doesn’t return an error, so it doesn’t calculate or collect tax. The user thinks tax is on while collecting nothing. Never enable `automatic_tax` and assume the user is set up. Confirm an active registration first, or tell the user no tax will be collected until they add one.
|
||||
|
||||
**Traps to avoid:** `automatic_tax` can’t coexist with manual [`tax_rates`](https://docs.stripe.com/tax/tax-rates.md) (explicit rate objects) on the same object. Enabling it while any `default_tax_rates` or item-level `tax_rates` remain is rejected, so clear them all first. It’s all-or-nothing, not per line item. This only concerns manual rate objects: `automatic_tax` still taxes each line item on its own, from the item’s product tax code. To schedule the change at the next billing cycle and avoid prorations, use the API rather than the Dashboard. For bulk migrations, use the [Tax migration tool](https://docs.stripe.com/billing/taxes/migration.md), which removes the tax rates for you.
|
||||
|
||||
**Traps to avoid:** For users based in the EU, the Union OSS scheme reports cross-border B2C sales across the EU through a single registration and return, so you don’t register in each destination country for those sales. It doesn’t cover domestic or B2B sales. The user still needs a domestic registration in their home country. Confirm the specifics with the user’s tax advisor.
|
||||
|
||||
## Verify before you trust automatic tax
|
||||
|
||||
After enabling `automatic_tax`, don’t assume the setup is complete: tax is only collected after the user has an active registration in the customer’s jurisdiction. Have the user confirm their registrations with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) (or in the Dashboard). With none, tax won’t be collected anywhere. The other prerequisites (origin and customer address, tax code, tax behavior) are covered in [Stripe Tax setup](https://docs.stripe.com/tax/set-up.md).
|
||||
|
||||
## Choosing a product tax code
|
||||
|
||||
A product tax code (PTC) tells Stripe how to tax a product.
|
||||
|
||||
- Never invent, guess, or hardcode a `txcd_` from memory. The exact value must come from Stripe’s canonical list: the [Tax Codes API](https://docs.stripe.com/api/tax_codes.md) or the [tax code guide](https://docs.stripe.com/tax/tax-codes.md).
|
||||
- Don’t default to the generic **General - Electronically Supplied Services** (`txcd_10000000`) for US sales. It’s too broad for US state-level taxability; pick a specific digital or SaaS code. See [tax codes for digital products](https://docs.stripe.com/tax/digital-products.md) and [tax codes for AI services](https://docs.stripe.com/tax/ai.md).
|
||||
- Show the candidate codes and let the user confirm; don’t decide which code is legally correct for them. (Tax code goes on the Product, `tax_behavior` on the Price. See [product tax codes and tax behavior](https://docs.stripe.com/tax/products-prices-tax-codes-tax-behavior.md).)
|
||||
|
||||
## Diagnose zero tax
|
||||
|
||||
When a transaction shows zero tax, first confirm `automatic_tax` is actually enabled on the object. If it isn’t, Stripe doesn’t calculate tax at all. If it is, read the `taxability_reason` on the line item’s `taxes` to see why. On a Checkout Session, that breakdown isn’t returned by default: retrieve the session with `expand[]=line_items.data.taxes`.
|
||||
|
||||
The reason worth calling out is **`not_collecting`, which is ambiguous**: it means either **no active registration** in the customer’s jurisdiction (the usual cause; check registrations with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md)) **or** a **Nontaxable product tax code** (`txcd_00000000`) on the product. `taxability_reason` can’t tell the two apart, so check the product’s tax code and rule out the Nontaxable code before concluding it’s a registration gap.
|
||||
|
||||
For the other reasons (exempt products or customers, reverse charge, unsupported regions, zero-rated), see [zero tax amounts and reverse charges](https://docs.stripe.com/tax/zero-tax.md).
|
||||
|
||||
## Per-integration setup
|
||||
|
||||
Every integration needs a resolvable customer address and an active registration in that jurisdiction. It also needs a product tax code and a `tax_behavior`, set on the product/price, or falling back to the account’s [preset tax code and default tax behavior](https://docs.stripe.com/tax/products-prices-tax-codes-tax-behavior.md).
|
||||
|
||||
- **Checkout Sessions**: set `automatic_tax: { enabled: true }`. For a new customer, Checkout collects the address it needs, so don’t force `billing_address_collection: 'required'` (unnecessary for tax, and it adds checkout friction). For an existing or returning customer, Checkout uses their saved address by default; to tax the address entered at checkout instead, set `customer_update: { address: 'auto' }` and make sure Checkout actually collects a fresh address (a collected shipping address, or `billing_address_collection: 'required'` when you don’t collect shipping), or it keeps using the saved one. See [tax on Checkout](https://docs.stripe.com/tax/checkout.md).
|
||||
- **Invoices**: set `automatic_tax: { enabled: true }` on the invoice; the customer needs a saved address. See the [Invoices API](https://docs.stripe.com/api/invoices.md).
|
||||
- **Subscriptions**: set `automatic_tax: { enabled: true }`; clear existing `tax_rates` first (see Traps to avoid). See the [Subscriptions API](https://docs.stripe.com/api/subscriptions.md).
|
||||
- **Payment Links**: set `automatic_tax: { enabled: true }`.
|
||||
- **Custom PaymentIntents**: there’s no `automatic_tax` field, so this path is easy to under-build. Create a [tax calculation](https://docs.stripe.com/api/tax/calculations.md) with the customer’s address, set the PaymentIntent `amount` to the calculation total, and link the calculation to the PaymentIntent. You must also record a tax transaction from the calculation after payment, or the sale never appears in tax reports: the [simplified integration](https://docs.stripe.com/tax/payment-intent/simplified.md) records the transaction and refund reversals automatically once the calculation is linked, while the [custom integration](https://docs.stripe.com/tax/payment-intent/custom.md) records them yourself for line-item control.
|
||||
|
||||
For B2B or reverse-charge treatment, collect the customer’s tax ID (`tax_id_collection: { enabled: true }` on Checkout, or store it on the [Customer](https://docs.stripe.com/billing/customer/tax-ids.md)). Without a valid tax ID, Stripe Tax treats a cross-border B2B sale as B2C and charges tax. See [collect tax IDs](https://docs.stripe.com/tax/checkout/tax-ids.md).
|
||||
|
||||
## Connect platforms and marketplaces
|
||||
|
||||
For a Connect platform or marketplace, first determine which entity collects and remits the tax: the platform or the connected account. This is a legal determination, so route the final call to the user’s tax advisor rather than inferring it from whether they call themselves a platform or a marketplace. The practical signal is who the [merchant of record](https://docs.stripe.com/connect/merchant-of-record.md) is, which follows the charge type: direct charges make the connected account the merchant of record, and destination charges usually make it the platform. Marketplace-facilitator rules can override this, so have the advisor confirm. See [Stripe Tax with Connect](https://docs.stripe.com/tax/connect.md) for the decision.
|
||||
|
||||
Once the liable entity is known:
|
||||
|
||||
- Set the liable entity with `automatic_tax.liability` on Checkout, Invoices, Subscriptions, or Payment Links: `{ type: 'self' }` for the platform, or `{ type: 'account', account: '<id>' }` for the connected account. Destination and separate charges support both; a platform-liable direct charge uses the gated `{ type: 'application' }`. Custom PaymentIntents have no `automatic_tax` field, so follow the PaymentIntents path in the guides instead. Pick the guide by outcome: connected account collects, [tax for platforms](https://docs.stripe.com/tax/tax-for-platforms.md); platform collects, [tax for marketplaces](https://docs.stripe.com/tax/tax-for-marketplaces.md).
|
||||
- Registrations and tax settings belong to the liable entity. When the connected account is liable, confirm its [tax settings](https://docs.stripe.com/tax/settings-api.md) `status` is `active` before enabling `automatic_tax` on its payments, and manage its registrations with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) using the `Stripe-Account` header (or Connect embedded components).
|
||||
|
||||
## Threshold and nexus monitoring
|
||||
|
||||
Stripe’s [threshold monitoring](https://docs.stripe.com/tax/monitoring.md) highlights *potential* registration obligations (no public API yet). Present it as information and route the decision to the user’s tax advisor. It’s up to the user to confirm whether registration is required; don’t tell them they must register.
|
||||
|
||||
## Registration safety
|
||||
|
||||
Guide, don’t advise. Never tell a user where they must register or whether they’re legally obligated. Recommend they consult their tax advisor to determine their obligations.
|
||||
|
||||
- The [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) can list, create, update, and expire registrations (set `expires_at` to expire; there’s no delete). A scheduled expiry can be changed, but an expiration that has taken effect is permanent (to collect again, the user adds a new registration), and there’s no pause. A head office address is required before adding a registration.
|
||||
- Adding a registration in Stripe records where the user is *already* registered. It doesn’t register them with the tax authority.
|
||||
- Creating or expiring a registration changes whether Stripe collects tax in that jurisdiction, but it doesn’t register or deregister the user with the tax authority. The user must do that separately. Prepare the change and have the user confirm it; never create or expire a registration automatically.
|
||||
|
||||
**How to register.** Present the paths that fit the user and let them (with their tax advisor) choose. Don’t pick for them.
|
||||
|
||||
- **Register themselves, then record it in Stripe**: the user registers with the tax authority, then records it with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) or the Dashboard. See [Register for tax](https://docs.stripe.com/tax/registering.md).
|
||||
- **Ask Stripe to register (US only)**: for remote, out-of-state sellers with no physical presence in the state; no public API, requires a Tax Complete subscription, and doesn’t support in-state registrations. See [Use Stripe to register](https://docs.stripe.com/tax/use-stripe-to-register.md).
|
||||
- **Register outside the US with Taxually**: no public API; done through the Taxually app. See [Register outside the US with Taxually](https://docs.stripe.com/tax/use-taxually-to-register.md).
|
||||
|
||||
**Reporting and filing.** Stripe Tax calculates and collects tax but doesn’t file returns unless the user is on a filing product. Point users to the Dashboard [tax reports and exports](https://docs.stripe.com/tax/reports.md) to reconcile and remit; filing runs through Stripe (US) or Taxually (non-US).
|
||||
|
||||
## If jurisdictions are unknown
|
||||
|
||||
Don’t guess which jurisdictions apply. Ask the user which states or countries they have customers in, then add a registration for each with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) or the Dashboard.
|
||||
|
||||
## If the region or tax type isn’t supported
|
||||
|
||||
Check the [supported countries list](https://docs.stripe.com/tax/supported-countries.md). If the jurisdiction isn’t listed, tell the user:
|
||||
|
||||
- Stripe Tax doesn’t support that region yet
|
||||
- They can collect tax manually using `tax_rates` on the subscription or invoice instead (not alongside `automatic_tax`; you can’t use both)
|
||||
- For unsupported tax types (customs duties, excise taxes), Stripe Tax doesn’t apply, so those are out of scope
|
||||
|
||||
Don’t attempt to approximate using a supported region as a proxy.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Treasury / Financial Accounts
|
||||
|
||||
## Table of contents
|
||||
|
||||
- v2 Financial Accounts API
|
||||
- Legacy v1 Treasury
|
||||
|
||||
## v2 Financial Accounts API
|
||||
|
||||
For embedded financial accounts (bank accounts, account and routing numbers, money movement), use the [v2 Financial Accounts API](https://docs.stripe.com/api/v2/core/vault/financial-accounts.md) (`POST /v2/core/vault/financial_accounts`). This is required for new integrations.
|
||||
|
||||
For Treasury for platforms concepts and guides, see the [Treasury for platforms overview](https://docs.stripe.com/treasury/connect.md).
|
||||
|
||||
## Legacy v1 Treasury
|
||||
|
||||
Don’t use the [v1 Treasury Financial Accounts API](https://docs.stripe.com/api/treasury/financial_accounts.md) (`POST /v1/treasury/financial_accounts`) for new integrations. Existing v1 integrations continue to work.
|
||||
@@ -10,3 +10,4 @@ uploads
|
||||
deploy-*.tgz
|
||||
scripts
|
||||
bg-video
|
||||
integrations
|
||||
|
||||
@@ -1,46 +1,23 @@
|
||||
DATABASE_URL="postgresql://s2yt:s2yt@localhost:5433/s2yt"
|
||||
# Songs2VID OSS is always self-hosted and payment-free.
|
||||
# Do not set Stripe, billing, credits, pricing, or S2VID_EDITION variables — they are unused.
|
||||
|
||||
# --- Required ---
|
||||
DATABASE_URL="postgresql://songs2vid:songs2vid@localhost:5433/songs2vid"
|
||||
REDIS_URL="redis://localhost:6380"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
# Generate with: openssl rand -base64 32
|
||||
NEXTAUTH_SECRET="replace-with-a-long-random-secret"
|
||||
# Used to encrypt YouTube OAuth tokens at rest (falls back to NEXTAUTH_SECRET if unset)
|
||||
# TOKEN_ENCRYPTION_KEY="replace-with-another-long-random-secret"
|
||||
# Server-side Google OAuth 2.0 credentials from Google Cloud Console
|
||||
|
||||
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
|
||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||
|
||||
# --- Optional ---
|
||||
# TOKEN_ENCRYPTION_KEY="replace-with-another-long-random-secret" # falls back to NEXTAUTH_SECRET
|
||||
UPLOAD_DIR="./uploads"
|
||||
# Open-source / self-hosted: unlimited video quota, API, and Pro features
|
||||
# S2VID_EDITION="selfhosted"
|
||||
# Admin API key for approving/rejecting quota extension requests (Bearer token)
|
||||
# ADMIN_API_KEY="your-secret-admin-key"
|
||||
# Pro users generate API keys in Dashboard → Settings (stored hashed; shown once)
|
||||
# Optional: override bundled ffmpeg-static binary (leave unset to use npm package)
|
||||
# FFMPEG_PATH="C:/path/to/ffmpeg.exe"
|
||||
# Public Gitea issues URL for community support links
|
||||
# FFMPEG_PATH="C:/path/to/ffmpeg.exe" # override bundled / image ffmpeg
|
||||
# S2VID_PORT=3000 # host port for docker-compose.yml (default 3000)
|
||||
# S2VID_IMAGE_TAG=latest # Docker Hub tag when using published image
|
||||
|
||||
NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com"
|
||||
NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2VID/songs2vid/issues"
|
||||
NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2VID"
|
||||
NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2vid"
|
||||
# Docusaurus docs — local: npm run docs:dev → http://localhost:3001
|
||||
# NEXT_PUBLIC_DOCS_URL="http://localhost:3001"
|
||||
# Production: NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com"
|
||||
# Stripe billing (Pro €5/mo + Free 1–15 credit top-ups @ €0.25)
|
||||
# STRIPE_SECRET_KEY="sk_test_..."
|
||||
# Prefer a restricted key (rk_...) with Checkout + Customers + Billing Portal + Webhooks only
|
||||
# STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||
# Optional Dashboard Price ID for Pro (otherwise inline price_data €5/mo is used)
|
||||
# STRIPE_PRO_PRICE_ID="price_..."
|
||||
# Collect VAT/GST via Stripe Tax AFTER adding Tax registrations in Dashboard:
|
||||
# STRIPE_AUTOMATIC_TAX="true"
|
||||
# Force local mock upgrades/grants without Stripe: BILLING_DEV_MOCK=true
|
||||
# Disable auto-mock when Stripe key missing: BILLING_DEV_MOCK=false
|
||||
# Point Stripe webhook to: POST /api/stripe/webhook
|
||||
# Events (required):
|
||||
# checkout.session.completed
|
||||
# invoice.payment_succeeded
|
||||
# invoice.payment_failed
|
||||
# customer.subscription.updated
|
||||
# customer.subscription.deleted
|
||||
# Dashboard: enable Customer portal (Settings → Billing → Customer portal)
|
||||
# Optional: Managed Payments (MoR) enable in Dashboard + set managed_payments on Checkout if you use it
|
||||
# Dev helpers: POST /api/dev/grant-credits { "action": "set-pro" | "free-top-up" | "grant-credits" }
|
||||
# Admin reset: POST /api/admin/reset-credits Authorization: Bearer $ADMIN_API_KEY
|
||||
|
||||
@@ -3,6 +3,8 @@ node_modules/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
tsconfig.tsbuildinfo
|
||||
website/node_modules/
|
||||
website/build/
|
||||
website/.docusaurus/
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Songs2VID
|
||||
|
||||
Payment-free, self-hosted software that turns cover art and audio into YouTube videos (FFmpeg + BullMQ). Full entitlements in every deployment — no plans, credits, Stripe, or paywall.
|
||||
|
||||
**Image:** `atakanozban/songs2vid:latest`
|
||||
**License:** MIT
|
||||
|
||||
**Docs:** https://docs.songs2vid.com
|
||||
**Source:** https://git.atakanozban.com/Songs2VID/songs2vid
|
||||
**Hosted SaaS (separate):** https://songs2vid.com
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Clone the repo (Compose + `.env.example`) from Gitea, or reuse the sample `docker-compose.yml`.
|
||||
2. Copy `.env.example` → `.env` and set:
|
||||
- `NEXTAUTH_SECRET`
|
||||
- `GOOGLE_CLIENT_ID`
|
||||
- `GOOGLE_CLIENT_SECRET`
|
||||
3. Enable **YouTube Data API v3** in Google Cloud and add OAuth redirect:
|
||||
- `http://localhost:3000/api/auth/callback/google` (or your public HTTPS origin)
|
||||
4. Run:
|
||||
|
||||
```bash
|
||||
docker pull atakanozban/songs2vid:latest
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open http://localhost:3000 → sign in with Google → dashboard.
|
||||
|
||||
No Stripe, billing, or edition environment variables are required. Watermarks are optional (no free-tier badge paywall).
|
||||
|
||||
## What you get
|
||||
|
||||
- Batch encode: cover art + audio → YouTube upload
|
||||
- Classic and art-track layouts, blur, typography, watermarks
|
||||
- Playlists, privacy, tags, resolutions
|
||||
- REST API (Dashboard → Settings → API key)
|
||||
- Always-on self-hosted entitlements
|
||||
|
||||
## Stack
|
||||
|
||||
Next.js 15 · PostgreSQL/Prisma · Redis/BullMQ · NextAuth (Google + YouTube) · FFmpeg
|
||||
|
||||
## Links
|
||||
|
||||
- Full documentation: https://docs.songs2vid.com
|
||||
- Getting started: https://docs.songs2vid.com/docs/getting-started
|
||||
- Environment variables: https://docs.songs2vid.com/docs/environment
|
||||
- REST API: https://docs.songs2vid.com/docs/api/overview
|
||||
- In-repo API notes: https://git.atakanozban.com/Songs2VID/songs2vid/src/branch/main/docs/api
|
||||
@@ -1,5 +1,5 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Hosted Songs2VID image (cloud plans + PAYG/Stripe). Do not bake selfhosted edition.
|
||||
# Songs2VID OSS self-hosted image.
|
||||
|
||||
FROM node:22-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Atakan Doğan Özban
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,119 +1,98 @@
|
||||
# Songs2VID
|
||||
# 🎬 Songs2VID
|
||||
|
||||
Create YouTube videos from an image and audio files.
|
||||
Payment-free, self-hosted software that turns cover art and audio into YouTube videos
|
||||
(FFmpeg encoding + BullMQ jobs). Every deployment has full entitlements — there are no
|
||||
plans, credits, purchases, subscriptions, paywalls, or Stripe.
|
||||
|
||||
## Stack
|
||||
Sign in with Google, open the dashboard, and create videos. Settings cover account,
|
||||
YouTube connection, and API keys only.
|
||||
|
||||
- Next.js 15 (App Router, TypeScript, Tailwind)
|
||||
- PostgreSQL + Prisma
|
||||
- Redis + BullMQ
|
||||
- NextAuth (Google OAuth 2.0 with YouTube scopes)
|
||||
- FFmpeg for video encoding
|
||||
- YouTube Data API v3
|
||||

|
||||
|
||||
## Editions
|
||||
## 🚀 Quick start (Docker)
|
||||
|
||||
| Edition | How | Limits |
|
||||
|---------|-----|--------|
|
||||
| **Hosted** (default) | Leave `S2VID_EDITION` unset | Free / Pro quotas and API gates |
|
||||
| **Self-hosted / OSS** | `S2VID_EDITION=selfhosted` | No video quota, no API rate caps; playlists + API unlocked |
|
||||
Published image: [`atakanozban/songs2vid:latest`](https://hub.docker.com/r/atakanozban/songs2vid)
|
||||
|
||||
Docker Compose sets `S2VID_EDITION=selfhosted` automatically.
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
1. Copy env and set Google OAuth + secrets:
|
||||
1. Clone this repo (or copy `docker-compose.yml` + `.env.example`).
|
||||
2. Copy `.env.example` to `.env` and set at least:
|
||||
- `NEXTAUTH_SECRET`
|
||||
- `GOOGLE_CLIENT_ID`
|
||||
- `GOOGLE_CLIENT_SECRET`
|
||||
3. In [Google Cloud Console](https://console.cloud.google.com/), enable **YouTube Data API v3**
|
||||
and add an OAuth redirect URI:
|
||||
- Local: `http://localhost:3000/api/auth/callback/google`
|
||||
- Production: `https://YOUR_DOMAIN/api/auth/callback/google`
|
||||
4. Start the stack (web + worker + Postgres + Redis):
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. Build and run the full stack (web, worker, Postgres, Redis):
|
||||
Compose uses the published image by default (`atakanozban/songs2vid:latest`) and can also
|
||||
build from this repo (`docker compose up -d --build`).
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
Open http://localhost:3000 → sign in → dashboard.
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000).
|
||||
No Stripe keys, billing env vars, or edition flags are required. Watermarks are optional;
|
||||
OSS does not force a free-tier SaaS badge paywall.
|
||||
|
||||
Add Google OAuth redirect URI: `http://localhost:3000/api/auth/callback/google` (or your public URL).
|
||||
## ✨ Features
|
||||
|
||||
## Local development
|
||||
- 📦 Batch: one cover (or per-track covers) + many audio files → YouTube uploads
|
||||
- 🖼️ Classic letterbox and art-track layouts (blur backgrounds, typography, fine-tuning)
|
||||
- 💧 Custom watermarks (badge, text, or logo) and curated or uploaded fonts
|
||||
- ▶️ YouTube playlists, privacy, tags, resolution, categories
|
||||
- 🔌 REST API for automation (keys under **Dashboard → Settings → API key**)
|
||||
- 🤖 n8n community node [`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid) + optional `webhookUrl` callbacks
|
||||
- 🔓 Always-on self-hosted entitlements (no quota paywall)
|
||||
|
||||
1. Copy environment variables:
|
||||
## 📚 API documentation
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
- In-repo: [docs/api/overview.md](./docs/api/overview.md), [docs/api/endpoints.md](./docs/api/endpoints.md), [docs/n8n.md](./docs/n8n.md)
|
||||
- Live site: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview)
|
||||
- Machine-readable discovery: `GET /api/v1` (no auth; no billing routes)
|
||||
- Node package source: [`integrations/n8n`](./integrations/n8n/)
|
||||
|
||||
2. Start Postgres and Redis only:
|
||||
## 🛠️ Local development
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
3. Install dependencies and push the schema:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # then edit secrets / Google OAuth
|
||||
npm install
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
4. Configure Google OAuth in [Google Cloud Console](https://console.cloud.google.com/):
|
||||
- Enable YouTube Data API v3
|
||||
- Create OAuth 2.0 credentials
|
||||
- Add redirect URI: `http://localhost:3000/api/auth/callback/google`
|
||||
- Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in `.env`
|
||||
|
||||
5. Optional for unlimited local use: `S2VID_EDITION=selfhosted` in `.env`
|
||||
|
||||
6. Start the app, worker, and docs:
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
npm run dev:all
|
||||
```
|
||||
|
||||
- App: http://localhost:3000
|
||||
- Docs: http://localhost:3001
|
||||
- `dev:all` runs Next.js and the BullMQ worker
|
||||
|
||||
Or run them separately with `npm run dev`, `npm run worker`, and `npm run docs:dev`.
|
||||
## ⚙️ Environment
|
||||
|
||||
## Authentication
|
||||
See [`.env.example`](./.env.example) for the required and optional variables. Matching guide:
|
||||
[docs.songs2vid.com/docs/environment](https://docs.songs2vid.com/docs/environment).
|
||||
|
||||
Users sign in with Google via OAuth 2.0. The app handles YouTube channel connection automatically end users never enter API keys for YouTube.
|
||||
## ⚖️ Legal (self-hosted)
|
||||
|
||||
## Self-hosted edition
|
||||
In-app **Privacy** and **Terms** pages describe self-hosted responsibility: the operator of
|
||||
each instance controls data and access; this software does not process payments. They are not
|
||||
the paid cloud terms for [songs2vid.com](https://songs2vid.com).
|
||||
|
||||
Set `S2VID_EDITION=selfhosted` (Docker Compose does this by default):
|
||||
## 🧱 Stack
|
||||
|
||||
- Unlimited video allowance
|
||||
- API access and playlists
|
||||
- Art-track layouts with blur backgrounds and fine-tuning (padding, gaps, text offsets)
|
||||
- Custom watermarks (text/logo), curated or uploaded fonts
|
||||
- Next.js 15, TypeScript, Tailwind
|
||||
- PostgreSQL and Prisma
|
||||
- Redis and BullMQ
|
||||
- NextAuth with Google OAuth (YouTube scopes)
|
||||
- FFmpeg
|
||||
- YouTube Data API v3
|
||||
|
||||
Composition details: see `website/docs/video-editing.md` (or open Video editing in the docs site after `npm run docs:dev`).
|
||||
## 🔗 Links
|
||||
|
||||
## Scripts
|
||||
- Docs: [docs.songs2vid.com](https://docs.songs2vid.com)
|
||||
- Docker Hub: [atakanozban/songs2vid](https://hub.docker.com/r/atakanozban/songs2vid)
|
||||
- Source: [git.atakanozban.com/Songs2VID](https://git.atakanozban.com/Songs2VID)
|
||||
- Hosted SaaS (separate product): [songs2vid.com](https://songs2vid.com)
|
||||
|
||||
- `npm run dev` - Next.js dev server
|
||||
- `npm run worker` - Background job processor
|
||||
- `npm run dev:all` - App (3000) + worker + docs (3001) together
|
||||
- `npm run db:push` - Push Prisma schema to database
|
||||
- `npm run build` - Production build
|
||||
- `npm run docs:dev` - Documentation site only (Docusaurus on port 3001)
|
||||
## 📄 License
|
||||
|
||||
## Documentation
|
||||
|
||||
Docs live in `website/` (Docusaurus). API reference:
|
||||
|
||||
```bash
|
||||
npm run docs:dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3001/docs/api/overview](http://localhost:3001/docs/api/overview).
|
||||
|
||||
The app “Full API docs” link and `/dashboard/api-docs` redirect use local docs when `NEXTAUTH_URL` is localhost (or set `NEXT_PUBLIC_DOCS_URL`).
|
||||
## Source
|
||||
|
||||
- Gitea: https://git.atakanozban.com/Songs2VID
|
||||
- Docker Hub: https://hub.docker.com/r/atakanozban/songs2vid
|
||||
[MIT](./LICENSE)
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
async function requireApiKeyAccess() {
|
||||
@@ -8,15 +7,6 @@ async function requireApiKeyAccess() {
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
|
||||
}
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "API keys are available on the Pro plan only" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
return { error: null, user };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
createQuotaExtensionRequest,
|
||||
EXTENSION_KIND,
|
||||
getQuotaExtensionUsage,
|
||||
} from "@/lib/quota-extensions";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return NextResponse.json({ error: "Pro plan required" }, { status: 403 });
|
||||
}
|
||||
|
||||
const usage = await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT);
|
||||
return NextResponse.json(usage);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return NextResponse.json({ error: "Pro plan required" }, { status: 403 });
|
||||
}
|
||||
|
||||
let message = "";
|
||||
try {
|
||||
const body = await req.json();
|
||||
if (typeof body.message === "string") message = body.message;
|
||||
} catch {
|
||||
// optional body
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createQuotaExtensionRequest(
|
||||
user.id,
|
||||
message,
|
||||
EXTENSION_KIND.API_RATE_LIMIT,
|
||||
);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to submit request";
|
||||
return NextResponse.json({ error: msg }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export async function GET() {
|
||||
@@ -8,13 +7,6 @@ export async function GET() {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return NextResponse.json(
|
||||
{ error: "API rate limits are available on the Pro plan only" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const status = await getApiRateLimitStatus(user.id);
|
||||
return NextResponse.json(status);
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
|
||||
|
||||
/**
|
||||
* Stripe Customer Portal update payment method, view invoices, cancel (per Dashboard config).
|
||||
* @see https://docs.stripe.com/customer-management/integrate-customer-portal
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (isBillingDevMock()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Billing portal is unavailable in mock mode." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isStripeConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
select: { stripeCustomerId: true },
|
||||
});
|
||||
|
||||
if (!dbUser.stripeCustomerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No Stripe customer on this account. Complete a checkout first." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
|
||||
const stripe = getStripe();
|
||||
|
||||
try {
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: dbUser.stripeCustomerId,
|
||||
return_url: `${origin}/dashboard/settings`,
|
||||
});
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Could not open billing portal";
|
||||
console.error("[stripe] billing portal failed", err);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
message.includes("No configuration") || message.includes("portal")
|
||||
? "Billing portal is not configured in Stripe Dashboard yet. Enable it under Settings → Billing → Customer portal."
|
||||
: message,
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { downgradeToFreePlan } from "@/lib/billing";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
|
||||
|
||||
type CancelWhen = "immediate" | "period_end";
|
||||
type CancelAction = "cancel" | "resume";
|
||||
|
||||
function periodEndIso(sub: {
|
||||
cancel_at?: number | null;
|
||||
current_period_end?: number;
|
||||
items?: { data?: Array<{ current_period_end?: number }> };
|
||||
}): string | null {
|
||||
const fromItem = sub.items?.data?.[0]?.current_period_end;
|
||||
const ts = sub.cancel_at ?? fromItem ?? sub.current_period_end ?? null;
|
||||
return ts ? new Date(ts * 1000).toISOString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel Pro subscription via Stripe.
|
||||
* Body: { when: "immediate" | "period_end" } or { action: "resume" }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (user.plan !== "PREMIUM") {
|
||||
return NextResponse.json({ error: "No active subscription to cancel" }, { status: 400 });
|
||||
}
|
||||
|
||||
let action: CancelAction = "cancel";
|
||||
let when: CancelWhen = "immediate";
|
||||
try {
|
||||
const body = await req.json();
|
||||
if (body?.action === "resume") action = "resume";
|
||||
if (body?.when === "period_end" || body?.when === "immediate") when = body.when;
|
||||
} catch {
|
||||
// empty body → default immediate cancel (legacy)
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
select: { stripeSubscriptionId: true },
|
||||
});
|
||||
|
||||
const subId = dbUser.stripeSubscriptionId;
|
||||
const isMockSub = !subId || subId.startsWith("dev_sub_");
|
||||
const useStripe =
|
||||
Boolean(subId) &&
|
||||
!isMockSub &&
|
||||
isStripeConfigured() &&
|
||||
!isBillingDevMock();
|
||||
|
||||
// Resume (undo cancel-at-period-end)
|
||||
if (action === "resume") {
|
||||
if (useStripe && subId) {
|
||||
try {
|
||||
const stripe = getStripe();
|
||||
const sub = await stripe.subscriptions.update(subId, {
|
||||
cancel_at_period_end: false,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action: "resume",
|
||||
cancelAtPeriodEnd: false,
|
||||
currentPeriodEnd: periodEndIso(sub as { current_period_end?: number }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[stripe] resume subscription failed", err);
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Failed to keep subscription" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action: "resume",
|
||||
cancelAtPeriodEnd: false,
|
||||
mocked: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel
|
||||
if (when === "period_end") {
|
||||
if (useStripe && subId) {
|
||||
try {
|
||||
const stripe = getStripe();
|
||||
const sub = await stripe.subscriptions.update(subId, {
|
||||
cancel_at_period_end: true,
|
||||
});
|
||||
const endsAt = periodEndIso(
|
||||
sub as {
|
||||
cancel_at?: number | null;
|
||||
current_period_end?: number;
|
||||
items?: { data?: Array<{ current_period_end?: number }> };
|
||||
},
|
||||
);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
when: "period_end",
|
||||
cancelAtPeriodEnd: true,
|
||||
endsAt,
|
||||
// Keep Pro until Stripe sends customer.subscription.deleted
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[stripe] cancel at period end failed", err);
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Failed to schedule cancellation" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mock / no Stripe id: schedule locally by leaving Pro and reporting next month
|
||||
const endsAt = new Date();
|
||||
endsAt.setMonth(endsAt.getMonth() + 1);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
when: "period_end",
|
||||
cancelAtPeriodEnd: true,
|
||||
endsAt: endsAt.toISOString(),
|
||||
mocked: true,
|
||||
});
|
||||
}
|
||||
|
||||
// immediate
|
||||
if (useStripe && subId) {
|
||||
try {
|
||||
const stripe = getStripe();
|
||||
await stripe.subscriptions.cancel(subId);
|
||||
} catch (err) {
|
||||
console.error("[stripe] cancel subscription failed", err);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Stripe could not cancel the subscription. Your plan was not changed.",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await downgradeToFreePlan(user.id);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
when: "immediate",
|
||||
cancelAtPeriodEnd: false,
|
||||
plan: "FREE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Current Stripe subscription cancel status for settings UI. */
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (user.plan !== "PREMIUM") {
|
||||
return NextResponse.json({
|
||||
plan: user.plan,
|
||||
cancelAtPeriodEnd: false,
|
||||
endsAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
select: { stripeSubscriptionId: true },
|
||||
});
|
||||
|
||||
const subId = dbUser.stripeSubscriptionId;
|
||||
if (
|
||||
!subId ||
|
||||
subId.startsWith("dev_sub_") ||
|
||||
!isStripeConfigured() ||
|
||||
isBillingDevMock()
|
||||
) {
|
||||
return NextResponse.json({
|
||||
plan: "PREMIUM",
|
||||
cancelAtPeriodEnd: false,
|
||||
endsAt: null,
|
||||
mocked: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = getStripe();
|
||||
const sub = await stripe.subscriptions.retrieve(subId);
|
||||
return NextResponse.json({
|
||||
plan: "PREMIUM",
|
||||
status: sub.status,
|
||||
cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end),
|
||||
endsAt: periodEndIso(
|
||||
sub as {
|
||||
cancel_at?: number | null;
|
||||
current_period_end?: number;
|
||||
items?: { data?: Array<{ current_period_end?: number }> };
|
||||
},
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[stripe] retrieve subscription failed", err);
|
||||
return NextResponse.json({
|
||||
plan: "PREMIUM",
|
||||
cancelAtPeriodEnd: false,
|
||||
endsAt: null,
|
||||
error: "Could not load subscription status from Stripe",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CREDIT_PRICE_CENTS,
|
||||
FREE_EXTRA_CREDITS_MAX,
|
||||
FREE_TOP_UP_MAX,
|
||||
FREE_TOP_UP_MIN,
|
||||
formatCreditPrice,
|
||||
validateFreeTopUpAmount,
|
||||
} from "@/lib/credits";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
checkoutTaxAndReferenceOptions,
|
||||
ensureStripeCustomer,
|
||||
priceDataTaxFields,
|
||||
productDataWithTaxCode,
|
||||
} from "@/lib/stripe-checkout";
|
||||
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
|
||||
import { grantExtraCredits } from "@/lib/billing";
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
extraCredits: true,
|
||||
plan: true,
|
||||
monthlyCredits: true,
|
||||
videosUsed: true,
|
||||
},
|
||||
});
|
||||
|
||||
const purchases = await prisma.creditPurchase.findMany({
|
||||
where: { userId: user.id, status: "COMPLETED" },
|
||||
orderBy: { completedAt: "desc" },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
const cap = FREE_EXTRA_CREDITS_MAX;
|
||||
const room = Math.max(0, cap - dbUser.extraCredits);
|
||||
|
||||
return NextResponse.json({
|
||||
extraCredits: dbUser.extraCredits,
|
||||
videoCredits: dbUser.extraCredits,
|
||||
topUpMin: FREE_TOP_UP_MIN,
|
||||
topUpMax: FREE_TOP_UP_MAX,
|
||||
priceCentsPerCredit: CREDIT_PRICE_CENTS,
|
||||
priceLabelPerCredit: formatCreditPrice(1),
|
||||
creditBalanceMax: cap,
|
||||
monthlyCredits: dbUser.monthlyCredits,
|
||||
creditsUsed: dbUser.videosUsed,
|
||||
plan: dbUser.plan,
|
||||
stripeConfigured: isStripeConfigured() || isBillingDevMock(),
|
||||
room,
|
||||
atExtraCap: room === 0,
|
||||
purchases: purchases.map((p) => ({
|
||||
id: p.id,
|
||||
credits: p.credits,
|
||||
amountCents: p.amountCents,
|
||||
completedAt: p.completedAt?.toISOString() ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/** Free tier: top up 1–15 extra credits (price = credits × €0.25). */
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (user.plan === "PREMIUM") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Credit top-ups are for the Free plan. Pro includes 50 monthly credits; existing extras never expire.",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
let credits = 0;
|
||||
try {
|
||||
const body = await req.json();
|
||||
credits = Math.floor(Number(body.credits));
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
select: { extraCredits: true, email: true, monthlyCredits: true, videosUsed: true, bonusQuota: true },
|
||||
});
|
||||
|
||||
const monthlyRemaining = Math.max(
|
||||
0,
|
||||
dbUser.monthlyCredits + dbUser.bonusQuota - dbUser.videosUsed,
|
||||
);
|
||||
const validation = validateFreeTopUpAmount(credits, dbUser.extraCredits, monthlyRemaining);
|
||||
if (!validation.ok) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const { credits: amount, amountCents } = validation;
|
||||
|
||||
if (isBillingDevMock()) {
|
||||
const purchase = await prisma.creditPurchase.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
credits: amount,
|
||||
amountCents,
|
||||
status: "COMPLETED",
|
||||
completedAt: new Date(),
|
||||
stripeSessionId: `dev_topup_${Date.now()}`,
|
||||
},
|
||||
});
|
||||
await grantExtraCredits(user.id, amount);
|
||||
return NextResponse.json({
|
||||
mocked: true,
|
||||
granted: amount,
|
||||
amountCents,
|
||||
purchaseId: purchase.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isStripeConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
|
||||
const purchase = await prisma.creditPurchase.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
credits: amount,
|
||||
amountCents,
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const stripe = getStripe();
|
||||
const customerId = await ensureStripeCustomer(user.id, dbUser.email);
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
quantity: amount,
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
unit_amount: CREDIT_PRICE_CENTS,
|
||||
...priceDataTaxFields(),
|
||||
product_data: productDataWithTaxCode(
|
||||
"Songs2VID video credit",
|
||||
`1 credit = 1 video upload (${formatCreditPrice(1)} each)`,
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
type: "free_top_up",
|
||||
userId: user.id,
|
||||
purchaseId: purchase.id,
|
||||
credits: String(amount),
|
||||
},
|
||||
...checkoutTaxAndReferenceOptions(user.id),
|
||||
success_url: `${origin}/dashboard/settings?credits=success`,
|
||||
cancel_url: `${origin}/dashboard/settings?credits=cancelled`,
|
||||
});
|
||||
|
||||
await prisma.creditPurchase.update({
|
||||
where: { id: purchase.id },
|
||||
data: { stripeSessionId: session.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: session.url, sessionId: session.id });
|
||||
} catch (err) {
|
||||
await prisma.creditPurchase.update({
|
||||
where: { id: purchase.id },
|
||||
data: { status: "FAILED" },
|
||||
});
|
||||
const message = err instanceof Error ? err.message : "Checkout failed";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createQuotaExtensionRequest, getQuotaExtensionUsage } from "@/lib/quota-extensions";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const usage = await getQuotaExtensionUsage(user.id);
|
||||
return NextResponse.json(usage);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let message = "";
|
||||
try {
|
||||
const body = await req.json();
|
||||
if (typeof body.message === "string") message = body.message;
|
||||
} catch {
|
||||
// optional body
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createQuotaExtensionRequest(user.id, message);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to submit request";
|
||||
return NextResponse.json({ error: msg }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { activateProPlan } from "@/lib/billing";
|
||||
import { PRO_PRICE_CENTS } from "@/lib/credits";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
checkoutTaxAndReferenceOptions,
|
||||
ensureStripeCustomer,
|
||||
priceDataTaxFields,
|
||||
productDataWithTaxCode,
|
||||
} from "@/lib/stripe-checkout";
|
||||
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
|
||||
|
||||
/** Start Pro subscription Checkout (€5/mo) or mock-upgrade in development. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (user.plan === "PREMIUM") {
|
||||
return NextResponse.json({ error: "Already on Pro." }, { status: 400 });
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
select: { email: true, stripeCustomerId: true },
|
||||
});
|
||||
|
||||
if (isBillingDevMock()) {
|
||||
await activateProPlan(user.id, {
|
||||
stripeCustomerId: dbUser.stripeCustomerId,
|
||||
stripeSubscriptionId: `dev_sub_${Date.now()}`,
|
||||
cardLast4: "4242",
|
||||
});
|
||||
return NextResponse.json({ mocked: true, plan: "PREMIUM" });
|
||||
}
|
||||
|
||||
if (!isStripeConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
|
||||
const stripe = getStripe();
|
||||
const customerId = await ensureStripeCustomer(user.id, dbUser.email);
|
||||
const priceId = process.env.STRIPE_PRO_PRICE_ID;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
customer: customerId,
|
||||
line_items: priceId
|
||||
? [{ price: priceId, quantity: 1 }]
|
||||
: [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
unit_amount: PRO_PRICE_CENTS,
|
||||
recurring: { interval: "month" },
|
||||
...priceDataTaxFields(),
|
||||
product_data: productDataWithTaxCode(
|
||||
"Songs2VID Pro",
|
||||
"50 video credits / month · 1080p · API access · Includes Custom Branding, Watermark Positioning, and Bulk Image Matching",
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
type: "pro_subscription",
|
||||
userId: user.id,
|
||||
},
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
type: "pro_subscription",
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
...checkoutTaxAndReferenceOptions(user.id),
|
||||
success_url: `${origin}/dashboard/settings?upgrade=success`,
|
||||
cancel_url: `${origin}/dashboard/settings?upgrade=cancelled`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: session.url, sessionId: session.id });
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
approveQuotaExtensionRequest,
|
||||
rejectQuotaExtensionRequest,
|
||||
} from "@/lib/quota-extensions";
|
||||
|
||||
function isAuthorized(req: NextRequest) {
|
||||
const key = process.env.ADMIN_API_KEY;
|
||||
if (!key) return false;
|
||||
const auth = req.headers.get("authorization");
|
||||
return auth === `Bearer ${key}`;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
if (!isAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
let action: "approve" | "reject" = "approve";
|
||||
let bonusQuota: number | undefined;
|
||||
let bonusRateLimit: number | undefined;
|
||||
let adminNote: string | undefined;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
if (body.action === "reject") action = "reject";
|
||||
if (typeof body.bonusQuota === "number" && Number.isFinite(body.bonusQuota)) {
|
||||
bonusQuota = body.bonusQuota;
|
||||
}
|
||||
if (typeof body.bonusRateLimit === "number" && Number.isFinite(body.bonusRateLimit)) {
|
||||
bonusRateLimit = body.bonusRateLimit;
|
||||
}
|
||||
if (typeof body.adminNote === "string") adminNote = body.adminNote;
|
||||
} catch {
|
||||
// defaults
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === "reject") {
|
||||
await rejectQuotaExtensionRequest(id, adminNote);
|
||||
} else {
|
||||
await approveQuotaExtensionRequest(id, { bonusQuota, bonusRateLimit, adminNote });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to process request";
|
||||
return NextResponse.json({ error: msg }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { adminResetUserCredits } from "@/lib/billing";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Admin utility: reset / adjust a user's credits.
|
||||
* Authorization: Bearer ${ADMIN_API_KEY}
|
||||
*
|
||||
* Body: { userId | email, plan?, monthlyCredits?, creditsUsed?, extraCredits?, clearFreeTopUp? }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
if (!adminKey) {
|
||||
return NextResponse.json({ error: "Admin API not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
const auth = req.headers.get("authorization");
|
||||
if (auth !== `Bearer ${adminKey}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: {
|
||||
userId?: string;
|
||||
email?: string;
|
||||
plan?: "FREE" | "PREMIUM";
|
||||
monthlyCredits?: number;
|
||||
creditsUsed?: number;
|
||||
extraCredits?: number;
|
||||
clearFreeTopUp?: boolean;
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
let userId = body.userId;
|
||||
if (!userId && body.email) {
|
||||
const found = await prisma.user.findUnique({ where: { email: body.email } });
|
||||
if (!found) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
userId = found.id;
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Provide userId or email" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await adminResetUserCredits(userId, {
|
||||
plan: body.plan,
|
||||
monthlyCredits: body.monthlyCredits,
|
||||
creditsUsed: body.creditsUsed,
|
||||
extraCredits: body.extraCredits,
|
||||
clearFreeTopUp: body.clearFreeTopUp,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
user: {
|
||||
id: updated.id,
|
||||
email: updated.email,
|
||||
plan: updated.plan,
|
||||
monthlyCredits: updated.monthlyCredits,
|
||||
creditsUsed: updated.videosUsed,
|
||||
extraCredits: updated.extraCredits,
|
||||
freeTopUpPurchased: updated.freeTopUpPurchased,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
activateProPlan,
|
||||
adminResetUserCredits,
|
||||
grantExtraCredits,
|
||||
markFreeTopUpPurchased,
|
||||
} from "@/lib/billing";
|
||||
import { FREE_TOP_UP_CREDITS } from "@/lib/credits";
|
||||
import { isBillingDevMock } from "@/lib/stripe";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
/**
|
||||
* Local-only billing helpers (no Stripe CLI required).
|
||||
* Enabled when NODE_ENV=development (unless BILLING_DEV_MOCK=false) or BILLING_DEV_MOCK=true.
|
||||
*/
|
||||
function assertDev() {
|
||||
if (!isBillingDevMock()) {
|
||||
return NextResponse.json({ error: "Not available outside development mock mode" }, { status: 404 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const blocked = assertDev();
|
||||
if (blocked) return blocked;
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: {
|
||||
action?: string;
|
||||
credits?: number;
|
||||
plan?: "FREE" | "PREMIUM";
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const action = body.action ?? "grant-credits";
|
||||
|
||||
switch (action) {
|
||||
case "grant-credits": {
|
||||
const credits = Math.floor(Number(body.credits ?? FREE_TOP_UP_CREDITS));
|
||||
if (!Number.isFinite(credits) || credits <= 0) {
|
||||
return NextResponse.json({ error: "Invalid credits" }, { status: 400 });
|
||||
}
|
||||
await grantExtraCredits(user.id, credits);
|
||||
return NextResponse.json({ ok: true, granted: credits });
|
||||
}
|
||||
case "free-top-up": {
|
||||
await markFreeTopUpPurchased(user.id);
|
||||
return NextResponse.json({ ok: true, granted: FREE_TOP_UP_CREDITS });
|
||||
}
|
||||
case "set-pro": {
|
||||
await activateProPlan(user.id, {
|
||||
stripeSubscriptionId: `dev_sub_${Date.now()}`,
|
||||
cardLast4: "4242",
|
||||
});
|
||||
return NextResponse.json({ ok: true, plan: "PREMIUM" });
|
||||
}
|
||||
case "set-free": {
|
||||
await adminResetUserCredits(user.id, { plan: "FREE", creditsUsed: 0 });
|
||||
return NextResponse.json({ ok: true, plan: "FREE" });
|
||||
}
|
||||
case "reset-cycle": {
|
||||
await adminResetUserCredits(user.id, {
|
||||
plan: body.plan,
|
||||
creditsUsed: 0,
|
||||
});
|
||||
return NextResponse.json({ ok: true, reset: true });
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience GET for quick browser testing: /api/dev/grant-credits?action=set-pro */
|
||||
export async function GET(req: NextRequest) {
|
||||
const blocked = assertDev();
|
||||
if (blocked) return blocked;
|
||||
|
||||
const action = req.nextUrl.searchParams.get("action") ?? "grant-credits";
|
||||
const credits = Number(req.nextUrl.searchParams.get("credits") ?? FREE_TOP_UP_CREDITS);
|
||||
|
||||
const fake = new NextRequest(req.url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", cookie: req.headers.get("cookie") ?? "" },
|
||||
body: JSON.stringify({ action, credits }),
|
||||
});
|
||||
return POST(fake);
|
||||
}
|
||||
@@ -1,25 +1,33 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import { CURATED_FONTS, isCuratedFontKey } from "@/lib/fonts";
|
||||
import { resolveCuratedFontPath } from "@/lib/fonts-server";
|
||||
import { CURATED_FONTS, isCuratedFontKey, SYSTEM_FONT } from "@/lib/fonts";
|
||||
import { resolveCuratedFontPath, resolveSystemFontPath } from "@/lib/fonts-server";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ key: string }> },
|
||||
) {
|
||||
const { key } = await params;
|
||||
if (!isCuratedFontKey(key)) {
|
||||
const fontPath =
|
||||
key === SYSTEM_FONT.key
|
||||
? resolveSystemFontPath()
|
||||
: isCuratedFontKey(key)
|
||||
? resolveCuratedFontPath(key)
|
||||
: null;
|
||||
if (!fontPath) {
|
||||
return NextResponse.json({ error: "Unknown font" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fontPath = resolveCuratedFontPath(key);
|
||||
try {
|
||||
const buf = await fs.readFile(fontPath);
|
||||
const meta = CURATED_FONTS.find((f) => f.key === key)!;
|
||||
const fileName =
|
||||
key === SYSTEM_FONT.key
|
||||
? SYSTEM_FONT.file
|
||||
: CURATED_FONTS.find((f) => f.key === key)!.file;
|
||||
return new NextResponse(buf, {
|
||||
headers: {
|
||||
"Content-Type": "font/ttf",
|
||||
"Content-Disposition": `inline; filename="${meta.file}"`,
|
||||
"Content-Disposition": `inline; filename="${fileName}"`,
|
||||
"Cache-Control": "public, max-age=86400, immutable",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { createVideoJob } from "@/lib/jobs/create-job";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/session";
|
||||
@@ -17,12 +16,8 @@ export async function POST(req: NextRequest) {
|
||||
const job = await createVideoJob(user, body);
|
||||
return NextResponse.json({ jobId: job.id });
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) {
|
||||
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Failed to create job";
|
||||
const status = message.includes("Quota exceeded") ? 403 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import {
|
||||
activateProPlan,
|
||||
applyMonthlyCreditRenewal,
|
||||
downgradeToFreePlan,
|
||||
grantExtraCredits,
|
||||
} from "@/lib/billing";
|
||||
import { monthlyCreditsForPlan } from "@/lib/credits";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getNextMonthlyQuotaReset } from "@/lib/plans";
|
||||
import { getStripe } from "@/lib/stripe";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/** Stripe API 2025+: subscription lives on parent.subscription_details, not invoice.subscription. */
|
||||
function subscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
||||
const legacy = (invoice as Stripe.Invoice & {
|
||||
subscription?: string | Stripe.Subscription | null;
|
||||
}).subscription;
|
||||
if (typeof legacy === "string" && legacy) return legacy;
|
||||
if (legacy && typeof legacy === "object" && "id" in legacy && legacy.id) {
|
||||
return String(legacy.id);
|
||||
}
|
||||
|
||||
const parent = (
|
||||
invoice as Stripe.Invoice & {
|
||||
parent?: {
|
||||
type?: string | null;
|
||||
subscription_details?: { subscription?: string | Stripe.Subscription | null } | null;
|
||||
} | null;
|
||||
}
|
||||
).parent;
|
||||
|
||||
const fromParent = parent?.subscription_details?.subscription;
|
||||
if (typeof fromParent === "string" && fromParent) return fromParent;
|
||||
if (fromParent && typeof fromParent === "object" && "id" in fromParent && fromParent.id) {
|
||||
return String(fromParent.id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fulfillFreeTopUp(session: Stripe.Checkout.Session) {
|
||||
const purchaseId = session.metadata?.purchaseId;
|
||||
const userId = session.metadata?.userId;
|
||||
if (!purchaseId || !userId) {
|
||||
console.error("[stripe] free_top_up missing metadata", session.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const purchase = await prisma.creditPurchase.findUnique({ where: { id: purchaseId } });
|
||||
if (!purchase || purchase.userId !== userId) {
|
||||
throw new Error(`Purchase not found: ${purchaseId}`);
|
||||
}
|
||||
if (purchase.status === "COMPLETED") return;
|
||||
|
||||
await grantExtraCredits(userId, purchase.credits);
|
||||
|
||||
await prisma.creditPurchase.update({
|
||||
where: { id: purchaseId },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
completedAt: new Date(),
|
||||
stripeSessionId: session.id,
|
||||
stripePaymentIntentId:
|
||||
typeof session.payment_intent === "string"
|
||||
? session.payment_intent
|
||||
: session.payment_intent?.id ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fulfillProCheckout(session: Stripe.Checkout.Session) {
|
||||
const userId = session.metadata?.userId;
|
||||
if (!userId) {
|
||||
console.error("[stripe] pro_subscription missing userId", session.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionId =
|
||||
typeof session.subscription === "string"
|
||||
? session.subscription
|
||||
: session.subscription?.id ?? null;
|
||||
|
||||
const customerId =
|
||||
typeof session.customer === "string" ? session.customer : session.customer?.id ?? null;
|
||||
|
||||
await activateProPlan(userId, {
|
||||
stripeCustomerId: customerId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) {
|
||||
const subscriptionId = subscriptionIdFromInvoice(invoice);
|
||||
if (!subscriptionId) {
|
||||
console.warn("[stripe] invoice.payment_succeeded without subscription id", invoice.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip the first invoice if checkout already activated Pro (billing_reason subscription_create)
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { stripeSubscriptionId: subscriptionId },
|
||||
});
|
||||
if (!user) {
|
||||
// Fallback: metadata on subscription via Stripe retrieve is optional; try customer
|
||||
const customerId =
|
||||
typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
|
||||
if (!customerId) return;
|
||||
const byCustomer = await prisma.user.findFirst({
|
||||
where: { stripeCustomerId: customerId },
|
||||
});
|
||||
if (!byCustomer) return;
|
||||
await applyMonthlyCreditRenewal(byCustomer.id, {
|
||||
plan: "PREMIUM",
|
||||
newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"),
|
||||
quotaResetAt: getNextMonthlyQuotaReset(),
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: byCustomer.id },
|
||||
data: {
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
subscribedAt: byCustomer.subscribedAt ?? new Date(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Renewal: rollover unused + new monthly, trim to MAX_CREDIT_CAP (30)
|
||||
await applyMonthlyCreditRenewal(user.id, {
|
||||
plan: "PREMIUM",
|
||||
newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"),
|
||||
quotaResetAt: getNextMonthlyQuotaReset(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
});
|
||||
if (!user) return;
|
||||
await downgradeToFreePlan(user.id);
|
||||
}
|
||||
|
||||
/** Stripe retries failed invoices; when status is unpaid/canceled, revoke Pro access. */
|
||||
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
|
||||
const terminal = new Set(["canceled", "unpaid", "incomplete_expired"]);
|
||||
if (!terminal.has(subscription.status)) return;
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
});
|
||||
if (!user || user.plan !== "PREMIUM") return;
|
||||
await downgradeToFreePlan(user.id);
|
||||
}
|
||||
|
||||
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
|
||||
const subscriptionId = subscriptionIdFromInvoice(invoice);
|
||||
if (!subscriptionId) return;
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ stripeSubscriptionId: subscriptionId },
|
||||
...(typeof invoice.customer === "string"
|
||||
? [{ stripeCustomerId: invoice.customer }]
|
||||
: invoice.customer?.id
|
||||
? [{ stripeCustomerId: invoice.customer.id }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
});
|
||||
if (!user) return;
|
||||
|
||||
// Soft signal only Stripe Smart Retries continue. Do not downgrade on first failure.
|
||||
console.warn(
|
||||
"[stripe] invoice.payment_failed",
|
||||
invoice.id,
|
||||
"user",
|
||||
user.id,
|
||||
"attempt",
|
||||
invoice.attempt_count,
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!webhookSecret) {
|
||||
return NextResponse.json({ error: "Webhook not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
const signature = req.headers.get("stripe-signature");
|
||||
if (!signature) {
|
||||
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
|
||||
}
|
||||
|
||||
const rawBody = await req.text();
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
const stripe = getStripe();
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Invalid signature";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
if (session.payment_status === "paid" || session.status === "complete") {
|
||||
const type = session.metadata?.type;
|
||||
if (type === "pro_subscription" || session.mode === "subscription") {
|
||||
await fulfillProCheckout(session);
|
||||
} else if (type === "free_top_up") {
|
||||
await fulfillFreeTopUp(session);
|
||||
} else if (session.metadata?.purchaseId) {
|
||||
// Legacy PAYG purchases: grant metadata credits as extras
|
||||
const userId = session.metadata.userId;
|
||||
const credits = Number(session.metadata.credits);
|
||||
const purchaseId = session.metadata.purchaseId;
|
||||
if (userId && purchaseId && Number.isFinite(credits) && credits > 0) {
|
||||
const purchase = await prisma.creditPurchase.findUnique({
|
||||
where: { id: purchaseId },
|
||||
});
|
||||
if (purchase && purchase.status !== "COMPLETED") {
|
||||
await grantExtraCredits(userId, credits);
|
||||
await prisma.creditPurchase.update({
|
||||
where: { id: purchaseId },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
completedAt: new Date(),
|
||||
stripeSessionId: session.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (event.type === "invoice.payment_succeeded") {
|
||||
await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice);
|
||||
} else if (event.type === "invoice.payment_failed") {
|
||||
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
|
||||
} else if (event.type === "customer.subscription.updated") {
|
||||
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
|
||||
} else if (event.type === "customer.subscription.deleted") {
|
||||
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[stripe] webhook handler error", event.type, event.id, err);
|
||||
return NextResponse.json({ error: "Handler failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
@@ -28,14 +27,11 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, {
|
||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType, {
|
||||
sessionKey,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) {
|
||||
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { requireApiUser } from "@/lib/api-auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { error, user } = await requirePaidApiUser(_req);
|
||||
const { error, user } = await requireApiUser(_req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { Privacy } from "@prisma/client";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { requireApiUser } from "@/lib/api-auth";
|
||||
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
|
||||
import {
|
||||
applyCreatePlaylistToItems,
|
||||
@@ -41,7 +40,7 @@ function defaultMetadata(title: string): ItemMetadata {
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
const { error, user } = await requireApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const formData = await req.formData();
|
||||
@@ -56,10 +55,10 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const limits = getPlanLimits(user.plan);
|
||||
const limits = getPlanLimits();
|
||||
if (audioFiles.length > limits.maxBatchSize) {
|
||||
return NextResponse.json(
|
||||
{ error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` },
|
||||
{ error: `Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -98,10 +97,10 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const sessionKey = Date.now().toString();
|
||||
const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey });
|
||||
const imageUpload = await saveUploadedFile(user.id, image, "image", { sessionKey });
|
||||
|
||||
const uploads = await mapWithConcurrency(audioFiles, 4, (audio) =>
|
||||
saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }),
|
||||
saveUploadedFile(user.id, audio, "audio", { sessionKey }),
|
||||
);
|
||||
|
||||
const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
|
||||
@@ -142,18 +141,8 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Batch upload failed:", err);
|
||||
if (err instanceof PremiumRequiredError) {
|
||||
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Batch upload failed";
|
||||
const status =
|
||||
message.includes("Batch limit exceeded")
|
||||
? 400
|
||||
: message.includes("Quota exceeded") ||
|
||||
message.includes("Not enough credits") ||
|
||||
message.includes("Payment Required")
|
||||
? 402
|
||||
: 500;
|
||||
const status = message.includes("Batch limit exceeded") ? 400 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { requireApiUser } from "@/lib/api-auth";
|
||||
import { createVideoJob } from "@/lib/jobs/create-job";
|
||||
import {
|
||||
applyCreatePlaylistToItems,
|
||||
@@ -10,7 +9,7 @@ import { prisma } from "@/lib/db";
|
||||
import type { CreateJobPayload } from "@/lib/types";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
const { error, user } = await requireApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
|
||||
@@ -36,33 +35,27 @@ export async function POST(req: NextRequest) {
|
||||
const job = await createVideoJob(user, {
|
||||
imagePath: body.imagePath,
|
||||
items,
|
||||
webhookUrl: body.webhookUrl ?? null,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobId: job.id,
|
||||
itemCount: job.items.length,
|
||||
status: job.status,
|
||||
statusUrl: `/api/v1/jobs/${job.id}`,
|
||||
webhookUrl: body.webhookUrl ?? null,
|
||||
playlist: playlist
|
||||
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
|
||||
: null,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) {
|
||||
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Failed to create job";
|
||||
const status =
|
||||
message.includes("Quota exceeded") ||
|
||||
message.includes("Not enough credits") ||
|
||||
message.includes("Payment Required")
|
||||
? 402
|
||||
: 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
const { error, user } = await requireApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { requireApiUser } from "@/lib/api-auth";
|
||||
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
|
||||
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
const { error, user } = await requireApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
@@ -18,7 +18,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
const { error, user } = await requireApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Alias for n8n / automation: POST|GET /api/v1/render → same as /api/v1/jobs
|
||||
* OSS: no plan gate — Bearer API key + YouTube connected.
|
||||
*/
|
||||
export { POST, GET } from "../jobs/route";
|
||||
@@ -1,4 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
COMPOSITION_FAMILIES,
|
||||
LAYOUT_TEMPLATE_LABELS,
|
||||
LAYOUT_TEMPLATES,
|
||||
} from "@/lib/layout";
|
||||
import { API_DOCS_URL } from "@/lib/plans";
|
||||
|
||||
export async function GET() {
|
||||
@@ -6,28 +11,54 @@ export async function GET() {
|
||||
name: "Songs2VID API",
|
||||
version: "1.0",
|
||||
authentication: "Authorization: Bearer <api_key>",
|
||||
requirements: ["Pro subscription", "YouTube account connected"],
|
||||
rateLimit: "60 requests per minute per account (plus any approved bonus)",
|
||||
requirements: ["YouTube account connected"],
|
||||
rateLimit: "100,000 requests per minute per account",
|
||||
billing: false,
|
||||
notes:
|
||||
"OSS self-hosted: full entitlements, no Stripe/credits/paywall. In-repo docs: docs/api/",
|
||||
webhooks:
|
||||
"Optional webhookUrl on job create — HTTPS POST JSON on item/job terminal states for n8n",
|
||||
guidance: {
|
||||
recommended:
|
||||
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
|
||||
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/render (or /api/v1/jobs) with paths + optional webhookUrl",
|
||||
batch:
|
||||
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
|
||||
polling: "GET /api/v1/jobs/:id until status is COMPLETED, FAILED, or PARTIAL",
|
||||
},
|
||||
layoutTemplates: LAYOUT_TEMPLATES.map((id) => ({
|
||||
id,
|
||||
label: LAYOUT_TEMPLATE_LABELS[id],
|
||||
})),
|
||||
compositionFamilies: COMPOSITION_FAMILIES.map((family) => ({
|
||||
id: family.id,
|
||||
label: family.label,
|
||||
defaultTemplate: family.defaultTemplate,
|
||||
variants: family.variants.map((id) => ({
|
||||
id,
|
||||
label: LAYOUT_TEMPLATE_LABELS[id],
|
||||
})),
|
||||
})),
|
||||
endpoints: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/upload",
|
||||
description:
|
||||
"Upload image, audio, PNG logo, or .ttf/.otf font (Pro typography; font ≤10MB)",
|
||||
"Upload image, audio, PNG logo, or .ttf/.otf font (font ≤10MB)",
|
||||
body: "multipart/form-data: file, type (image|audio|logo|font)",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/render",
|
||||
description:
|
||||
"Alias of /api/v1/jobs — create a render job (n8n-friendly). Optional webhookUrl for callbacks.",
|
||||
body: "application/json: { imagePath, webhookUrl?, items[{ audioPath, audioFilename, metadata }] }",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/jobs",
|
||||
description:
|
||||
"Create a video job from uploaded file paths. Pro: layout templates, blur, watermark, per-item covers",
|
||||
body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }",
|
||||
"Create a video job from uploaded file paths with layouts, watermark, and per-item covers",
|
||||
body: "application/json: { imagePath, webhookUrl?, items[{ audioPath, audioFilename, metadata }] }",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
@@ -39,7 +70,7 @@ export async function GET() {
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/playlists",
|
||||
description: "List YouTube playlists for the authenticated Pro account",
|
||||
description: "List YouTube playlists for the authenticated account",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
@@ -50,13 +81,13 @@ export async function GET() {
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/jobs",
|
||||
description: "List recent jobs",
|
||||
description: "List recent jobs (also available as GET /api/v1/render)",
|
||||
query: "limit (default 20, max 100)",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/jobs/:id",
|
||||
description: "Get job status and item details",
|
||||
description: "Get job status and item details (poll for n8n)",
|
||||
},
|
||||
],
|
||||
docs: API_DOCS_URL,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { requireApiUser } from "@/lib/api-auth";
|
||||
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
const { error, user } = await requireApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const formData = await req.formData();
|
||||
@@ -25,12 +24,9 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan);
|
||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) {
|
||||
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
|
||||
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
|
||||
@@ -9,15 +8,6 @@ async function requirePremiumYouTubeUser() {
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
|
||||
}
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "Playlist features are available on the Pro plan only" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
if (!user.youtubeConnection) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
|
||||
|
||||
@@ -4,23 +4,10 @@ import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { AccountPrivacyActions } from "@/components/AccountPrivacyActions";
|
||||
import { CreditPurchasePanel } from "@/components/CreditPurchasePanel";
|
||||
import { PlanBillingActions } from "@/components/PlanBillingActions";
|
||||
import { DashboardShell } from "@/components/DashboardShell";
|
||||
import { UpgradeProButton } from "@/components/UpgradeProButton";
|
||||
import { getUserApiKeyStatus } from "@/lib/api-keys";
|
||||
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
||||
import { ApiKeySettings } from "@/components/ApiKeySettings";
|
||||
import { hasProFeatures, isSelfHostedEdition } from "@/lib/edition";
|
||||
import { EXTENSION_KIND, getQuotaExtensionUsage } from "@/lib/quota-extensions";
|
||||
import { getQuotaInfo } from "@/lib/quota";
|
||||
import { isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
|
||||
import { CreditPurchaseStatus } from "@prisma/client";
|
||||
|
||||
const PLAN_LABELS = {
|
||||
FREE: "Bedroom Producer",
|
||||
PREMIUM: "Pro",
|
||||
} as const;
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
@@ -41,22 +28,10 @@ export default async function SettingsPage() {
|
||||
});
|
||||
if (!user) redirect("/");
|
||||
|
||||
const quota = await getQuotaInfo(user.id);
|
||||
const selfHosted = isSelfHostedEdition();
|
||||
const proFeatures = hasProFeatures(user.plan);
|
||||
const recentCreditPurchases = await prisma.creditPurchase.findMany({
|
||||
where: { userId: user.id, status: CreditPurchaseStatus.COMPLETED },
|
||||
orderBy: { completedAt: "desc" },
|
||||
take: 5,
|
||||
});
|
||||
const extensionUsage =
|
||||
!selfHosted && user.plan === "PREMIUM" ? await getQuotaExtensionUsage(user.id) : null;
|
||||
const apiRateExtensionUsage = proFeatures
|
||||
? await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT)
|
||||
: null;
|
||||
const apiKeyStatus = proFeatures ? await getUserApiKeyStatus(user.id) : null;
|
||||
const apiRateLimit = proFeatures ? await getApiRateLimitStatus(user.id) : null;
|
||||
const planLabel = selfHosted ? "Self-hosted" : PLAN_LABELS[user.plan];
|
||||
const [apiKeyStatus, apiRateLimit] = await Promise.all([
|
||||
getUserApiKeyStatus(user.id),
|
||||
getApiRateLimitStatus(user.id),
|
||||
]);
|
||||
const youtube = user.youtubeConnection;
|
||||
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
|
||||
|
||||
@@ -64,174 +39,51 @@ export default async function SettingsPage() {
|
||||
<DashboardShell channelTitle={youtube?.channelTitle}>
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-white">Settings</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Account information</h2>
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Account</h2>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Name" value={user.name || "Not set"} />
|
||||
<InfoRow label="Email" value={user.email} />
|
||||
<InfoRow label="Videos created" value={String(user.createdVideoCount)} />
|
||||
</dl>
|
||||
|
||||
<div className="mt-6 border-t border-gray-800 pt-6">
|
||||
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
|
||||
YouTube
|
||||
</h3>
|
||||
{youtube ? (
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Channel name" value={youtube.channelTitle} />
|
||||
<InfoRow label="Channel ID" value={youtube.channelId} />
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-red-400">YouTube account not connected.</p>
|
||||
)}
|
||||
|
||||
{channelUrl && (
|
||||
<a
|
||||
href={channelUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-flex rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
Go to your channel
|
||||
</a>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-sm text-gray-400">
|
||||
To refresh your YouTube permissions,{" "}
|
||||
<Link href="/" className="text-accent hover:underline">
|
||||
sign out and sign in again
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Plan & billing</h2>
|
||||
|
||||
<div className="mb-6 rounded border border-gray-800 bg-surface p-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Video quota remaining
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-white">
|
||||
{quota.totalAvailable}
|
||||
<span className="text-base font-normal text-gray-400">
|
||||
{" "}
|
||||
of {quota.limit + quota.extraCredits} videos
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">
|
||||
{quota.used} monthly used
|
||||
{quota.extraCredits > 0 ? ` · ${quota.extraCredits} extras` : ""}
|
||||
{quota.bonusQuota > 0 ? ` · +${quota.bonusQuota} bonus` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded bg-gray-800">
|
||||
<div
|
||||
className={`h-full rounded ${
|
||||
quota.totalAvailable <= 0
|
||||
? "bg-red-500"
|
||||
: quota.totalAvailable / Math.max(1, quota.limit + quota.extraCredits) <= 0.2
|
||||
? "bg-amber-500"
|
||||
: "bg-accent"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
((quota.limit + quota.extraCredits - quota.totalAvailable) /
|
||||
Math.max(1, quota.limit + quota.extraCredits)) *
|
||||
100,
|
||||
),
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
{`Includes monthly + purchased extras · monthly resets on ${quota.resetsIn}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Current plan" value={planLabel} />
|
||||
<InfoRow
|
||||
label="Monthly credits"
|
||||
value={`${quota.monthlyCredits} / cycle`}
|
||||
/>
|
||||
<InfoRow label="Extra credits" value={`${quota.extraCredits}`} />
|
||||
{user.plan === "PREMIUM" && user.subscribedAt && (
|
||||
<InfoRow
|
||||
label="Subscribed since"
|
||||
value={user.subscribedAt.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{user.plan === "PREMIUM" && (
|
||||
<InfoRow
|
||||
label="Payment method"
|
||||
value={user.cardLast4 ? `•••• ${user.cardLast4}` : "No card on file"}
|
||||
/>
|
||||
)}
|
||||
{extensionUsage && (
|
||||
<InfoRow
|
||||
label="Extension requests (this year)"
|
||||
value={`${extensionUsage.used} / ${extensionUsage.limit}`}
|
||||
/>
|
||||
)}
|
||||
<InfoRow label="Max batch size" value={`${quota.maxBatchSize} videos`} />
|
||||
<InfoRow label="Max resolution" value={`${quota.maxResolutionHeight}p`} />
|
||||
</dl>
|
||||
|
||||
{user.plan === "FREE" ? (
|
||||
<>
|
||||
<p className="mt-4 text-sm text-gray-400">
|
||||
<UpgradeProButton /> for 50 videos/month, 1080p, API access, and lossless audio.
|
||||
</p>
|
||||
{!selfHosted && (
|
||||
<CreditPurchasePanel
|
||||
initialCredits={user.extraCredits}
|
||||
stripeConfigured={isStripeConfigured() || isBillingDevMock()}
|
||||
recentPurchases={recentCreditPurchases.map((p) => ({
|
||||
id: p.id,
|
||||
credits: p.credits,
|
||||
amountCents: p.amountCents,
|
||||
completedAt: p.completedAt?.toISOString() ?? null,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">YouTube</h2>
|
||||
{youtube ? (
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Channel name" value={youtube.channelTitle} />
|
||||
<InfoRow label="Channel ID" value={youtube.channelId} />
|
||||
</dl>
|
||||
) : (
|
||||
<>
|
||||
{extensionUsage && <PlanBillingActions initialUsage={extensionUsage} />}
|
||||
<p className="mt-6 border-t border-gray-800 pt-6 text-sm text-gray-400">
|
||||
Pro uses your monthly allocation first, then any never-expiring extra credits.
|
||||
</p>
|
||||
</>
|
||||
<p className="text-sm text-red-400">YouTube account not connected.</p>
|
||||
)}
|
||||
{channelUrl && (
|
||||
<a
|
||||
href={channelUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-flex rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Go to your channel
|
||||
</a>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-gray-400">
|
||||
To refresh YouTube permissions,{" "}
|
||||
<Link href="/" className="text-accent hover:underline">sign out and sign in again</Link>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{proFeatures && apiKeyStatus && apiRateLimit && apiRateExtensionUsage && (
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">API access</h2>
|
||||
<ApiKeySettings
|
||||
initialStatus={apiKeyStatus}
|
||||
initialRateLimit={apiRateLimit}
|
||||
initialExtensionUsage={apiRateExtensionUsage}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">API key</h2>
|
||||
<ApiKeySettings initialStatus={apiKeyStatus} initialRateLimit={apiRateLimit} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-2 text-lg font-semibold text-white">Account deletion & data</h2>
|
||||
<p className="mb-4 text-sm text-gray-400">
|
||||
Request a copy of your data or permanently delete your account and associated uploads.
|
||||
Request a copy of your data or permanently delete your account and uploads.
|
||||
</p>
|
||||
<AccountPrivacyActions email={user.email} />
|
||||
</section>
|
||||
|
||||
@@ -116,6 +116,68 @@
|
||||
.mobile-sidebar-link-danger:hover::before {
|
||||
@apply bg-red-400;
|
||||
}
|
||||
|
||||
.badge-marquee {
|
||||
@apply relative w-full overflow-hidden;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 8%,
|
||||
black 92%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.badge-marquee-track {
|
||||
@apply flex w-max items-center gap-10;
|
||||
animation: badge-marquee-scroll var(--badge-marquee-duration, 28s) linear infinite;
|
||||
}
|
||||
|
||||
.badge-marquee:hover .badge-marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.badge-marquee-track {
|
||||
animation: none;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badge-marquee-item[aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-marquee-item {
|
||||
@apply flex shrink-0 items-center justify-center;
|
||||
}
|
||||
|
||||
.badge-marquee-slot {
|
||||
@apply flex h-14 max-h-14 items-center justify-center;
|
||||
}
|
||||
|
||||
.badge-marquee-slot a,
|
||||
.badge-marquee-link {
|
||||
@apply m-0 inline-flex items-center p-0 leading-none no-underline;
|
||||
}
|
||||
|
||||
.badge-marquee-slot img,
|
||||
.badge-marquee-slot iframe,
|
||||
.badge-marquee-slot svg,
|
||||
.badge-marquee-slot object {
|
||||
display: block !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
max-height: 3.5rem !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: min(280px, 70vw) !important;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mockup-float {
|
||||
@@ -246,3 +308,12 @@
|
||||
transform: scale(0.6) rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes badge-marquee-scroll {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,74 +2,18 @@ import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
const SITE_URL = "https://songs2vid.com";
|
||||
const SITE_NAME = "Songs2VID";
|
||||
const DEFAULT_TITLE =
|
||||
"Songs2VID - Fast Audio to Video Converter for YouTube | TunesToTube Alternative";
|
||||
const DEFAULT_TITLE = "Songs2VID";
|
||||
const DEFAULT_DESCRIPTION =
|
||||
"Convert MP3, WAV, and audio files into YouTube videos instantly. The fastest, privacy-focused online tool to upload music, beats, and podcasts directly to YouTube.";
|
||||
const OG_TITLE = "Songs2VID - Fast Audio to Video Converter for YouTube";
|
||||
const OG_DESCRIPTION =
|
||||
"Convert MP3 & audio files into YouTube videos instantly without heavy editing.";
|
||||
const TWITTER_DESCRIPTION = "Convert MP3 & audio files into YouTube videos instantly.";
|
||||
"Payment-free self-hosted audio-to-video creation and YouTube uploading.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: DEFAULT_TITLE,
|
||||
template: `%s | ${SITE_NAME}`,
|
||||
},
|
||||
title: DEFAULT_TITLE,
|
||||
description: DEFAULT_DESCRIPTION,
|
||||
keywords: [
|
||||
"mp3 to youtube",
|
||||
"audio to video converter",
|
||||
"upload audio to youtube",
|
||||
"tunestotube alternative",
|
||||
"song to video",
|
||||
"podcast to youtube",
|
||||
"convert mp3 to mp4",
|
||||
],
|
||||
authors: [{ name: SITE_NAME, url: SITE_URL }],
|
||||
creator: SITE_NAME,
|
||||
publisher: SITE_NAME,
|
||||
applicationName: SITE_NAME,
|
||||
alternates: {
|
||||
canonical: SITE_URL,
|
||||
},
|
||||
openGraph: {
|
||||
title: OG_TITLE,
|
||||
description: OG_DESCRIPTION,
|
||||
url: SITE_URL,
|
||||
siteName: SITE_NAME,
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
images: [
|
||||
{
|
||||
url: "/logo.png",
|
||||
width: 512,
|
||||
height: 512,
|
||||
alt: SITE_NAME,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: OG_TITLE,
|
||||
description: TWITTER_DESCRIPTION,
|
||||
images: ["/logo.png"],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.png",
|
||||
},
|
||||
@@ -83,7 +27,6 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className} suppressHydrationWarning>
|
||||
<JsonLd />
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,94 +1,27 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { BenefitsSection } from "@/components/BenefitsSection";
|
||||
import { DownloadSection } from "@/components/DownloadSection";
|
||||
import { LandingNavbar } from "@/components/LandingNavbar";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { SignInButton } from "@/components/SignInButton";
|
||||
import { PricingSection } from "@/components/PricingSection";
|
||||
import { StepsSection } from "@/components/StepsSection";
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { SupportSection } from "@/components/SupportSection";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session) redirect("/dashboard");
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<section className="relative min-h-dvh overflow-hidden">
|
||||
<video
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<source src="/bg-video.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<div className="absolute inset-0 bg-black/55" aria-hidden="true" />
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/40 to-black/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<LandingNavbar />
|
||||
|
||||
{/* Purpose + brand above the fold for Google OAuth verification */}
|
||||
<div className="relative z-10 mx-auto flex min-h-dvh max-w-3xl flex-col justify-center px-6 pb-16 pt-28 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight sm:text-6xl lg:text-7xl">
|
||||
<span className="text-white">Songs</span>
|
||||
<span className="text-red-400">2VID</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-gray-200 sm:text-lg md:text-xl">
|
||||
Convert MP3, WAV, and audio files into YouTube videos instantly — a fast, privacy-focused
|
||||
audio to video converter and TunesToTube alternative that uploads music, beats, and
|
||||
podcasts directly to your channel.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col items-center gap-3">
|
||||
{session ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center gap-2 rounded bg-accent px-8 py-3.5 font-medium text-white transition-all duration-300 hover:scale-[1.02] hover:bg-accent-hover hover:shadow-lg hover:shadow-accent/20"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<SignInButton large />
|
||||
<p className="max-w-md text-xs leading-relaxed text-gray-400">
|
||||
By clicking Continue with Google, you accept our{" "}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<main className="flex min-h-dvh items-center justify-center bg-surface px-6">
|
||||
<div className="w-full max-w-sm rounded-xl border border-gray-700 bg-surface-light p-8 text-center shadow-xl">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<Logo size="lg" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<StepsSection />
|
||||
<BenefitsSection />
|
||||
|
||||
<DownloadSection />
|
||||
|
||||
<PricingSection />
|
||||
|
||||
<SupportSection />
|
||||
<Footer />
|
||||
<SignInButton large />
|
||||
<p className="mt-5 text-xs leading-relaxed text-gray-500">
|
||||
By continuing, you accept the{" "}
|
||||
<Link href="/terms" className="hover:text-gray-300">Terms</Link> and{" "}
|
||||
<Link href="/privacy" className="hover:text-gray-300">Privacy Policy</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,289 +1,76 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy",
|
||||
description: "How Songs2VID collects, uses, and protects your personal data.",
|
||||
alternates: { canonical: "https://songs2vid.com/privacy" },
|
||||
};
|
||||
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Privacy Policy"
|
||||
description="How we handle your personal data when you use Songs2VID."
|
||||
>
|
||||
<h2>1. Overview</h2>
|
||||
<LegalPageLayout title="Privacy Policy">
|
||||
<p>
|
||||
This Privacy Policy explains how {LEGAL_OPERATOR.name} ("we", "us")
|
||||
processes personal data when you use our website, hosted cloud service, and related features
|
||||
that convert audio and images into videos for upload to YouTube.
|
||||
</p>
|
||||
<p>
|
||||
We process personal data in accordance with applicable data protection laws, including the
|
||||
General Data Protection Regulation (GDPR) where it applies.
|
||||
This policy describes how a <strong>self-hosted Songs2VID</strong> instance typically
|
||||
handles data. Songs2VID OSS does not include payment processing, plans, credits, or
|
||||
Stripe. The person or organization that operates this deployment (the "operator")
|
||||
controls the server, database, uploads, logs, and Google OAuth configuration, and is
|
||||
responsible for privacy compliance for their users.
|
||||
</p>
|
||||
|
||||
<h2>2. Data controller</h2>
|
||||
<h2>1. Who is responsible</h2>
|
||||
<p>
|
||||
{LEGAL_OPERATOR.legalName}
|
||||
<br />
|
||||
{LEGAL_OPERATOR.address}
|
||||
<br />
|
||||
{LEGAL_OPERATOR.city}
|
||||
<br />
|
||||
Email: <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
|
||||
For this software package as published by {LEGAL_OPERATOR.legalName}, contact:{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. For data on{" "}
|
||||
<em>this running instance</em>, contact the operator of the deployment you signed into —
|
||||
not necessarily the hosted SaaS at songs2vid.com, which is a separate product with its own
|
||||
policies.
|
||||
</p>
|
||||
|
||||
<h2>3. What data we collect</h2>
|
||||
<h3>3.1 Account and authentication data</h3>
|
||||
<p>When you sign in with Google, we receive and store:</p>
|
||||
<h2>2. Data this software processes</h2>
|
||||
<ul>
|
||||
<li>Your name, email address, and profile image (from Google)</li>
|
||||
<li>OAuth tokens required to authenticate your session</li>
|
||||
<li>YouTube connection data, including channel ID and title</li>
|
||||
<li>
|
||||
Encrypted YouTube API access and refresh tokens needed to upload videos, create or list
|
||||
playlists, and manage related YouTube actions you request
|
||||
</li>
|
||||
<li>Account profile from Google sign-in (email, name, avatar)</li>
|
||||
<li>YouTube OAuth tokens (encrypted at rest when configured) and channel metadata</li>
|
||||
<li>Uploaded cover art, audio, optional logos/fonts, and derived video files</li>
|
||||
<li>Job metadata (titles, tags, privacy, layouts, watermarks)</li>
|
||||
<li>API key hashes and rate-limit counters</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.2 Uploaded content and job data</h3>
|
||||
<p>When you use the service, we temporarily process:</p>
|
||||
<ul>
|
||||
<li>Image and audio files you upload (including optional per-track cover images)</li>
|
||||
<li>Optional custom watermark assets (text, PNG logo, or font files)</li>
|
||||
<li>Generated video files prior to or during YouTube upload</li>
|
||||
<li>
|
||||
Per-video metadata you provide (title, artist, description, tags, privacy, category,
|
||||
resolution, layout, watermark settings, Made for Kids / embedding / license flags, etc.)
|
||||
</li>
|
||||
<li>
|
||||
Embedded audio tag metadata (for example ID3 title/artist/album) when we read it from
|
||||
uploaded MP3 files to help prefill fields
|
||||
</li>
|
||||
<li>Playlist titles and IDs when you create or attach YouTube playlists through the Service</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.3 Usage, API, and technical data</h3>
|
||||
<ul>
|
||||
<li>
|
||||
Plan type, monthly credit allocation, credits used, purchased extra credits, quota reset
|
||||
dates, and job processing status
|
||||
</li>
|
||||
<li>
|
||||
Quota reset / extension and API rate-limit extension request history (Pro) when you submit
|
||||
a request
|
||||
</li>
|
||||
<li>
|
||||
API key material for Pro users: we store a cryptographic hash and a short non-secret
|
||||
prefix; the full key is shown once at creation and is not stored in plaintext
|
||||
</li>
|
||||
<li>IP address, browser type, device information, and request logs</li>
|
||||
<li>Error reports and operational diagnostics</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.4 Payment data</h3>
|
||||
<h2>3. Google and YouTube</h2>
|
||||
<p>
|
||||
If you purchase a Pro subscription or Free-plan extra credits, payment processing is handled
|
||||
by Stripe. We do not store full payment card details on our servers. We may receive and store
|
||||
billing status, Stripe customer and subscription identifiers, Checkout session or payment
|
||||
references, purchase amounts, and credit pack size for fulfilled top-ups.
|
||||
</p>
|
||||
|
||||
<h2>4. Why we process your data</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Contract performance:</strong> to provide video encoding, metadata handling,
|
||||
playlist actions, API access, and YouTube upload features you request
|
||||
</li>
|
||||
<li>
|
||||
<strong>Legitimate interests:</strong> to secure our service, prevent abuse, improve
|
||||
reliability, and enforce our terms
|
||||
</li>
|
||||
<li>
|
||||
<strong>Legal obligations:</strong> where required by tax, accounting, or regulatory law
|
||||
</li>
|
||||
<li>
|
||||
<strong>Consent:</strong> where you have given explicit consent, such as optional
|
||||
marketing communications if offered
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Third-party services</h2>
|
||||
<p>We use trusted third parties to operate Songs2VID, including:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Google / YouTube:</strong> authentication and video uploads via Google OAuth and
|
||||
the YouTube Data API. Your use of Google and YouTube is also subject to{" "}
|
||||
<a
|
||||
href="https://policies.google.com/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Google's Privacy Policy
|
||||
</a>
|
||||
,{" "}
|
||||
<a
|
||||
href="https://www.youtube.com/t/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
YouTube Terms of Service
|
||||
</a>
|
||||
, and related Google API terms
|
||||
</li>
|
||||
<li>
|
||||
<strong>Hosting and infrastructure providers:</strong> servers, databases, queues, and
|
||||
storage
|
||||
</li>
|
||||
<li>
|
||||
<strong>Stripe:</strong> Pro subscriptions, Free-plan credit top-ups, and related billing
|
||||
webhooks (
|
||||
<a href="https://stripe.com/privacy" target="_blank" rel="noopener noreferrer">
|
||||
Stripe Privacy Policy
|
||||
</a>
|
||||
)
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
These providers process data only as necessary to deliver their services and under
|
||||
appropriate contractual safeguards where required.
|
||||
</p>
|
||||
|
||||
<h2>6. Google / YouTube user data (Limited Use)</h2>
|
||||
<p>
|
||||
Songs2VID's use and transfer to any other app of information received from Google APIs
|
||||
will adhere to the{" "}
|
||||
Google and YouTube process account and upload data under their own terms and policies.
|
||||
Operators must configure OAuth correctly and respect{" "}
|
||||
<a
|
||||
href="https://developers.google.com/terms/api-services-user-data-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Google API Services User Data Policy
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href="https://developers.google.com/youtube/terms/developer-policies"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
YouTube API Services Policies
|
||||
</a>
|
||||
, including the Limited Use requirements.
|
||||
.
|
||||
</p>
|
||||
|
||||
<h2>4. Your choices</h2>
|
||||
<p>
|
||||
We request Google OAuth access (including the YouTube Data API scope needed to upload videos
|
||||
and manage playlists on your connected channel) solely to provide prominent, user-facing
|
||||
features of Songs2VID: signing you in, connecting your channel, encoding your media, uploading
|
||||
videos you create, and creating or listing playlists you request. We do not use Google user
|
||||
data for advertising, credit scoring, or unrelated profiling.
|
||||
</p>
|
||||
<p>
|
||||
We do not sell, share, transfer, or disclose Google user data obtained via Google OAuth /
|
||||
YouTube APIs to third parties, except as needed to operate the Service infrastructure under
|
||||
our control or when required by law. Google / YouTube themselves process data when we call
|
||||
their APIs on your behalf to perform actions you initiate.
|
||||
</p>
|
||||
<p>
|
||||
You can revoke Songs2VID's access to your Google account at any time in{" "}
|
||||
Use <strong>Dashboard → Settings</strong> to export or delete your account data on this
|
||||
instance, or ask the operator. You can also revoke Google access in your{" "}
|
||||
<a
|
||||
href="https://security.google.com/settings/security/permissions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Google Account → Security → Third-party access
|
||||
Google Account permissions
|
||||
</a>
|
||||
. After revocation (or when tokens can no longer be refreshed), we will stop using those
|
||||
credentials and delete or invalidate stored YouTube OAuth tokens and related connection data
|
||||
associated with that consent, subject to short-term backup or security logs and any legal
|
||||
retention duties.
|
||||
.
|
||||
</p>
|
||||
|
||||
<h2>7. Data retention and account deletion</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Uploaded source files and generated outputs are retained only as long as needed to complete
|
||||
your jobs (typically removed after successful processing/upload or when no longer required)
|
||||
</li>
|
||||
<li>Account data is kept while your account remains active</li>
|
||||
<li>Billing records may be retained as required by law</li>
|
||||
<li>Logs are retained for a limited period for security and troubleshooting</li>
|
||||
<li>
|
||||
API key hashes are removed when you revoke the key, downgrade from Pro (where applicable),
|
||||
or delete your account
|
||||
</li>
|
||||
</ul>
|
||||
<h2>5. Documentation</h2>
|
||||
<p>
|
||||
You may delete your account from <strong>Dashboard → Settings</strong> (account deletion
|
||||
control) or by emailing{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. Deletion removes
|
||||
account and connection data from active systems subject to legal retention obligations
|
||||
(for example certain billing records). Cancelling a subscription does not by itself delete
|
||||
your account.
|
||||
</p>
|
||||
|
||||
<h2>8. Self-hosted deployments</h2>
|
||||
<p>
|
||||
If you deploy Songs2VID on your own infrastructure, you are the data controller for data
|
||||
processed on your instance. This Privacy Policy applies to the hosted cloud service
|
||||
operated by us, not to independent self-hosted installations unless we provide managed
|
||||
hosting for you under contract.
|
||||
</p>
|
||||
|
||||
<h2>9. Your rights</h2>
|
||||
<p>Depending on your location, you may have the right to:</p>
|
||||
<ul>
|
||||
<li>Access the personal data we hold about you</li>
|
||||
<li>Request correction or deletion</li>
|
||||
<li>Restrict or object to certain processing</li>
|
||||
<li>Data portability</li>
|
||||
<li>Withdraw consent where processing is consent-based</li>
|
||||
<li>
|
||||
Lodge a complaint with a supervisory authority (in Hungary, the Nemzeti Adatvédelmi és
|
||||
Információszabadság Hatóság — NAIH)
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
To exercise these rights, contact{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>, or use in-product
|
||||
account deletion where available. You may also revoke Google access as described in section
|
||||
6.
|
||||
</p>
|
||||
|
||||
<h2>10. Cookies and local storage</h2>
|
||||
<p>
|
||||
We use essential cookies and similar technologies for authentication, session management,
|
||||
and security. We do not use non-essential tracking cookies unless disclosed separately and
|
||||
enabled with your consent where required.
|
||||
</p>
|
||||
|
||||
<h2>11. Security</h2>
|
||||
<p>
|
||||
We implement appropriate technical and organizational measures to protect your data,
|
||||
including encryption in transit, encrypted storage of YouTube OAuth tokens at rest, access
|
||||
controls, and isolated processing environments. No method of transmission or storage is
|
||||
100% secure.
|
||||
</p>
|
||||
|
||||
<h2>12. International transfers</h2>
|
||||
<p>
|
||||
If data is transferred outside your country, we ensure appropriate safeguards such as
|
||||
standard contractual clauses or equivalent mechanisms where required by law. Google, Stripe,
|
||||
and infrastructure providers may process data in other countries as described in their
|
||||
policies.
|
||||
</p>
|
||||
|
||||
<h2>13. Children</h2>
|
||||
<p>
|
||||
Songs2VID is not directed at children under 16. We do not knowingly collect personal data
|
||||
from children. If you believe a child has provided us data, please contact us.
|
||||
</p>
|
||||
|
||||
<h2>14. Changes to this policy</h2>
|
||||
<p>
|
||||
We may update this Privacy Policy from time to time. Material changes will be posted on
|
||||
this page with an updated effective date. If we change how we use Google user data, we will
|
||||
update this policy and, where required, notify you or obtain renewed consent.
|
||||
</p>
|
||||
|
||||
<h2>15. Contact</h2>
|
||||
<p>
|
||||
Questions about this Privacy Policy or our privacy practices:{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
|
||||
Product docs: <a href={DOCS_URL}>{DOCS_URL}</a>. Source:{" "}
|
||||
<a href={GITEA_URL}>{GITEA_URL}</a>.
|
||||
</p>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Refund Policy",
|
||||
description: "Refund and cancellation policy for Songs2VID paid plans.",
|
||||
alternates: { canonical: "https://songs2vid.com/refund" },
|
||||
};
|
||||
|
||||
export default function RefundPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Refund Policy"
|
||||
description="Our policy on refunds, cancellations, and billing for paid plans."
|
||||
>
|
||||
<h2>1. Overview</h2>
|
||||
<p>
|
||||
This Refund Policy explains how refunds and cancellations work for paid Songs2VID plans and
|
||||
credit purchases. The free monthly Bedroom Producer allocation does not involve payment and
|
||||
is not subject to refunds; optional Free-plan extra credit purchases are covered in section
|
||||
2.
|
||||
</p>
|
||||
|
||||
<h2>2. Free plan and extra credits</h2>
|
||||
<p>
|
||||
The Bedroom Producer (Free) monthly allocation is provided at no charge. Free users may
|
||||
optionally purchase extra video credits (currently €0.25 each, 1–15 per checkout, Free extras
|
||||
balance capped at 15). Once extra credits are granted to your account, those one-time
|
||||
purchases are <strong>generally non-refundable</strong>, except where mandatory consumer law
|
||||
requires otherwise. Credits trimmed under the account accumulation cap (see our{" "}
|
||||
<Link href="/terms">Terms of Service</Link>) are not refundable.
|
||||
</p>
|
||||
|
||||
<h2>3. Pro subscriptions</h2>
|
||||
<h3>3.1 Billing cycle</h3>
|
||||
<p>
|
||||
Pro plans are billed on a recurring monthly basis unless otherwise stated at checkout.
|
||||
Your subscription renews automatically until cancelled.
|
||||
</p>
|
||||
|
||||
<h3>3.2 14-day refund window</h3>
|
||||
<p>
|
||||
If you are a new Pro subscriber, you may request a full refund within <strong>14 days</strong> of
|
||||
your initial purchase, provided you have not substantially consumed paid entitlements
|
||||
(for example, a large portion of your monthly video quota or premium-only features).
|
||||
</p>
|
||||
|
||||
<h3>3.3 After the refund window</h3>
|
||||
<p>
|
||||
After 14 days, subscription fees are generally non-refundable for the current billing
|
||||
period. You may cancel at any time to prevent future renewals. Access typically continues
|
||||
until the end of the paid period.
|
||||
</p>
|
||||
|
||||
<h3>3.4 Cancellation</h3>
|
||||
<p>
|
||||
You can cancel your subscription through your account billing settings (choose{" "}
|
||||
<strong>cancel immediately</strong> or <strong>at the end of the billing period</strong>) or
|
||||
via the Stripe customer portal / by contacting{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. Cancelling at period
|
||||
end keeps Pro access until the paid period finishes and stops future renewals. Cancelling
|
||||
immediately ends Pro access and moves you to the Free plan right away (including loss of Pro
|
||||
API access). Cancellation does not automatically delete your account; use{" "}
|
||||
<strong>Dashboard → Settings</strong> or contact us if you want permanent account deletion.
|
||||
</p>
|
||||
|
||||
<h2>4. Enterprise and custom agreements</h2>
|
||||
<p>
|
||||
Enterprise, managed cloud, and paid self-hosted setup fees are governed by the individual
|
||||
quote or contract signed with us. Refund terms for those services are specified in your
|
||||
agreement. Contact{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.salesEmail}`}>{LEGAL_OPERATOR.salesEmail}</a> for
|
||||
contract-related billing questions.
|
||||
</p>
|
||||
|
||||
<h2>5. Non-refundable situations</h2>
|
||||
<p>Refunds are generally not provided when:</p>
|
||||
<ul>
|
||||
<li>The refund request is made outside the applicable refund window</li>
|
||||
<li>The account was terminated for violation of our <Link href="/terms">Terms of Service</Link></li>
|
||||
<li>The issue is caused by third-party services outside our control (e.g. YouTube API outages, Google account restrictions)</li>
|
||||
<li>You simply changed your mind after substantial use of paid quota or features</li>
|
||||
<li>Purchased Free-plan extra credits have already been granted to your account</li>
|
||||
<li>Credits expired or were trimmed under the 30-credit accumulation cap</li>
|
||||
<li>One-time setup or license fees after delivery of agreed setup work, unless required by law or contract</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Chargebacks</h2>
|
||||
<p>
|
||||
If you believe a charge is incorrect, please contact us before initiating a chargeback so
|
||||
we can resolve the issue promptly. Unjustified chargebacks may result in account suspension.
|
||||
</p>
|
||||
|
||||
<h2>7. How to request a refund</h2>
|
||||
<p>Email us at <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a> with:</p>
|
||||
<ul>
|
||||
<li>Your account email address</li>
|
||||
<li>Date of purchase and invoice or transaction reference if available</li>
|
||||
<li>Reason for the refund request</li>
|
||||
</ul>
|
||||
<p>We aim to respond within 5 business days. Approved refunds are issued to the original payment method where possible.</p>
|
||||
|
||||
<h2>8. Consumer rights</h2>
|
||||
<p>
|
||||
Nothing in this policy limits mandatory statutory rights you may have as a consumer under
|
||||
applicable law, including withdrawal rights where required by EU or local consumer
|
||||
protection regulations.
|
||||
</p>
|
||||
|
||||
<h2>9. Changes</h2>
|
||||
<p>
|
||||
We may update this Refund Policy from time to time. The version published on this page
|
||||
applies to purchases made after the effective date shown at the top.
|
||||
</p>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
function siteUrl() {
|
||||
const raw = process.env.NEXTAUTH_URL?.trim() || "http://localhost:3000";
|
||||
return raw.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/api/", "/dashboard", "/jobs"],
|
||||
},
|
||||
sitemap: "https://songs2vid.com/sitemap.xml",
|
||||
sitemap: `${siteUrl()}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
const SITE_URL = "https://songs2vid.com";
|
||||
function siteUrl() {
|
||||
const raw = process.env.NEXTAUTH_URL?.trim() || "http://localhost:3000";
|
||||
return raw.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const base = siteUrl();
|
||||
return [
|
||||
{
|
||||
url: SITE_URL,
|
||||
url: base,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
changeFrequency: "weekly",
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/privacy`,
|
||||
url: `${base}/privacy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${base}/terms`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.3,
|
||||
|
||||
@@ -1,261 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service",
|
||||
description: "Terms and conditions for using the Songs2VID hosted service.",
|
||||
alternates: { canonical: "https://songs2vid.com/terms" },
|
||||
};
|
||||
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Terms of Service"
|
||||
description="Please read these terms carefully before using Songs2VID."
|
||||
>
|
||||
<h2>1. Agreement</h2>
|
||||
<LegalPageLayout title="Terms of Use">
|
||||
<p>
|
||||
These Terms of Service ("Terms") govern your access to and use of the Songs2VID
|
||||
website and hosted cloud service (the "Service") operated by{" "}
|
||||
{LEGAL_OPERATOR.legalName} ("we", "us"). By creating an account or using
|
||||
the Service, you agree to these Terms.
|
||||
</p>
|
||||
<p>
|
||||
If you do not agree, do not use the Service. If you self-host the open-source software on
|
||||
your own infrastructure without using our hosted Service, these Terms apply only to the
|
||||
extent you use our website, support channels, or paid services we provide.
|
||||
Songs2VID OSS is open-source, <strong>payment-free, self-hosted software</strong>. These
|
||||
terms describe use of the software and of instances that run it. They are{" "}
|
||||
<strong>not</strong> the paid cloud Terms of Service for songs2vid.com (a separate hosted
|
||||
product with its own billing and policies).
|
||||
</p>
|
||||
|
||||
<h2>2. The Service</h2>
|
||||
<h2>1. Software license and warranty</h2>
|
||||
<p>
|
||||
Songs2VID converts user-provided images and audio files into videos and can upload them to
|
||||
YouTube using your connected Google/YouTube account. Features, limits, and availability
|
||||
depend on your plan.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Bedroom Producer (Free):</strong> 10 video credits per calendar month, 720p, MP3,
|
||||
optional Songs2VID watermark (bottom-right), one static cover image per batch; may purchase
|
||||
limited extra credits as described in Clause 8.2
|
||||
</li>
|
||||
<li>
|
||||
<strong>Independent Artist (Pro):</strong> 50 video credits per month, higher resolution and
|
||||
batch limits, API access, custom watermark/logo/typography, blurred art-track layouts with
|
||||
position controls, unique image per
|
||||
track in bulk uploads, and other features as described on our pricing page (€5/month
|
||||
unless otherwise stated at checkout)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Enterprise (Record Label / Studio):</strong> custom terms as agreed in writing
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
We may modify features, limits, or pricing with reasonable notice where required. The
|
||||
open-source software is provided separately under its applicable open-source license.
|
||||
The source is provided under the license in the repository <code>LICENSE</code> file. The
|
||||
software is provided <strong>without warranty</strong> of any kind. The operator of each
|
||||
instance is responsible for availability, security, backups, configuration, and who may
|
||||
sign in.
|
||||
</p>
|
||||
|
||||
<h2>3. Eligibility and accounts</h2>
|
||||
<ul>
|
||||
<li>You must be at least 16 years old or the age required in your jurisdiction</li>
|
||||
<li>You must have a valid Google account and authorized access to the YouTube channel you connect</li>
|
||||
<li>You are responsible for maintaining the security of your account and OAuth connection</li>
|
||||
<li>You must provide accurate information and promptly update it if it changes</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Your content and responsibilities</h2>
|
||||
<p>You retain ownership of content you upload. You grant us a limited license to host, process, encode, transmit, and upload your content solely to provide the Service.</p>
|
||||
<p>You represent and warrant that:</p>
|
||||
<ul>
|
||||
<li>You own or have all necessary rights to the content you upload</li>
|
||||
<li>Your content and use of the Service comply with applicable law and YouTube policies</li>
|
||||
<li>Your content does not infringe third-party rights or contain unlawful material</li>
|
||||
<li>You have configured metadata (including "Made for Kids" and privacy settings) accurately</li>
|
||||
</ul>
|
||||
<h2>2. No paid features in this edition</h2>
|
||||
<p>
|
||||
You are solely responsible for content published to your YouTube channel through the
|
||||
Service.
|
||||
This edition has no Stripe integration, subscriptions, credits, pricing pages, or paywalls.
|
||||
Full layout, watermark, API, and playlist features are available to signed-in users of the
|
||||
instance. Any paid offering lives only on the separate hosted SaaS product.
|
||||
</p>
|
||||
|
||||
<h2>5. Acceptable use</h2>
|
||||
<p>You agree not to:</p>
|
||||
<ul>
|
||||
<li>Use the Service for unlawful, harmful, or abusive purposes</li>
|
||||
<li>Upload malware, attempt unauthorized access, or interfere with the Service</li>
|
||||
<li>Circumvent quotas, plan limits, or technical restrictions</li>
|
||||
<li>Resell or commercially exploit the hosted Service without authorization</li>
|
||||
<li>Use the Service in a way that violates Google, YouTube, or third-party terms</li>
|
||||
</ul>
|
||||
<p>We may suspend or terminate access for violations or risks to the Service or other users.</p>
|
||||
|
||||
<h2>6. YouTube and third-party services</h2>
|
||||
<h2>3. Your content and YouTube</h2>
|
||||
<p>
|
||||
The Service integrates with Google OAuth and the YouTube Data API to sign you in, connect
|
||||
your channel, upload videos you create, and create or list playlists you request. Your use
|
||||
of those services is also subject to{" "}
|
||||
<a href="https://policies.google.com/terms" target="_blank" rel="noopener noreferrer">
|
||||
Google Terms of Service
|
||||
</a>
|
||||
,{" "}
|
||||
<a href="https://www.youtube.com/t/terms" target="_blank" rel="noopener noreferrer">
|
||||
YouTube Terms of Service
|
||||
</a>
|
||||
,{" "}
|
||||
<a
|
||||
href="https://developers.google.com/youtube/terms/api-services-terms-of-service"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
YouTube API Services Terms
|
||||
</a>
|
||||
, and{" "}
|
||||
<a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer">
|
||||
Google's Privacy Policy
|
||||
</a>
|
||||
. We are not responsible for changes, outages, quota limits, or enforcement actions taken by
|
||||
Google or YouTube. How we handle Google user data is described in our{" "}
|
||||
<Link href="/privacy">Privacy Policy</Link>.
|
||||
You are solely responsible for media you upload and for compliance with copyright law and
|
||||
YouTube's terms. Connecting Google/YouTube authorizes the instance to upload on your
|
||||
behalf within the scopes granted.
|
||||
</p>
|
||||
|
||||
<h2>7. Open-source software</h2>
|
||||
<h2>4. Acceptable use</h2>
|
||||
<p>
|
||||
Portions of Songs2VID are available as open-source software. Self-hosting is permitted under
|
||||
the applicable open-source license. The hosted Service, enterprise features, managed
|
||||
infrastructure, and certain premium capabilities may require a separate commercial license
|
||||
or subscription.
|
||||
Do not use the software to infringe rights, abuse YouTube or Google APIs, or circumvent
|
||||
another party's security. Operators may suspend access on their instances.
|
||||
</p>
|
||||
|
||||
<h2>8. Subscription, Billing & Credits</h2>
|
||||
<h2>5. Contact</h2>
|
||||
<p>
|
||||
Paid plans and credit top-ups are billed according to the pricing displayed at the time of
|
||||
purchase. Taxes may apply. Payments are processed by Stripe. Subscriptions renew
|
||||
automatically unless cancelled in accordance with our{" "}
|
||||
<Link href="/refund">Refund Policy</Link>. Failure to pay may result in downgrade or
|
||||
suspension.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Credit usage order:</strong> when you create video jobs, we deduct from your current
|
||||
monthly allocation first, then from any purchased extra credits.
|
||||
</p>
|
||||
|
||||
<h3>8.1 Pro plan quota and rate-limit extensions</h3>
|
||||
<p>
|
||||
Independent Artist (Pro) subscribers receive <strong>50 video credits</strong> per
|
||||
subscription month. Unused credits from prior cycles may roll over into the next cycle
|
||||
subject to the accumulation cap in Clause 8.3. Renewal and rollover occur on successful
|
||||
Stripe subscription renewal (typically aligned with your billing period).
|
||||
</p>
|
||||
<p>
|
||||
Pro subscribers may request a manual video-quota reset or temporary extension, and/or an API
|
||||
rate-limit extension, via account settings or by emailing{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.quotaRequestEmail}`}>
|
||||
{LEGAL_OPERATOR.quotaRequestEmail}
|
||||
</a>
|
||||
. Each Pro account is entitled to up to{" "}
|
||||
<strong>five (5) video-quota reset or extension requests per calendar year</strong> and up
|
||||
to <strong>five (5) API rate-limit extension requests per calendar year</strong>. We review
|
||||
requests in good faith and may decline requests that are abusive, repetitive without cause,
|
||||
or inconsistent with fair use. Approved requests do not increase those annual limits.
|
||||
</p>
|
||||
|
||||
<h3>8.2 Free plan extra credits</h3>
|
||||
<p>
|
||||
Bedroom Producer (Free) accounts receive <strong>10 video credits</strong> per calendar
|
||||
month. Free users may purchase additional never-expiring extra credits at the price shown at
|
||||
checkout (currently <strong>€0.25 per credit</strong>), in packs of{" "}
|
||||
<strong>1 to 15 credits</strong> per purchase. On the Free plan, the extras balance may not
|
||||
exceed <strong>15 credits</strong>. After monthly and extra credits are exhausted, further
|
||||
uploads require upgrading to Pro (or waiting for the next monthly reset). Extra credits are
|
||||
available only on the Free plan purchase flow; Pro includes its monthly allocation and does
|
||||
not sell the same Free top-up packs.
|
||||
</p>
|
||||
|
||||
<h3>8.3 Credit rollover & accumulation cap</h3>
|
||||
<p>
|
||||
Unused credits from previous cycles may roll over, but the total accumulated balance on any
|
||||
account (remaining monthly allocation + extra credits) shall strictly not exceed{" "}
|
||||
<strong>30 credits</strong> at any given time. Upon renewal (or when applying a new monthly
|
||||
allocation), any excess above this 30-credit threshold is automatically trimmed and expires
|
||||
permanently, without entitlement to refund or compensation.
|
||||
</p>
|
||||
|
||||
<h3>8.4 Pro API access</h3>
|
||||
<p>
|
||||
Independent Artist (Pro) subscribers may generate an API key in account settings to upload
|
||||
files and create batch video jobs programmatically. API access requires an active Pro
|
||||
subscription, a connected YouTube account, and compliance with the same quota, file-type, and
|
||||
resolution limits as the web interface. API keys are personal, must be kept confidential, and
|
||||
may be revoked by you or by us if misused. We may apply rate limits and suspend API access
|
||||
for abuse, security incidents, or plan downgrades. If your subscription ends or you are
|
||||
moved to the Free plan, API keys and Pro-only API access are revoked or disabled.
|
||||
</p>
|
||||
|
||||
<h2>9. Availability and support</h2>
|
||||
<p>
|
||||
We strive for high availability but do not guarantee uninterrupted access. Maintenance,
|
||||
updates, and outages may occur. Support levels depend on your plan. Self-hosted DIY
|
||||
deployments without a paid setup are community-supported unless otherwise agreed in
|
||||
writing.
|
||||
</p>
|
||||
|
||||
<h2>10. Disclaimer of warranties</h2>
|
||||
<p>
|
||||
THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" TO THE MAXIMUM EXTENT
|
||||
PERMITTED BY LAW. WE DISCLAIM ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. WE DO NOT WARRANT THAT ENCODING,
|
||||
UPLOADS, OR METADATA TRANSFER WILL BE ERROR-FREE OR UNINTERRUPTED.
|
||||
</p>
|
||||
|
||||
<h2>11. Limitation of liability</h2>
|
||||
<p>
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, WE SHALL NOT BE LIABLE FOR INDIRECT, INCIDENTAL,
|
||||
SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, REVENUE, DATA, OR
|
||||
GOODWILL. OUR TOTAL LIABILITY FOR ANY CLAIM ARISING OUT OF THESE TERMS OR THE SERVICE IS
|
||||
LIMITED TO THE AMOUNT YOU PAID US IN THE TWELVE (12) MONTHS BEFORE THE EVENT GIVING RISE TO
|
||||
THE CLAIM, OR EUR 100 IF YOU USE THE FREE PLAN ONLY.
|
||||
</p>
|
||||
<p>
|
||||
Some jurisdictions do not allow certain limitations, so some of the above may not apply to
|
||||
you.
|
||||
</p>
|
||||
|
||||
<h2>12. Indemnification</h2>
|
||||
<p>
|
||||
You agree to indemnify and hold us harmless from claims arising out of your content, your
|
||||
use of the Service, or your violation of these Terms or applicable law.
|
||||
</p>
|
||||
|
||||
<h2>13. Termination and account deletion</h2>
|
||||
<p>
|
||||
You may stop using the Service at any time. You may cancel a paid subscription (see our{" "}
|
||||
<Link href="/refund">Refund Policy</Link>) without deleting your account. To permanently
|
||||
delete your account and associated active data, use the account deletion control in{" "}
|
||||
<strong>Dashboard → Settings</strong> or contact{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. We may suspend or
|
||||
terminate your access if you breach these Terms, create risk or legal exposure, or where
|
||||
required by law. Upon termination or deletion, your right to use the hosted Service ends.
|
||||
Provisions that by nature should survive will survive. Deletion is subject to legal
|
||||
retention obligations described in our <Link href="/privacy">Privacy Policy</Link>.
|
||||
</p>
|
||||
|
||||
<h2>14. Governing law</h2>
|
||||
<p>
|
||||
These Terms are governed by the laws of Hungary, excluding conflict
|
||||
of law rules. Courts in Hungary shall have exclusive jurisdiction unless
|
||||
mandatory consumer protection laws in your country provide otherwise.
|
||||
</p>
|
||||
|
||||
<h2>15. Changes</h2>
|
||||
<p>
|
||||
We may update these Terms from time to time. Continued use after changes become effective
|
||||
constitutes acceptance of the revised Terms, where permitted by law.
|
||||
</p>
|
||||
|
||||
<h2>16. Contact</h2>
|
||||
<p>
|
||||
Questions about these Terms:{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
|
||||
Package / project contact:{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. Instance-specific
|
||||
support: contact your operator. Docs: <a href={DOCS_URL}>{DOCS_URL}</a>. Source:{" "}
|
||||
<a href={GITEA_URL}>{GITEA_URL}</a>.
|
||||
</p>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
|
||||
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 167 KiB |
@@ -1,571 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mlt LC_NUMERIC="C" title="Shotcut version 25.07.26" producer="main_bin">
|
||||
<profile description="automatic" width="3840" height="2160" progressive="1" sample_aspect_num="1" sample_aspect_den="1" display_aspect_num="16" display_aspect_den="9" frame_rate_num="25" frame_rate_den="1" colorspace="709"/>
|
||||
<chain id="chain0" out="00:00:07.520">
|
||||
<property name="length">00:00:07.560</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">12336223-uhd_3840_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">2</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">3840</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">26564154</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2022-06-01T09:11:45.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.media.1.stream.type">audio</property>
|
||||
<property name="meta.media.1.codec.sample_fmt">fltp</property>
|
||||
<property name="meta.media.1.codec.sample_rate">48000</property>
|
||||
<property name="meta.media.1.codec.channels">2</property>
|
||||
<property name="meta.media.1.codec.layout">stereo</property>
|
||||
<property name="meta.media.1.codec.name">aac</property>
|
||||
<property name="meta.media.1.codec.long_name">AAC (Advanced Audio Coding)</property>
|
||||
<property name="meta.media.1.codec.bit_rate">253375</property>
|
||||
<property name="meta.attr.1.stream.creation_time.markup">2022-06-01T09:11:45.000000Z</property>
|
||||
<property name="meta.attr.1.stream.handler_name.markup">L-SMASH Audio Handler</property>
|
||||
<property name="meta.attr.1.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2022-06-01T09:11:45.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2022-06-01T09:11:45</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">3840</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="astream">0</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">deedb8954e1edb1403f9bfcfeec06fd4</property>
|
||||
</chain>
|
||||
<chain id="chain1" out="00:00:13.280">
|
||||
<property name="length">00:00:13.320</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">5657838-uhd_4096_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">4096</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">15838633</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2020-10-21T09:36:44.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2020-10-21T09:36:44.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2020-10-21T09:36:44</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">4096</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">a8579d660409a043fa3836308c1d07fc</property>
|
||||
</chain>
|
||||
<chain id="chain2" out="00:00:08.920">
|
||||
<property name="length">00:00:08.960</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">5657843-uhd_4096_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">4096</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">22478882</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2020-10-21T09:34:54.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2020-10-21T09:34:54.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2020-10-21T09:34:54</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">4096</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">a680baf367e6ad49fff40416e3d971cd</property>
|
||||
<property name="ignore_points">1</property>
|
||||
<property name="meta.media.top_field_first">0</property>
|
||||
<property name="meta.media.progressive">1</property>
|
||||
<property name="shotcut:caption">5657843-uhd_4096_2160_25fps.mp4</property>
|
||||
</chain>
|
||||
<chain id="chain3" out="00:00:09.880">
|
||||
<property name="length">00:00:09.920</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">7507373-uhd_3840_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">3840</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">22476258</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2021-04-14T11:58:42.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2021-04-14T11:58:42.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2021-04-14T11:58:42</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">3840</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">54400e51ad7e058f46fefae726dc0f77</property>
|
||||
</chain>
|
||||
<chain id="chain4" out="00:00:30.640">
|
||||
<property name="length">00:00:30.680</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">9006055-hd_1920_1080_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">1920</property>
|
||||
<property name="meta.media.0.codec.height">1080</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">5666771</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2021-07-31T17:21:02.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2021-07-31T17:21:02.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2021-07-31T17:21:02</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">1920</property>
|
||||
<property name="meta.media.height">1080</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">153beb054a7e25fb75735a4129b379f2</property>
|
||||
</chain>
|
||||
<playlist id="main_bin">
|
||||
<property name="xml_retain">1</property>
|
||||
<entry producer="chain0" in="00:00:00.000" out="00:00:07.520"/>
|
||||
<entry producer="chain1" in="00:00:00.000" out="00:00:13.280"/>
|
||||
<entry producer="chain2" in="00:00:00.000" out="00:00:08.920"/>
|
||||
<entry producer="chain3" in="00:00:00.000" out="00:00:09.880"/>
|
||||
<entry producer="chain4" in="00:00:00.000" out="00:00:30.640"/>
|
||||
</playlist>
|
||||
<producer id="black" in="00:00:00.000" out="00:00:20.360">
|
||||
<property name="length">00:00:20.400</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">0</property>
|
||||
<property name="aspect_ratio">1</property>
|
||||
<property name="mlt_service">color</property>
|
||||
<property name="mlt_image_format">rgba</property>
|
||||
<property name="set.test_audio">0</property>
|
||||
</producer>
|
||||
<playlist id="background">
|
||||
<entry producer="black" in="00:00:00.000" out="00:00:20.360"/>
|
||||
</playlist>
|
||||
<chain id="chain5" out="00:00:08.920">
|
||||
<property name="length">00:00:08.960</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">5657843-uhd_4096_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">4096</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">22478882</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2020-10-21T09:34:54.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2020-10-21T09:34:54.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2020-10-21T09:34:54</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">4096</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">a680baf367e6ad49fff40416e3d971cd</property>
|
||||
<property name="ignore_points">0</property>
|
||||
<property name="meta.media.top_field_first">0</property>
|
||||
<property name="meta.media.progressive">1</property>
|
||||
<property name="shotcut:caption">5657843-uhd_4096_2160_25fps.mp4</property>
|
||||
</chain>
|
||||
<chain id="chain6" out="00:00:09.880">
|
||||
<property name="length">00:00:09.920</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">7507373-uhd_3840_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">3840</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">22476258</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2021-04-14T11:58:42.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2021-04-14T11:58:42.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2021-04-14T11:58:42</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">3840</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">54400e51ad7e058f46fefae726dc0f77</property>
|
||||
<property name="meta.media.top_field_first">0</property>
|
||||
<property name="meta.media.progressive">1</property>
|
||||
<property name="shotcut:caption">7507373-uhd_3840_2160_25fps.mp4</property>
|
||||
</chain>
|
||||
<chain id="chain7" out="00:00:30.640">
|
||||
<property name="length">00:00:30.680</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">9006055-hd_1920_1080_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">1920</property>
|
||||
<property name="meta.media.0.codec.height">1080</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">5666771</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2021-07-31T17:21:02.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2021-07-31T17:21:02.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2021-07-31T17:21:02</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">1920</property>
|
||||
<property name="meta.media.height">1080</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">153beb054a7e25fb75735a4129b379f2</property>
|
||||
<property name="meta.media.top_field_first">0</property>
|
||||
<property name="meta.media.progressive">1</property>
|
||||
<property name="shotcut:caption">9006055-hd_1920_1080_25fps.mp4</property>
|
||||
</chain>
|
||||
<chain id="chain8" out="00:00:07.520">
|
||||
<property name="length">00:00:07.560</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">12336223-uhd_3840_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">2</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">3840</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">26564154</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2022-06-01T09:11:45.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.media.1.stream.type">audio</property>
|
||||
<property name="meta.media.1.codec.sample_fmt">fltp</property>
|
||||
<property name="meta.media.1.codec.sample_rate">48000</property>
|
||||
<property name="meta.media.1.codec.channels">2</property>
|
||||
<property name="meta.media.1.codec.layout">stereo</property>
|
||||
<property name="meta.media.1.codec.name">aac</property>
|
||||
<property name="meta.media.1.codec.long_name">AAC (Advanced Audio Coding)</property>
|
||||
<property name="meta.media.1.codec.bit_rate">253375</property>
|
||||
<property name="meta.attr.1.stream.creation_time.markup">2022-06-01T09:11:45.000000Z</property>
|
||||
<property name="meta.attr.1.stream.handler_name.markup">L-SMASH Audio Handler</property>
|
||||
<property name="meta.attr.1.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2022-06-01T09:11:45.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2022-06-01T09:11:45</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">3840</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="astream">0</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">deedb8954e1edb1403f9bfcfeec06fd4</property>
|
||||
<property name="meta.media.top_field_first">0</property>
|
||||
<property name="meta.media.progressive">1</property>
|
||||
<property name="shotcut:caption">12336223-uhd_3840_2160_25fps.mp4</property>
|
||||
</chain>
|
||||
<chain id="chain9" out="00:00:13.280">
|
||||
<property name="length">00:00:13.320</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">5657838-uhd_4096_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">4096</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">15838633</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2020-10-21T09:36:44.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2020-10-21T09:36:44.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2020-10-21T09:36:44</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">4096</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">a8579d660409a043fa3836308c1d07fc</property>
|
||||
<property name="meta.media.top_field_first">0</property>
|
||||
<property name="meta.media.progressive">1</property>
|
||||
<property name="shotcut:caption">5657838-uhd_4096_2160_25fps.mp4</property>
|
||||
</chain>
|
||||
<playlist id="playlist0">
|
||||
<property name="shotcut:video">1</property>
|
||||
<property name="shotcut:name">V1</property>
|
||||
<entry producer="chain5" in="00:00:00.000" out="00:00:03.000"/>
|
||||
<entry producer="chain6" in="00:00:00.000" out="00:00:03.720"/>
|
||||
<entry producer="chain7" in="00:00:00.000" out="00:00:03.400"/>
|
||||
<entry producer="chain8" in="00:00:00.000" out="00:00:03.440"/>
|
||||
<entry producer="chain9" in="00:00:00.000" out="00:00:06.640"/>
|
||||
</playlist>
|
||||
<tractor id="tractor0" title="Shotcut version 25.07.26" in="00:00:00.000" out="00:00:20.360">
|
||||
<property name="shotcut">1</property>
|
||||
<property name="shotcut:scaleFactor">5.11712</property>
|
||||
<property name="shotcut:projectAudioChannels">2</property>
|
||||
<property name="shotcut:projectFolder">1</property>
|
||||
<track producer="background"/>
|
||||
<track producer="playlist0"/>
|
||||
<transition id="transition0">
|
||||
<property name="a_track">0</property>
|
||||
<property name="b_track">1</property>
|
||||
<property name="mlt_service">mix</property>
|
||||
<property name="always_active">1</property>
|
||||
<property name="sum">1</property>
|
||||
</transition>
|
||||
<transition id="transition1">
|
||||
<property name="a_track">0</property>
|
||||
<property name="b_track">1</property>
|
||||
<property name="version">0.1</property>
|
||||
<property name="mlt_service">frei0r.cairoblend</property>
|
||||
<property name="threads">0</property>
|
||||
<property name="disable">1</property>
|
||||
</transition>
|
||||
</tractor>
|
||||
</mlt>
|
||||
|
||||
@@ -1,570 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<mlt LC_NUMERIC="C" version="7.33.0" title="Shotcut version 25.07.26" producer="main_bin">
|
||||
<profile description="automatic" width="3840" height="2160" progressive="1" sample_aspect_num="1" sample_aspect_den="1" display_aspect_num="16" display_aspect_den="9" frame_rate_num="25" frame_rate_den="1" colorspace="709"/>
|
||||
<chain id="chain0" out="00:00:07.520">
|
||||
<property name="length">00:00:07.560</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">C:/Users/Atakan Doğan Özban/Desktop/12336223-uhd_3840_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">2</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">3840</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">26564154</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2022-06-01T09:11:45.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.media.1.stream.type">audio</property>
|
||||
<property name="meta.media.1.codec.sample_fmt">fltp</property>
|
||||
<property name="meta.media.1.codec.sample_rate">48000</property>
|
||||
<property name="meta.media.1.codec.channels">2</property>
|
||||
<property name="meta.media.1.codec.layout">stereo</property>
|
||||
<property name="meta.media.1.codec.name">aac</property>
|
||||
<property name="meta.media.1.codec.long_name">AAC (Advanced Audio Coding)</property>
|
||||
<property name="meta.media.1.codec.bit_rate">253375</property>
|
||||
<property name="meta.attr.1.stream.creation_time.markup">2022-06-01T09:11:45.000000Z</property>
|
||||
<property name="meta.attr.1.stream.handler_name.markup">L-SMASH Audio Handler</property>
|
||||
<property name="meta.attr.1.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2022-06-01T09:11:45.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2022-06-01T09:11:45</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">3840</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="astream">0</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">deedb8954e1edb1403f9bfcfeec06fd4</property>
|
||||
</chain>
|
||||
<chain id="chain1" out="00:00:13.280">
|
||||
<property name="length">00:00:13.320</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">C:/Users/Atakan Doğan Özban/Desktop/5657838-uhd_4096_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">4096</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">15838633</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2020-10-21T09:36:44.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2020-10-21T09:36:44.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2020-10-21T09:36:44</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">4096</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">a8579d660409a043fa3836308c1d07fc</property>
|
||||
</chain>
|
||||
<chain id="chain2" out="00:00:08.920">
|
||||
<property name="length">00:00:08.960</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">C:/Users/Atakan Doğan Özban/Desktop/5657843-uhd_4096_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">4096</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">22478882</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2020-10-21T09:34:54.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2020-10-21T09:34:54.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2020-10-21T09:34:54</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">4096</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">a680baf367e6ad49fff40416e3d971cd</property>
|
||||
<property name="ignore_points">1</property>
|
||||
<property name="meta.media.top_field_first">0</property>
|
||||
<property name="meta.media.progressive">1</property>
|
||||
<property name="shotcut:caption">5657843-uhd_4096_2160_25fps.mp4</property>
|
||||
</chain>
|
||||
<chain id="chain3" out="00:00:09.880">
|
||||
<property name="length">00:00:09.920</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">C:/Users/Atakan Doğan Özban/Desktop/7507373-uhd_3840_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">3840</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">22476258</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2021-04-14T11:58:42.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2021-04-14T11:58:42.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2021-04-14T11:58:42</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">3840</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">54400e51ad7e058f46fefae726dc0f77</property>
|
||||
</chain>
|
||||
<chain id="chain4" out="00:00:30.640">
|
||||
<property name="length">00:00:30.680</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">C:/Users/Atakan Doğan Özban/Desktop/9006055-hd_1920_1080_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">1920</property>
|
||||
<property name="meta.media.0.codec.height">1080</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">5666771</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2021-07-31T17:21:02.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2021-07-31T17:21:02.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2021-07-31T17:21:02</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">1920</property>
|
||||
<property name="meta.media.height">1080</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">153beb054a7e25fb75735a4129b379f2</property>
|
||||
</chain>
|
||||
<playlist id="main_bin">
|
||||
<property name="xml_retain">1</property>
|
||||
<entry producer="chain0" in="00:00:00.000" out="00:00:07.520"/>
|
||||
<entry producer="chain1" in="00:00:00.000" out="00:00:13.280"/>
|
||||
<entry producer="chain2" in="00:00:00.000" out="00:00:08.920"/>
|
||||
<entry producer="chain3" in="00:00:00.000" out="00:00:09.880"/>
|
||||
<entry producer="chain4" in="00:00:00.000" out="00:00:30.640"/>
|
||||
</playlist>
|
||||
<producer id="black" in="00:00:00.000" out="00:00:20.360">
|
||||
<property name="length">00:00:20.400</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">0</property>
|
||||
<property name="aspect_ratio">1</property>
|
||||
<property name="mlt_service">color</property>
|
||||
<property name="mlt_image_format">rgba</property>
|
||||
<property name="set.test_audio">0</property>
|
||||
</producer>
|
||||
<playlist id="background">
|
||||
<entry producer="black" in="00:00:00.000" out="00:00:20.360"/>
|
||||
</playlist>
|
||||
<chain id="chain5" out="00:00:08.920">
|
||||
<property name="length">00:00:08.960</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">C:/Users/Atakan Doğan Özban/Desktop/5657843-uhd_4096_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">4096</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">22478882</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2020-10-21T09:34:54.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2020-10-21T09:34:54.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2020-10-21T09:34:54</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">4096</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">a680baf367e6ad49fff40416e3d971cd</property>
|
||||
<property name="ignore_points">0</property>
|
||||
<property name="meta.media.top_field_first">0</property>
|
||||
<property name="meta.media.progressive">1</property>
|
||||
<property name="shotcut:caption">5657843-uhd_4096_2160_25fps.mp4</property>
|
||||
</chain>
|
||||
<chain id="chain6" out="00:00:09.880">
|
||||
<property name="length">00:00:09.920</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">C:/Users/Atakan Doğan Özban/Desktop/7507373-uhd_3840_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">3840</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">22476258</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2021-04-14T11:58:42.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2021-04-14T11:58:42.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2021-04-14T11:58:42</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">3840</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">54400e51ad7e058f46fefae726dc0f77</property>
|
||||
<property name="meta.media.top_field_first">0</property>
|
||||
<property name="meta.media.progressive">1</property>
|
||||
<property name="shotcut:caption">7507373-uhd_3840_2160_25fps.mp4</property>
|
||||
</chain>
|
||||
<chain id="chain7" out="00:00:30.640">
|
||||
<property name="length">00:00:30.680</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">C:/Users/Atakan Doğan Özban/Desktop/9006055-hd_1920_1080_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">1920</property>
|
||||
<property name="meta.media.0.codec.height">1080</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">5666771</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2021-07-31T17:21:02.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2021-07-31T17:21:02.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2021-07-31T17:21:02</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">1920</property>
|
||||
<property name="meta.media.height">1080</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">153beb054a7e25fb75735a4129b379f2</property>
|
||||
<property name="meta.media.top_field_first">0</property>
|
||||
<property name="meta.media.progressive">1</property>
|
||||
<property name="shotcut:caption">9006055-hd_1920_1080_25fps.mp4</property>
|
||||
</chain>
|
||||
<chain id="chain8" out="00:00:07.520">
|
||||
<property name="length">00:00:07.560</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">C:/Users/Atakan Doğan Özban/Desktop/12336223-uhd_3840_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">2</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">3840</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">26564154</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2022-06-01T09:11:45.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.media.1.stream.type">audio</property>
|
||||
<property name="meta.media.1.codec.sample_fmt">fltp</property>
|
||||
<property name="meta.media.1.codec.sample_rate">48000</property>
|
||||
<property name="meta.media.1.codec.channels">2</property>
|
||||
<property name="meta.media.1.codec.layout">stereo</property>
|
||||
<property name="meta.media.1.codec.name">aac</property>
|
||||
<property name="meta.media.1.codec.long_name">AAC (Advanced Audio Coding)</property>
|
||||
<property name="meta.media.1.codec.bit_rate">253375</property>
|
||||
<property name="meta.attr.1.stream.creation_time.markup">2022-06-01T09:11:45.000000Z</property>
|
||||
<property name="meta.attr.1.stream.handler_name.markup">L-SMASH Audio Handler</property>
|
||||
<property name="meta.attr.1.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2022-06-01T09:11:45.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2022-06-01T09:11:45</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">3840</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="astream">0</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">deedb8954e1edb1403f9bfcfeec06fd4</property>
|
||||
<property name="meta.media.top_field_first">0</property>
|
||||
<property name="meta.media.progressive">1</property>
|
||||
<property name="shotcut:caption">12336223-uhd_3840_2160_25fps.mp4</property>
|
||||
</chain>
|
||||
<chain id="chain9" out="00:00:13.280">
|
||||
<property name="length">00:00:13.320</property>
|
||||
<property name="eof">pause</property>
|
||||
<property name="resource">C:/Users/Atakan Doğan Özban/Desktop/5657838-uhd_4096_2160_25fps.mp4</property>
|
||||
<property name="mlt_service">avformat-novalidate</property>
|
||||
<property name="meta.media.nb_streams">1</property>
|
||||
<property name="meta.media.0.stream.type">video</property>
|
||||
<property name="meta.media.0.stream.frame_rate">25</property>
|
||||
<property name="meta.media.0.stream.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.width">4096</property>
|
||||
<property name="meta.media.0.codec.height">2160</property>
|
||||
<property name="meta.media.0.codec.rotate">0</property>
|
||||
<property name="meta.media.0.codec.pix_fmt">yuv420p</property>
|
||||
<property name="meta.media.0.codec.sample_aspect_ratio">0</property>
|
||||
<property name="meta.media.0.codec.colorspace">709</property>
|
||||
<property name="meta.media.0.codec.color_trc">1</property>
|
||||
<property name="meta.media.0.codec.name">h264</property>
|
||||
<property name="meta.media.0.codec.long_name">H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10</property>
|
||||
<property name="meta.media.0.codec.bit_rate">15838633</property>
|
||||
<property name="meta.attr.0.stream.creation_time.markup">2020-10-21T09:36:44.000000Z</property>
|
||||
<property name="meta.attr.0.stream.handler_name.markup">L-SMASH Video Handler</property>
|
||||
<property name="meta.attr.0.stream.vendor_id.markup">[0][0][0][0]</property>
|
||||
<property name="meta.attr.0.stream.encoder.markup">AVC Coding</property>
|
||||
<property name="meta.attr.major_brand.markup">mp42</property>
|
||||
<property name="meta.attr.minor_version.markup">0</property>
|
||||
<property name="meta.attr.compatible_brands.markup">mp42mp41isomavc1</property>
|
||||
<property name="meta.attr.creation_time.markup">2020-10-21T09:36:44.000000Z</property>
|
||||
<property name="seekable">1</property>
|
||||
<property name="meta.media.sample_aspect_num">1</property>
|
||||
<property name="meta.media.sample_aspect_den">1</property>
|
||||
<property name="audio_index">-1</property>
|
||||
<property name="video_index">0</property>
|
||||
<property name="creation_time">2020-10-21T09:36:44</property>
|
||||
<property name="vstream">0</property>
|
||||
<property name="meta.media.frame_rate_num">25</property>
|
||||
<property name="meta.media.frame_rate_den">1</property>
|
||||
<property name="meta.media.colorspace">709</property>
|
||||
<property name="meta.media.color_trc">1</property>
|
||||
<property name="meta.media.has_b_frames">2</property>
|
||||
<property name="meta.media.width">4096</property>
|
||||
<property name="meta.media.height">2160</property>
|
||||
<property name="meta.media.aspect_ratio">1</property>
|
||||
<property name="meta.media.color_range">mpeg</property>
|
||||
<property name="shotcut:skipConvert">1</property>
|
||||
<property name="xml">was here</property>
|
||||
<property name="shotcut:hash">a8579d660409a043fa3836308c1d07fc</property>
|
||||
<property name="meta.media.top_field_first">0</property>
|
||||
<property name="meta.media.progressive">1</property>
|
||||
<property name="shotcut:caption">5657838-uhd_4096_2160_25fps.mp4</property>
|
||||
</chain>
|
||||
<playlist id="playlist0">
|
||||
<property name="shotcut:video">1</property>
|
||||
<property name="shotcut:name">V1</property>
|
||||
<entry producer="chain5" in="00:00:00.000" out="00:00:03.000"/>
|
||||
<entry producer="chain6" in="00:00:00.000" out="00:00:03.720"/>
|
||||
<entry producer="chain7" in="00:00:00.000" out="00:00:03.400"/>
|
||||
<entry producer="chain8" in="00:00:00.000" out="00:00:03.440"/>
|
||||
<entry producer="chain9" in="00:00:00.000" out="00:00:06.640"/>
|
||||
</playlist>
|
||||
<tractor id="tractor0" title="Shotcut version 25.07.26" in="00:00:00.000" out="00:00:20.360">
|
||||
<property name="shotcut">1</property>
|
||||
<property name="shotcut:scaleFactor">5.11712</property>
|
||||
<property name="shotcut:projectAudioChannels">2</property>
|
||||
<property name="shotcut:projectFolder">1</property>
|
||||
<track producer="background"/>
|
||||
<track producer="playlist0"/>
|
||||
<transition id="transition0">
|
||||
<property name="a_track">0</property>
|
||||
<property name="b_track">1</property>
|
||||
<property name="mlt_service">mix</property>
|
||||
<property name="always_active">1</property>
|
||||
<property name="sum">1</property>
|
||||
</transition>
|
||||
<transition id="transition1">
|
||||
<property name="a_track">0</property>
|
||||
<property name="b_track">1</property>
|
||||
<property name="version">0.1</property>
|
||||
<property name="mlt_service">frei0r.cairoblend</property>
|
||||
<property name="threads">0</property>
|
||||
<property name="disable">1</property>
|
||||
</transition>
|
||||
</tractor>
|
||||
</mlt>
|
||||
@@ -1,95 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { API_DOCS_URL } from "@/lib/plans";
|
||||
|
||||
type ApiKeyStatus = {
|
||||
configured: boolean;
|
||||
prefix: string | null;
|
||||
};
|
||||
|
||||
type RateLimitStatus = {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
windowSeconds: number;
|
||||
resetsInSeconds: number;
|
||||
bonus?: number;
|
||||
};
|
||||
|
||||
type ExtensionRequest = {
|
||||
id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
requestedAt: string;
|
||||
processedAt: string | null;
|
||||
adminNote: string | null;
|
||||
};
|
||||
|
||||
type ExtensionUsage = {
|
||||
used: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
requests: ExtensionRequest[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialStatus: ApiKeyStatus;
|
||||
initialRateLimit: RateLimitStatus;
|
||||
initialExtensionUsage: ExtensionUsage;
|
||||
initialStatus: { configured: boolean; prefix: string | null };
|
||||
initialRateLimit: {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
windowSeconds: number;
|
||||
resetsInSeconds: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function ApiKeySettings({
|
||||
initialStatus,
|
||||
initialRateLimit,
|
||||
initialExtensionUsage,
|
||||
}: Props) {
|
||||
export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
const [rateLimit, setRateLimit] = useState(initialRateLimit);
|
||||
const [extensionUsage, setExtensionUsage] = useState(initialExtensionUsage);
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const refresh = () => {
|
||||
fetch("/api/account/api-rate-limit")
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (active) setRateLimit(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
refresh();
|
||||
const id = setInterval(refresh, 5000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function generateKey() {
|
||||
if (
|
||||
status.configured &&
|
||||
!confirm("This will replace your existing API key. Continue?")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.configured && !confirm("Replace your existing API key?")) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setNewKey(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/api-key", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to generate API key");
|
||||
|
||||
setNewKey(data.apiKey);
|
||||
setStatus({ configured: true, prefix: data.prefix });
|
||||
} catch (err) {
|
||||
@@ -100,17 +39,13 @@ export function ApiKeySettings({
|
||||
}
|
||||
|
||||
async function revokeKey() {
|
||||
if (!confirm("Revoke your API key? External integrations will stop working.")) return;
|
||||
|
||||
if (!confirm("Revoke your API key?")) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/api-key", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to revoke API key");
|
||||
|
||||
setStatus({ configured: false, prefix: null });
|
||||
setNewKey(null);
|
||||
} catch (err) {
|
||||
@@ -120,143 +55,35 @@ export function ApiKeySettings({
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRateLimitExtension() {
|
||||
if (extensionUsage.remaining <= 0) return;
|
||||
|
||||
const reason = prompt(
|
||||
"Optional: tell us why you need a higher API rate limit (leave blank to skip).",
|
||||
);
|
||||
if (reason === null) return;
|
||||
|
||||
setRequesting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/api-rate-limit-extension-request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: reason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to submit request");
|
||||
|
||||
setExtensionUsage({
|
||||
used: data.used,
|
||||
limit: data.limit,
|
||||
remaining: data.remaining,
|
||||
requests: data.requests ?? extensionUsage.requests,
|
||||
});
|
||||
setSuccess(
|
||||
`Rate limit extension request submitted. ${data.used} of ${data.limit} used this year.`,
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to submit request");
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasPending = extensionUsage.requests.some((r) => r.status === "PENDING");
|
||||
const usedPct = Math.min(100, Math.round((rateLimit.used / Math.max(1, rateLimit.limit)) * 100));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
Use the REST API to upload files and create batch video jobs programmatically. Pro plan
|
||||
only.
|
||||
Use the REST API to upload files and create video jobs programmatically. There are no
|
||||
billing endpoints in this self-hosted edition.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Rate limit: {initialRateLimit.limit} requests per {initialRateLimit.windowSeconds} seconds.
|
||||
</p>
|
||||
|
||||
<div className="rounded border border-gray-800 bg-surface p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-white">API rate limit</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Resets in {rateLimit.resetsInSeconds}s
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
{rateLimit.used} / {rateLimit.limit} requests used this minute
|
||||
{rateLimit.bonus ? (
|
||||
<span className="text-gray-500"> (includes +{rateLimit.bonus} bonus)</span>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="h-2 overflow-hidden rounded bg-gray-800">
|
||||
<div
|
||||
className={`h-full rounded transition-all ${
|
||||
usedPct >= 90 ? "bg-red-500" : usedPct >= 70 ? "bg-amber-500" : "bg-accent"
|
||||
}`}
|
||||
style={{ width: `${usedPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{rateLimit.remaining} requests remaining · window {rateLimit.windowSeconds}s
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-gray-400">
|
||||
Extension requests this year: {extensionUsage.used} / {extensionUsage.limit}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestRateLimitExtension}
|
||||
disabled={requesting || extensionUsage.remaining <= 0 || hasPending}
|
||||
className="inline-flex items-center justify-center rounded border border-accent/40 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/10 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{requesting
|
||||
? "Submitting…"
|
||||
: hasPending
|
||||
? "Pending request"
|
||||
: extensionUsage.remaining <= 0
|
||||
? "No extensions left"
|
||||
: "Request rate limit extension"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{extensionUsage.requests.length > 0 && (
|
||||
<ul className="space-y-1 border-t border-gray-800 pt-3 text-xs text-gray-500">
|
||||
{extensionUsage.requests.slice(0, 5).map((req) => (
|
||||
<li key={req.id}>
|
||||
{new Date(req.requestedAt).toLocaleDateString()} · {req.status}
|
||||
{req.adminNote ? ` · ${req.adminNote}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status.configured && status.prefix && (
|
||||
<p className="text-sm text-gray-300">
|
||||
Active key: <span className="font-mono text-white">{status.prefix}…</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{newKey && (
|
||||
<div className="rounded border border-green-500/40 bg-green-500/10 p-4">
|
||||
<p className="mb-2 text-sm font-medium text-green-300">Your new API key (copy now)</p>
|
||||
<p className="mb-2 text-sm font-medium text-green-300">Copy your new API key now</p>
|
||||
<code className="block break-all rounded bg-black/40 px-3 py-2 text-xs text-green-200">
|
||||
{newKey}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-300">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
{error && <p className="text-sm text-red-300">{error}</p>}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateKey}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Working…" : status.configured ? "Regenerate API key" : "Generate API key"}
|
||||
</button>
|
||||
@@ -265,37 +92,19 @@ export function ApiKeySettings({
|
||||
type="button"
|
||||
onClick={revokeKey}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
className="rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
Revoke API key
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-gray-800 bg-surface p-4 text-xs text-gray-400">
|
||||
<p className="mb-2 font-semibold uppercase tracking-wider text-gray-500">Endpoints</p>
|
||||
<ul className="space-y-2 font-mono">
|
||||
<li>POST /api/v1/upload: upload image or audio file</li>
|
||||
<li>POST /api/v1/jobs: create job from paths (recommended for large batches)</li>
|
||||
<li>POST /api/v1/jobs/batch: small packs only (one-shot multipart)</li>
|
||||
<li>GET /api/v1/playlists: list YouTube playlists</li>
|
||||
<li>POST /api/v1/playlists: create a YouTube playlist</li>
|
||||
<li>GET /api/v1/jobs: list jobs</li>
|
||||
<li>GET /api/v1/jobs/:id: job status</li>
|
||||
</ul>
|
||||
<p className="mt-3">
|
||||
Send <span className="text-gray-300">Authorization: Bearer YOUR_API_KEY</span> on every
|
||||
request. For many audio files, upload each file then call{" "}
|
||||
<span className="text-gray-300">/api/v1/jobs</span> (avoid large one-shot batches).{" "}
|
||||
<a
|
||||
href={API_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Full API docs
|
||||
</a>
|
||||
</p>
|
||||
<a
|
||||
href={API_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded border border-gray-600 px-4 py-2 text-sm text-gray-300 hover:text-white"
|
||||
>
|
||||
API documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { useMockupProgress } from "@/hooks/useMockupProgress";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
|
||||
function FilmStripIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M2 8h20M2 16h20M6 4v4M6 16v4M10 4v4M10 16v4M14 4v4M14 16v4M18 4v4M18 16v4" />
|
||||
<path d="m15 9 3 3-3 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function YouTubeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8z" />
|
||||
<path fill="#12121f" d="M9.75 15.02l6.35-3.02-6.35-3.02v6.04z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CoinsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<ellipse cx="8" cy="6" rx="5" ry="2" />
|
||||
<path d="M3 6v4c0 1.1 2.24 2 5 2s5-.9 5-2V6" />
|
||||
<path d="M3 10v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
|
||||
<ellipse cx="16" cy="14" rx="5" ry="2" />
|
||||
<path d="M11 14v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaylistIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 6h12M4 12h12M4 18h8" strokeLinecap="round" />
|
||||
<path d="M17 14.5v5l4-2.5-4-2.5z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WaveIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 12h1M7 12h1M10 8v8M13 10v4M16 7v10M19 11v2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GearsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const BENEFITS = [
|
||||
{
|
||||
title: "No editing required",
|
||||
desc: "Skip complex video editors. Just upload an image and audio, and Songs2VID handles the rest.",
|
||||
Icon: FilmStripIcon,
|
||||
},
|
||||
{
|
||||
title: "YouTube-ready output",
|
||||
desc: "Videos are encoded and uploaded with the metadata you set, ready for your channel.",
|
||||
Icon: YouTubeIcon,
|
||||
},
|
||||
{
|
||||
title: "Free tier included",
|
||||
desc: "Start creating with 10 videos every month at up to 720p. No credit card needed.",
|
||||
Icon: CoinsIcon,
|
||||
},
|
||||
{
|
||||
title: "YouTube playlists",
|
||||
desc: "Independent Artist (Pro) can create YouTube playlists and add every upload from the dashboard or API.",
|
||||
Icon: PlaylistIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const STOCK_THUMBS = [
|
||||
"https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1571330735066-03aaa9429d89?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1619983081563-430f63602796?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1483412033650-1015ddeb83d1?w=120&h=120&fit=crop&auto=format",
|
||||
] as const;
|
||||
|
||||
function TypewriterText({ text }: { text: string }) {
|
||||
const [displayed, setDisplayed] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let index = 0;
|
||||
const interval = setInterval(() => {
|
||||
index += 1;
|
||||
setDisplayed(text.slice(0, index));
|
||||
if (index >= text.length) {
|
||||
clearInterval(interval);
|
||||
setDone(true);
|
||||
}
|
||||
}, 45);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<span className="text-xs font-medium text-gray-300">
|
||||
{displayed}
|
||||
{!done && <span className="ml-0.5 inline-block w-[2px] animate-pulse bg-red-400">|</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitCard({
|
||||
title,
|
||||
desc,
|
||||
Icon,
|
||||
}: {
|
||||
title: string;
|
||||
desc: string;
|
||||
Icon: typeof FilmStripIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-xl border border-red-500/20 bg-surface p-6 shadow-[0_0_24px_rgba(239,68,68,0.08)] transition-all duration-300 hover:border-red-500/40 hover:shadow-[0_0_32px_rgba(239,68,68,0.18)]">
|
||||
<Icon className="absolute right-5 top-5 h-8 w-8 text-gray-600 transition-colors duration-300 group-hover:text-red-500/50" />
|
||||
<h3 className="pr-12 text-lg font-semibold text-white">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-gray-400">{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MockupProgressRow({
|
||||
initialWidth,
|
||||
midWidth,
|
||||
active,
|
||||
phaseOneMs = 3000,
|
||||
phaseTwoMs = 2000,
|
||||
}: {
|
||||
initialWidth: string;
|
||||
midWidth: string;
|
||||
active: boolean;
|
||||
phaseOneMs?: number;
|
||||
phaseTwoMs?: number;
|
||||
}) {
|
||||
const { phase, processed, transitionMs } = useMockupProgress(active, phaseOneMs, phaseTwoMs);
|
||||
|
||||
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] text-gray-500">{processed ? "Processed" : "Processing..."}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1 min-w-0 flex-1 rounded-full bg-gray-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-white/80 ease-out"
|
||||
style={{
|
||||
width,
|
||||
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={`h-3.5 w-3.5 shrink-0 text-accent transition-all duration-300 ${
|
||||
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardMockup() {
|
||||
const { ref, inView } = useInView();
|
||||
const sidebarIcons = [NoteIcon, WaveIcon, GearsIcon] as const;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex w-11 shrink-0 flex-col items-center gap-3 rounded-lg bg-surface py-3">
|
||||
<div className="h-2 w-2 rounded-full bg-gray-500" />
|
||||
{sidebarIcons.map((Icon, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-gray-700/50 bg-surface-dark text-gray-500"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<TypewriterText text="Multi-step configuration" />
|
||||
<div className="h-4 w-8 rounded-full bg-red-500/80" />
|
||||
</div>
|
||||
<div className="h-7 rounded border border-gray-700 bg-surface px-2 text-xs leading-7 text-gray-500">
|
||||
Title
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">genre</span>
|
||||
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">audio</span>
|
||||
</div>
|
||||
<div className="flex h-7 items-center justify-between rounded border border-gray-700 bg-surface px-2 text-xs text-gray-500">
|
||||
Category <span className="text-gray-400">Music ▾</span>
|
||||
</div>
|
||||
<div className="space-y-2 pt-1">
|
||||
<MockupProgressRow active={inView} initialWidth="25%" midWidth="45%" phaseOneMs={3000} phaseTwoMs={2000} />
|
||||
<MockupProgressRow active={inView} initialWidth="10%" midWidth="55%" phaseOneMs={3000} phaseTwoMs={2000} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProcessFlow() {
|
||||
return (
|
||||
<div className="relative mt-4 overflow-hidden rounded-xl border border-gray-700/50 bg-surface-dark p-5">
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
viewBox="0 0 420 240"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="flowGradH" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="rgb(239 68 68 / 0.15)" />
|
||||
<stop offset="50%" stopColor="rgb(239 68 68 / 0.55)" />
|
||||
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="flowGradV" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="rgb(239 68 68 / 0.5)" />
|
||||
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path
|
||||
d="M 58 52 C 120 52, 140 58, 168 72"
|
||||
fill="none"
|
||||
stroke="url(#flowGradH)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
/>
|
||||
<path
|
||||
d="M 58 118 C 120 108, 140 88, 168 78"
|
||||
fill="none"
|
||||
stroke="url(#flowGradH)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
style={{ animationDelay: "0.3s" }}
|
||||
/>
|
||||
<path
|
||||
d="M 198 75 C 250 75, 285 68, 318 68"
|
||||
fill="none"
|
||||
stroke="url(#flowGradH)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
style={{ animationDelay: "0.6s" }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<FlowInput label="Batch Audio Files" icon="audio" />
|
||||
<FlowInput label="Single Cover Image" icon="image" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center px-2 pt-6">
|
||||
<div className="benefits-process-icon flex h-16 w-16 items-center justify-center rounded-xl border border-red-500/40 bg-surface shadow-[0_0_20px_rgba(239,68,68,0.25)]">
|
||||
<Image
|
||||
src="/database.png"
|
||||
alt="Process"
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 object-contain brightness-0 invert opacity-90"
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 text-[10px] font-medium text-gray-400">Process</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{STOCK_THUMBS.map((src) => (
|
||||
<div
|
||||
key={src}
|
||||
className="relative h-11 w-11 overflow-hidden rounded border border-gray-700"
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="44px"
|
||||
className="scale-110 object-cover blur-[2px]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/25" />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[9px] text-white/80">
|
||||
▶
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
className="my-2 h-10 w-6 overflow-visible"
|
||||
viewBox="0 0 6 40"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line
|
||||
x1="3"
|
||||
y1="2"
|
||||
x2="3"
|
||||
y2="38"
|
||||
stroke="rgb(239 68 68 / 0.55)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
style={{ animationDelay: "0.9s" }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<YouTubeIcon className="h-9 w-9 text-red-500" />
|
||||
<div>
|
||||
<p className="text-xs font-bold tracking-wider text-white">YouTube</p>
|
||||
<p className="text-[10px] font-semibold tracking-[0.15em] text-red-400">DIRECT UPLOAD</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowInput({ label, icon }: { label: string; icon: "audio" | "image" }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-gray-700 bg-surface">
|
||||
{icon === "audio" ? (
|
||||
<NoteIcon className="h-5 w-5 text-gray-500" />
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="max-w-[72px] text-center text-[9px] leading-tight text-gray-500">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitsVisual() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-700/50 bg-surface p-5 shadow-2xl shadow-black/30">
|
||||
<DashboardMockup />
|
||||
<ProcessFlow />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitsScrollTitle({ sectionRef }: { sectionRef: React.RefObject<HTMLElement | null> }) {
|
||||
return <SectionScrollTitle sectionRef={sectionRef} title="Benefits" />;
|
||||
}
|
||||
|
||||
export function BenefitsSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="benefits"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
|
||||
>
|
||||
<BenefitsScrollTitle sectionRef={sectionRef} />
|
||||
<div className="relative z-10 mx-auto max-w-7xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-12 text-center text-3xl font-bold text-white">Benefits</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.15fr] lg:gap-12">
|
||||
<div className="flex flex-col gap-5">
|
||||
{BENEFITS.map((benefit, index) => (
|
||||
<ScrollReveal key={benefit.title} delay={index * 100} direction="left">
|
||||
<BenefitCard title={benefit.title} desc={benefit.desc} Icon={benefit.Icon} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ScrollReveal delay={200} direction="right">
|
||||
<BenefitsVisual />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { UpgradeProButton } from "@/components/UpgradeProButton";
|
||||
import {
|
||||
CREDIT_PRICE_CENTS,
|
||||
FREE_EXTRA_CREDITS_MAX,
|
||||
FREE_TOP_UP_MAX,
|
||||
FREE_TOP_UP_MIN,
|
||||
formatCreditPrice,
|
||||
formatEuroFromCents,
|
||||
} from "@/lib/credits";
|
||||
|
||||
type Purchase = {
|
||||
id: string;
|
||||
credits: number;
|
||||
amountCents: number;
|
||||
completedAt: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialCredits: number;
|
||||
stripeConfigured: boolean;
|
||||
recentPurchases?: Purchase[];
|
||||
};
|
||||
|
||||
export function CreditPurchasePanel({
|
||||
initialCredits,
|
||||
stripeConfigured,
|
||||
recentPurchases = [],
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [creditsBalance, setCreditsBalance] = useState(initialCredits);
|
||||
const [amount, setAmount] = useState(FREE_TOP_UP_MIN);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const room = Math.max(0, FREE_EXTRA_CREDITS_MAX - creditsBalance);
|
||||
const maxBuyable = Math.min(FREE_TOP_UP_MAX, room);
|
||||
const minBuyable = room < FREE_TOP_UP_MIN ? 0 : FREE_TOP_UP_MIN;
|
||||
const atCap = room === 0;
|
||||
|
||||
const totalLabel = useMemo(() => formatCreditPrice(amount), [amount]);
|
||||
const unitLabel = formatCreditPrice(1);
|
||||
|
||||
async function handleBuy() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/account/credits", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ credits: amount }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Checkout failed");
|
||||
|
||||
if (data.mocked) {
|
||||
setCreditsBalance((c) => c + (data.granted ?? amount));
|
||||
setMessage(
|
||||
`Granted +${data.granted ?? amount} credits for ${formatEuroFromCents(data.amountCents ?? amount * CREDIT_PRICE_CENTS)} (dev mock).`,
|
||||
);
|
||||
setLoading(false);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.url) throw new Error("No checkout URL returned");
|
||||
window.location.href = data.url;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Checkout failed");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function clampAmount(value: number) {
|
||||
if (maxBuyable < FREE_TOP_UP_MIN) return FREE_TOP_UP_MIN;
|
||||
return Math.min(maxBuyable, Math.max(FREE_TOP_UP_MIN, value));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-gray-800 pt-6">
|
||||
<h3 className="mb-1 text-sm font-semibold uppercase tracking-wider text-gray-500">
|
||||
Extra credits
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-gray-400">
|
||||
Free plan: 10 monthly videos + up to {FREE_EXTRA_CREDITS_MAX} paid extras (
|
||||
{unitLabel} each). After both are used, Pro is required.
|
||||
</p>
|
||||
|
||||
<div className="mb-4 rounded border border-gray-800 bg-surface p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Extra credit balance
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-white">
|
||||
{Math.min(creditsBalance, FREE_EXTRA_CREDITS_MAX)}
|
||||
<span className="text-base font-normal text-gray-400">
|
||||
{" "}
|
||||
/ {FREE_EXTRA_CREDITS_MAX} credits
|
||||
</span>
|
||||
</p>
|
||||
{creditsBalance > FREE_EXTRA_CREDITS_MAX && (
|
||||
<p className="mt-1 text-xs text-amber-400">
|
||||
You have {creditsBalance} extras from earlier purchases; no further top-ups until
|
||||
under {FREE_EXTRA_CREDITS_MAX}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{atCap ? (
|
||||
<div className="space-y-2 rounded border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-100">
|
||||
<p>
|
||||
Free extras are capped at {FREE_EXTRA_CREDITS_MAX}. When your monthly 10 and these
|
||||
extras are gone, continue with Pro (€5/mo · 50 videos).
|
||||
</p>
|
||||
<UpgradeProButton className="font-medium text-accent underline hover:text-white" />
|
||||
</div>
|
||||
) : !stripeConfigured ? (
|
||||
<p className="text-sm text-amber-400">
|
||||
Card checkout is not configured yet (missing Stripe keys). In development you can also
|
||||
use{" "}
|
||||
<code className="text-gray-300">POST /api/dev/grant-credits</code>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm text-gray-300">
|
||||
Credits to buy ({FREE_TOP_UP_MIN}–{maxBuyable})
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={minBuyable || FREE_TOP_UP_MIN}
|
||||
max={maxBuyable}
|
||||
value={clampAmount(amount)}
|
||||
onChange={(e) => setAmount(clampAmount(Number(e.target.value)))}
|
||||
className="w-full accent-[var(--accent,#3b82f6)]"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={FREE_TOP_UP_MIN}
|
||||
max={maxBuyable}
|
||||
value={clampAmount(amount)}
|
||||
onChange={(e) =>
|
||||
setAmount(
|
||||
clampAmount(Math.floor(Number(e.target.value)) || FREE_TOP_UP_MIN),
|
||||
)
|
||||
}
|
||||
className="w-16 rounded border border-gray-700 bg-surface px-2 py-1 text-center text-white"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<p className="text-sm text-gray-300">
|
||||
Total:{" "}
|
||||
<span className="font-semibold text-white">{totalLabel}</span>
|
||||
<span className="text-gray-500">
|
||||
{" "}
|
||||
({unitLabel} × {clampAmount(amount)})
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={handleBuy}
|
||||
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? "Starting checkout…"
|
||||
: `Buy ${clampAmount(amount)} credits (${totalLabel})`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && <p className="mt-3 text-sm text-emerald-400">{message}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
|
||||
|
||||
{recentPurchases.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Recent purchases
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-gray-400">
|
||||
{recentPurchases.map((p) => (
|
||||
<li key={p.id}>
|
||||
+{p.credits} credits · {formatEuroFromCents(p.amountCents)}
|
||||
{p.completedAt
|
||||
? ` · ${new Date(p.completedAt).toLocaleDateString()}`
|
||||
: ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
import { DOCKER_HUB_URL, GITEA_URL } from "@/lib/plans";
|
||||
|
||||
function DockerIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Docker">
|
||||
<path
|
||||
fill="#2496ED"
|
||||
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GiteaIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Gitea">
|
||||
<path
|
||||
fill="#609926"
|
||||
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const DEPLOY_CARDS = [
|
||||
{
|
||||
name: "Docker Hub",
|
||||
desc: "Pull the image and run Songs2VID on your own server with Docker Compose.",
|
||||
Icon: DockerIcon,
|
||||
cta: "View on Docker Hub",
|
||||
href: DOCKER_HUB_URL,
|
||||
accent: "text-[#2496ED]",
|
||||
hover:
|
||||
"hover:border-[#2496ED]/45 hover:bg-[#2496ED]/[0.06] hover:shadow-lg hover:shadow-[#2496ED]/20",
|
||||
titleHover: "group-hover:text-[#2496ED]",
|
||||
},
|
||||
{
|
||||
name: "Gitea",
|
||||
desc: "Clone the open-source repository and deploy from source on your infrastructure.",
|
||||
Icon: GiteaIcon,
|
||||
cta: "View on Gitea",
|
||||
href: GITEA_URL,
|
||||
accent: "text-[#609926]",
|
||||
hover:
|
||||
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
|
||||
titleHover: "group-hover:text-[#609926]",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function DeployCard({
|
||||
name,
|
||||
desc,
|
||||
Icon,
|
||||
cta,
|
||||
href,
|
||||
accent,
|
||||
hover,
|
||||
titleHover,
|
||||
}: (typeof DEPLOY_CARDS)[number]) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`group block rounded-xl border border-gray-700/50 bg-surface p-6 transition-all duration-300 ${hover}`}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Icon className="h-8 w-8 transition-transform duration-300 group-hover:scale-110" />
|
||||
<span className="rounded bg-gray-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-gray-500 transition-colors duration-300 group-hover:bg-gray-700/80">
|
||||
Open Source
|
||||
</span>
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}>
|
||||
{name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
|
||||
{desc}
|
||||
</p>
|
||||
<p className={`mt-4 text-sm font-medium ${accent} transition-all duration-300 group-hover:underline`}>
|
||||
{cta}
|
||||
</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="download"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
|
||||
>
|
||||
<SectionScrollTitle sectionRef={sectionRef} title="Download" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-4 text-center text-3xl font-bold text-white">Download</h2>
|
||||
<p className="mx-auto mb-12 max-w-2xl text-center text-gray-400">
|
||||
Songs2VID is open source. Self-host on your own server with Docker or deploy from source.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.1fr] lg:gap-12">
|
||||
<ScrollReveal direction="left">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-white">Run it yourself</h3>
|
||||
<p className="mt-3 leading-relaxed text-gray-400">
|
||||
Host Songs2VID on your infrastructure with full control over data, queues, and
|
||||
storage. Ideal for teams and creators who want a private deployment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 text-sm text-gray-400">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
Docker image published to Docker Hub
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
Source code available on Gitea
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
Gitea Issues community support
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
PostgreSQL, Redis, FFmpeg, and worker included
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
|
||||
<p className="mb-3 text-xs font-medium text-gray-500">Quick start</p>
|
||||
<pre className="overflow-x-auto text-sm leading-relaxed text-gray-300">
|
||||
<code>{`docker pull atakanozban/songs2vid:latest\ndocker compose up -d`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
|
||||
{DEPLOY_CARDS.map((card, index) => (
|
||||
<ScrollReveal key={card.name} delay={index * 120} direction="right">
|
||||
<DeployCard {...card} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import {
|
||||
DOCKER_HUB_URL,
|
||||
DOCS_URL,
|
||||
GITEA_ISSUES_URL,
|
||||
GITEA_URL,
|
||||
SUPPORT_EMAIL,
|
||||
} from "@/lib/plans";
|
||||
|
||||
function GiteaIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DockerIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink({
|
||||
href,
|
||||
children,
|
||||
external,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
external?: boolean;
|
||||
}) {
|
||||
const className = "transition-colors duration-300 hover:text-white";
|
||||
|
||||
if (external || href.startsWith("#") || href.startsWith("mailto:")) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalLink({
|
||||
href,
|
||||
children,
|
||||
external,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
external?: boolean;
|
||||
}) {
|
||||
const className = "transition-colors duration-300 hover:text-gray-300";
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_URL = "https://status.atakanozban.com/status/2";
|
||||
const DOCS_INTRO_URL = `${DOCS_URL.replace(/\/$/, "")}/docs/intro`;
|
||||
const DOCS_API_URL = `${DOCS_URL.replace(/\/$/, "")}/docs/api/overview`;
|
||||
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="mt-20 w-full border-t border-gray-800 bg-black/40 pb-8 pt-16 text-sm text-gray-400">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="grid grid-cols-1 gap-8 pb-12 md:grid-cols-12">
|
||||
<div className="flex flex-col gap-4 md:col-span-5">
|
||||
<Logo size="sm" beta />
|
||||
<p className="max-w-sm text-gray-500">
|
||||
Convert your audio tracks into stunning videos for YouTube. Beautiful, automated, and
|
||||
fully open-source.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-4 text-gray-500">
|
||||
<a
|
||||
href={GITEA_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Gitea"
|
||||
className="transition-colors duration-300 hover:text-[#609926]"
|
||||
>
|
||||
<GiteaIcon className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href={DOCKER_HUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Docker Hub"
|
||||
className="transition-colors duration-300 hover:text-[#2496ED]"
|
||||
>
|
||||
<DockerIcon className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="https://www.producthunt.com/products/songs2vid?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-songs2vid"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-block"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt="Songs2VID - Bulk render audio & artworks into videos in seconds | Product Hunt"
|
||||
width={250}
|
||||
height={54}
|
||||
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1206681&theme=dark&t=1785422871345"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-7">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Product
|
||||
</span>
|
||||
<FooterLink href="#pricing">Pricing</FooterLink>
|
||||
<FooterLink href="#download">Download</FooterLink>
|
||||
<FooterLink href="#benefits">Benefits</FooterLink>
|
||||
<FooterLink href={DOCS_INTRO_URL} external>
|
||||
Documentation
|
||||
</FooterLink>
|
||||
<FooterLink href={DOCS_API_URL} external>
|
||||
API Reference
|
||||
</FooterLink>
|
||||
<FooterLink href={STATUS_URL} external>
|
||||
Service Status
|
||||
</FooterLink>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Open Source
|
||||
</span>
|
||||
<FooterLink href={GITEA_URL} external>
|
||||
Gitea Instance
|
||||
</FooterLink>
|
||||
<FooterLink href={DOCKER_HUB_URL} external>
|
||||
Docker Image
|
||||
</FooterLink>
|
||||
<FooterLink href={GITEA_ISSUES_URL} external>
|
||||
Report a Bug
|
||||
</FooterLink>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Contact
|
||||
</span>
|
||||
<FooterLink href={`mailto:${SUPPORT_EMAIL}`}>Support Email</FooterLink>
|
||||
<span className="text-xs text-gray-600">Response within 24h for Pro users</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 border-t border-gray-900 pt-8 text-xs text-gray-500 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 sm:justify-start">
|
||||
<span>© {year} Songs2VID. All rights reserved.</span>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/privacy">Privacy Policy</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/terms">Terms of Service</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/refund">Refund Policy</LegalLink>
|
||||
</div>
|
||||
<span className="shrink-0 text-center sm:text-right">
|
||||
Made with ❤ by{" "}
|
||||
<a
|
||||
href="https://www.atakanozban.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition-colors duration-300 hover:text-gray-300"
|
||||
>
|
||||
atakan
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -21,11 +21,7 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
export function JobProgress({ jobId }: Props) {
|
||||
const [job, setJob] = useState<JobResponse | null>(null);
|
||||
const [quota, setQuota] = useState<{
|
||||
remaining: number;
|
||||
limit: number;
|
||||
totalAvailable: number;
|
||||
plan: string;
|
||||
resetsIn: string;
|
||||
used: number;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -47,11 +43,7 @@ export function JobProgress({ jobId }: Props) {
|
||||
const quotaData = await quotaRes.json();
|
||||
if (active)
|
||||
setQuota({
|
||||
remaining: quotaData.remaining,
|
||||
limit: quotaData.limit,
|
||||
totalAvailable: quotaData.totalAvailable ?? quotaData.remaining,
|
||||
plan: quotaData.plan,
|
||||
resetsIn: quotaData.resetsIn,
|
||||
used: quotaData.used ?? 0,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -91,10 +83,7 @@ export function JobProgress({ jobId }: Props) {
|
||||
</div>
|
||||
{quota && (
|
||||
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
|
||||
{quota.remaining} / {quota.limit} plan · {quota.totalAvailable} total available ·{" "}
|
||||
{quota.plan === "FREE"
|
||||
? `resets ${quota.resetsIn}`
|
||||
: `monthly resets on ${quota.resetsIn}`}
|
||||
{quota.used} videos created
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
export function JsonLd() {
|
||||
const schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
name: "Songs2VID",
|
||||
url: "https://songs2vid.com",
|
||||
operatingSystem: "Web",
|
||||
applicationCategory: "MultimediaApplication",
|
||||
description: "Online tool to convert audio files into YouTube videos.",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { MobileMenuButton, MobileSidebar, sidebarLinkClass } from "@/components/MobileSidebar";
|
||||
import { DOCS_URL } from "@/lib/plans";
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: "#benefits", label: "Benefits" },
|
||||
{ href: "#download", label: "Download" },
|
||||
{ href: "#pricing", label: "Pricing" },
|
||||
{ href: "#support", label: "Support" },
|
||||
] as const;
|
||||
|
||||
const DOCS_HREF = `${DOCS_URL.replace(/\/$/, "")}/docs/intro`;
|
||||
|
||||
function NavAnchor({
|
||||
href,
|
||||
label,
|
||||
onNavigate,
|
||||
className = "text-base font-medium text-gray-300 transition-colors duration-200 hover:text-white",
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
onNavigate?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
const id = href.replace("#", "");
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
window.history.pushState(null, "", href);
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={href} onClick={handleClick} className={className}>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function DocsLink({
|
||||
onNavigate,
|
||||
className = "text-base font-medium text-gray-300 transition-colors duration-200 hover:text-white",
|
||||
}: {
|
||||
onNavigate?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={DOCS_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={className}
|
||||
onClick={() => onNavigate?.()}
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function LandingNavbar() {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="group absolute left-0 right-0 top-0 z-20 bg-transparent transition-all duration-300 hover:bg-black/70 hover:backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
||||
<Logo size="md" beta />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<nav className="hidden items-center gap-10 sm:flex">
|
||||
{NAV_LINKS.map(({ href, label }) => (
|
||||
<NavAnchor key={href} href={href} label={label} />
|
||||
))}
|
||||
<DocsLink />
|
||||
</nav>
|
||||
|
||||
<MobileMenuButton
|
||||
open={menuOpen}
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} title="Menu">
|
||||
{NAV_LINKS.map(({ href, label }) => (
|
||||
<NavAnchor
|
||||
key={href}
|
||||
href={href}
|
||||
label={label}
|
||||
onNavigate={() => setMenuOpen(false)}
|
||||
className={sidebarLinkClass()}
|
||||
/>
|
||||
))}
|
||||
<DocsLink
|
||||
onNavigate={() => setMenuOpen(false)}
|
||||
className={sidebarLinkClass()}
|
||||
/>
|
||||
</MobileSidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { getVideoAttributionText } from "@/lib/branding";
|
||||
import {
|
||||
CURATED_FONTS,
|
||||
SYSTEM_FONT,
|
||||
type CuratedFontKey,
|
||||
type WatermarkFontKey,
|
||||
} from "@/lib/fonts";
|
||||
@@ -19,15 +20,18 @@ import {
|
||||
BLUR_OPACITY_DEFAULT,
|
||||
BLUR_OPACITY_MAX,
|
||||
BLUR_OPACITY_MIN,
|
||||
COMPOSITION_FAMILIES,
|
||||
DEFAULT_LAYOUT,
|
||||
LAYOUT_TEMPLATE_LABELS,
|
||||
LAYOUT_TEMPLATES,
|
||||
TEXT_OFFSET_MAX,
|
||||
TEXT_OFFSET_MIN,
|
||||
TEXT_PADDING_MAX,
|
||||
TEXT_PADDING_MIN,
|
||||
TITLE_ARTIST_GAP_MAX,
|
||||
TITLE_ARTIST_GAP_MIN,
|
||||
compositionFamilyForTemplate,
|
||||
isLowerCornerTemplate,
|
||||
lowerCornerCoverSide,
|
||||
type LayoutSettings,
|
||||
type LayoutTemplate,
|
||||
} from "@/lib/layout";
|
||||
@@ -50,11 +54,12 @@ import {
|
||||
type WatermarkPosition,
|
||||
type WatermarkSettings,
|
||||
} from "@/lib/watermark";
|
||||
import { UpgradeProButton } from "./UpgradeProButton";
|
||||
|
||||
type Props = {
|
||||
locked: boolean;
|
||||
previewImageUrl: string | null;
|
||||
/** Optional custom blur-fill for lower-corner layouts. */
|
||||
previewBackgroundUrl?: string | null;
|
||||
/** On-video song title (art-track layouts). */
|
||||
songTitle: string;
|
||||
artist: string;
|
||||
@@ -66,7 +71,10 @@ type Props = {
|
||||
onWatermarkChange: (next: WatermarkSettings) => void;
|
||||
onUploadLogo: (file: File) => Promise<string>;
|
||||
onUploadFont: (file: File) => Promise<string>;
|
||||
onUploadBackground?: (file: File) => Promise<string>;
|
||||
onClearBackground?: () => void;
|
||||
logoPreviewUrl?: string | null;
|
||||
hasCustomBackground?: boolean;
|
||||
};
|
||||
|
||||
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
|
||||
@@ -122,11 +130,13 @@ function watermarkOverlayStyle(
|
||||
|
||||
function MiniThumb({
|
||||
template,
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
template: LayoutTemplate | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
@@ -151,20 +161,14 @@ function MiniThumb({
|
||||
) : (
|
||||
<div className="relative h-full w-full overflow-hidden bg-gray-700/50">
|
||||
<div className="absolute inset-0 scale-110 bg-gray-600/40 blur-[3px]" />
|
||||
{template === "COVER_LEFT_TEXT_RIGHT" && (
|
||||
{(template === "COVER_LEFT_TEXT_RIGHT" ||
|
||||
template === "COVER_RIGHT_TEXT_LEFT") && (
|
||||
<>
|
||||
<div className="absolute left-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
|
||||
<div className="absolute right-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
|
||||
<div className="absolute right-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
|
||||
</>
|
||||
)}
|
||||
{template === "COVER_RIGHT_TEXT_LEFT" && (
|
||||
<>
|
||||
<div className="absolute right-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
|
||||
<div className="absolute left-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
|
||||
<div className="absolute left-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
|
||||
</>
|
||||
)}
|
||||
{template === "COVER_TOP_TEXT_BOTTOM" && (
|
||||
<>
|
||||
<div className="absolute left-[8%] top-[6%] h-[52%] w-[84%] bg-gray-300" />
|
||||
@@ -179,12 +183,18 @@ function MiniThumb({
|
||||
<div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] rounded bg-white/50" />
|
||||
</>
|
||||
)}
|
||||
{(template === "LOWER_LEFT_COVER_TEXT" ||
|
||||
template === "LOWER_RIGHT_COVER_TEXT") && (
|
||||
<>
|
||||
<div className="absolute bottom-[8%] left-[5%] h-[36%] w-[24%] bg-gray-300" />
|
||||
<div className="absolute bottom-[26%] left-[34%] h-1 w-[40%] rounded bg-white/80" />
|
||||
<div className="absolute bottom-[14%] left-[34%] h-0.5 w-[28%] rounded bg-white/50" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">
|
||||
{isClassic ? "Classic letterbox" : LAYOUT_TEMPLATE_LABELS[template]}
|
||||
</p>
|
||||
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">{label}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -192,8 +202,18 @@ function MiniThumb({
|
||||
function previewCoverStyle(
|
||||
template: LayoutTemplate,
|
||||
padPct: number,
|
||||
textPadding: number,
|
||||
encodeWidth: number,
|
||||
): CSSProperties {
|
||||
const p = `${padPct}%`;
|
||||
// Equal pixel inset on both axes (not % — % of width ≠ % of height on 16:9).
|
||||
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
|
||||
// Assume 16:9 when only width is known; side is usually width-driven for lower corner.
|
||||
const encodeHeight = Math.round((encodeWidth * 9) / 16);
|
||||
const cornerSidePx = scaleFontToPreview(
|
||||
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
|
||||
encodeWidth,
|
||||
);
|
||||
switch (template) {
|
||||
case "COVER_LEFT_TEXT_RIGHT":
|
||||
return {
|
||||
@@ -235,6 +255,24 @@ function previewCoverStyle(
|
||||
maxHeight: "38%",
|
||||
objectFit: "contain",
|
||||
};
|
||||
case "LOWER_LEFT_COVER_TEXT":
|
||||
return {
|
||||
position: "absolute",
|
||||
left: cornerEdgePx,
|
||||
bottom: cornerEdgePx,
|
||||
width: cornerSidePx,
|
||||
height: cornerSidePx,
|
||||
objectFit: "contain",
|
||||
};
|
||||
case "LOWER_RIGHT_COVER_TEXT":
|
||||
return {
|
||||
position: "absolute",
|
||||
right: cornerEdgePx,
|
||||
bottom: cornerEdgePx,
|
||||
width: cornerSidePx,
|
||||
height: cornerSidePx,
|
||||
objectFit: "contain",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,11 +282,20 @@ function previewTextStyle(
|
||||
textOffsetX: number,
|
||||
textOffsetY: number,
|
||||
titleArtistGap: number,
|
||||
textPadding: number,
|
||||
encodeWidth: number,
|
||||
): CSSProperties {
|
||||
const p = `${padPct}%`;
|
||||
const shift = {
|
||||
transform: undefined as string | undefined,
|
||||
};
|
||||
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
|
||||
const coverTextGapPx = scaleFontToPreview(
|
||||
Math.max(10, Math.round(textPadding * 0.55)),
|
||||
encodeWidth,
|
||||
);
|
||||
const encodeHeight = Math.round((encodeWidth * 9) / 16);
|
||||
const cornerSidePx = scaleFontToPreview(
|
||||
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
|
||||
encodeWidth,
|
||||
);
|
||||
|
||||
const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` };
|
||||
|
||||
@@ -295,13 +342,38 @@ function previewTextStyle(
|
||||
textAlign: "center",
|
||||
alignItems: "center",
|
||||
};
|
||||
case "LOWER_LEFT_COVER_TEXT":
|
||||
return {
|
||||
...baseGap,
|
||||
position: "absolute",
|
||||
left: cornerEdgePx + cornerSidePx + coverTextGapPx + textOffsetX,
|
||||
right: p,
|
||||
bottom: cornerEdgePx,
|
||||
height: cornerSidePx,
|
||||
justifyContent: "center",
|
||||
textAlign: "left",
|
||||
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
|
||||
};
|
||||
case "LOWER_RIGHT_COVER_TEXT":
|
||||
return {
|
||||
...baseGap,
|
||||
position: "absolute",
|
||||
left: p,
|
||||
right: cornerEdgePx + cornerSidePx + coverTextGapPx - textOffsetX,
|
||||
bottom: cornerEdgePx,
|
||||
height: cornerSidePx,
|
||||
justifyContent: "center",
|
||||
textAlign: "right",
|
||||
alignItems: "flex-end",
|
||||
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
|
||||
};
|
||||
}
|
||||
void shift;
|
||||
}
|
||||
|
||||
export function LayoutStudio({
|
||||
locked,
|
||||
previewImageUrl,
|
||||
previewBackgroundUrl = null,
|
||||
songTitle,
|
||||
artist,
|
||||
encodeWidth = 1280,
|
||||
@@ -311,24 +383,31 @@ export function LayoutStudio({
|
||||
onWatermarkChange,
|
||||
onUploadLogo,
|
||||
onUploadFont,
|
||||
onUploadBackground,
|
||||
onClearBackground,
|
||||
logoPreviewUrl,
|
||||
hasCustomBackground = false,
|
||||
}: Props) {
|
||||
const [logoUploading, setLogoUploading] = useState(false);
|
||||
const [logoError, setLogoError] = useState<string | null>(null);
|
||||
const [fontUploading, setFontUploading] = useState(false);
|
||||
const [fontError, setFontError] = useState<string | null>(null);
|
||||
const [customFontObjectUrl, setCustomFontObjectUrl] = useState<string | null>(null);
|
||||
const [bgUploading, setBgUploading] = useState(false);
|
||||
const [bgError, setBgError] = useState<string | null>(null);
|
||||
|
||||
// Always coalesce HMR / older session state may omit newly added fields
|
||||
const L: LayoutSettings = {
|
||||
...DEFAULT_LAYOUT,
|
||||
...layout,
|
||||
blurFill: layout.blurFill ?? DEFAULT_LAYOUT.blurFill,
|
||||
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
|
||||
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
|
||||
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
|
||||
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
|
||||
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
|
||||
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
|
||||
titleBold: layout.titleBold ?? DEFAULT_LAYOUT.titleBold,
|
||||
};
|
||||
const W: WatermarkSettings = {
|
||||
...DEFAULT_WATERMARK,
|
||||
@@ -387,13 +466,34 @@ export function LayoutStudio({
|
||||
el.id = styleId;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = CURATED_FONTS.map(
|
||||
(f) => `@font-face {
|
||||
font-family: 'S2VIDPreview-${f.key}';
|
||||
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
|
||||
el.textContent = [
|
||||
`@font-face {
|
||||
font-family: '${SYSTEM_FONT.previewFamily}';
|
||||
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}`,
|
||||
).join("\n");
|
||||
`@font-face {
|
||||
font-family: '${SYSTEM_FONT.previewFamily}';
|
||||
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
}`,
|
||||
`@font-face {
|
||||
font-family: '${SYSTEM_FONT.previewFamily}';
|
||||
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}`,
|
||||
...CURATED_FONTS.map(
|
||||
(f) => `@font-face {
|
||||
font-family: 'S2VIDPreview-${f.key}';
|
||||
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}`,
|
||||
),
|
||||
].join("\n");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -464,6 +564,21 @@ export function LayoutStudio({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBackground(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file || locked || !onUploadBackground) return;
|
||||
setBgError(null);
|
||||
setBgUploading(true);
|
||||
try {
|
||||
await onUploadBackground(file);
|
||||
} catch (err) {
|
||||
setBgError(err instanceof Error ? err.message : "Background upload failed");
|
||||
} finally {
|
||||
setBgUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setFontKey(key: WatermarkFontKey) {
|
||||
if (key === "custom") {
|
||||
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
|
||||
@@ -473,6 +588,8 @@ export function LayoutStudio({
|
||||
}
|
||||
|
||||
const artTrack = Boolean(L.template);
|
||||
const lowerCorner = isLowerCornerTemplate(L.template);
|
||||
const blurPreviewUrl = previewBackgroundUrl || previewImageUrl;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -480,26 +597,8 @@ export function LayoutStudio({
|
||||
locked ? "opacity-80" : ""
|
||||
}`}
|
||||
>
|
||||
{locked && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-lg bg-black/55 px-4 text-center backdrop-blur-[1px]">
|
||||
<span className="rounded border border-accent/50 bg-accent/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent">
|
||||
Pro
|
||||
</span>
|
||||
<p className="max-w-sm text-sm text-gray-200">
|
||||
Art-track layouts, blur backgrounds, typography, and watermark studio are Pro features.
|
||||
</p>
|
||||
<UpgradeProButton
|
||||
label="Unlock video layout studio"
|
||||
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-white">Video layout</h3>
|
||||
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
|
||||
Pro
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Single live preview: art-track + watermark */}
|
||||
@@ -514,7 +613,7 @@ export function LayoutStudio({
|
||||
<div className="absolute inset-0 bg-black" />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
src={blurPreviewUrl!}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
style={{
|
||||
@@ -527,7 +626,12 @@ export function LayoutStudio({
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt=""
|
||||
style={previewCoverStyle(L.template!, padPct)}
|
||||
style={previewCoverStyle(
|
||||
L.template!,
|
||||
padPct,
|
||||
L.textPadding,
|
||||
encodeWidth,
|
||||
)}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<div
|
||||
@@ -537,12 +641,17 @@ export function LayoutStudio({
|
||||
L.textOffsetX,
|
||||
L.textOffsetY,
|
||||
L.titleArtistGap,
|
||||
L.textPadding,
|
||||
encodeWidth,
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className="max-w-full truncate font-semibold text-white drop-shadow"
|
||||
className={`max-w-full truncate text-white drop-shadow ${
|
||||
L.titleBold ? "font-semibold" : "font-normal"
|
||||
}`}
|
||||
style={{
|
||||
...textFontStyle,
|
||||
fontWeight: L.titleBold ? 600 : 400,
|
||||
fontSize: `${titlePreviewPx}px`,
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
@@ -562,13 +671,28 @@ export function LayoutStudio({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt=""
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black">
|
||||
{L.blurFill && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
style={{
|
||||
filter: L.blurAmount > 0 ? `blur(${blurPx}px)` : undefined,
|
||||
transform: "scale(1.15)",
|
||||
opacity: L.blurOpacity / 100,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt=""
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -614,22 +738,114 @@ export function LayoutStudio({
|
||||
{/* Art-track templates */}
|
||||
<p className="mb-2 mt-5 text-sm font-medium text-gray-300">Composition</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<MiniThumb
|
||||
template={null}
|
||||
active={L.template === null}
|
||||
disabled={locked}
|
||||
onClick={() => patchLayout({ template: null })}
|
||||
/>
|
||||
{LAYOUT_TEMPLATES.map((t) => (
|
||||
<MiniThumb
|
||||
key={t}
|
||||
template={t}
|
||||
active={L.template === t}
|
||||
disabled={locked}
|
||||
onClick={() => patchLayout({ template: t })}
|
||||
/>
|
||||
))}
|
||||
{COMPOSITION_FAMILIES.map((family) => {
|
||||
const active =
|
||||
family.id === "classic"
|
||||
? L.template === null
|
||||
: family.variants.includes(L.template as LayoutTemplate);
|
||||
return (
|
||||
<MiniThumb
|
||||
key={family.id}
|
||||
template={family.thumb}
|
||||
label={family.label}
|
||||
active={active}
|
||||
disabled={locked}
|
||||
onClick={() => {
|
||||
if (family.id === "classic") {
|
||||
patchLayout({ template: null });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
L.template &&
|
||||
family.variants.includes(L.template as LayoutTemplate)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
patchLayout({ template: family.defaultTemplate, blurFill: false });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(() => {
|
||||
const family = compositionFamilyForTemplate(L.template);
|
||||
if (family.variants.length < 2) return null;
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{family.variants.map((variant) => (
|
||||
<button
|
||||
key={variant}
|
||||
type="button"
|
||||
disabled={locked}
|
||||
onClick={() => patchLayout({ template: variant })}
|
||||
className={`rounded border px-2.5 py-1.5 text-[11px] font-medium transition-colors ${
|
||||
L.template === variant
|
||||
? "border-accent bg-accent/15 text-white"
|
||||
: "border-gray-700 bg-black/40 text-gray-300 hover:border-gray-500"
|
||||
} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
>
|
||||
{LAYOUT_TEMPLATE_LABELS[variant]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{!artTrack && (
|
||||
<label className="mt-3 flex cursor-pointer items-start gap-2 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
disabled={locked}
|
||||
checked={L.blurFill}
|
||||
onChange={(e) => patchLayout({ blurFill: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium text-gray-200">Blurred cover background</span>
|
||||
<span className="mt-0.5 block text-xs text-gray-500">
|
||||
Fill letterbox bars with a blurred cover instead of black. Adjust blur and opacity
|
||||
below when enabled.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{lowerCorner && (
|
||||
<div className="mt-3 rounded border border-gray-700 bg-black/30 p-3">
|
||||
<p className="mb-2 text-sm font-medium text-gray-300">Background image</p>
|
||||
<p className="mb-2 text-xs text-gray-500">
|
||||
Optional. Used as the blurred full-frame fill; the cover stays the sharp corner square.
|
||||
Leave empty to blur the cover itself.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label
|
||||
className={`cursor-pointer rounded border border-gray-600 bg-surface-dark px-3 py-1.5 text-xs text-gray-200 hover:border-gray-400 ${
|
||||
locked || bgUploading ? "pointer-events-none opacity-40" : ""
|
||||
}`}
|
||||
>
|
||||
{bgUploading ? "Uploading…" : hasCustomBackground ? "Replace image" : "Upload image"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
disabled={locked || bgUploading || !onUploadBackground}
|
||||
onChange={handleBackground}
|
||||
/>
|
||||
</label>
|
||||
{hasCustomBackground && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={locked}
|
||||
onClick={() => onClearBackground?.()}
|
||||
className="rounded border border-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:border-gray-500 hover:text-gray-200 disabled:opacity-40"
|
||||
>
|
||||
Use cover as background
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{bgError && <p className="mt-2 text-xs text-red-400">{bgError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-sm text-gray-400">
|
||||
@@ -638,7 +854,7 @@ export function LayoutStudio({
|
||||
type="range"
|
||||
min={BLUR_AMOUNT_MIN}
|
||||
max={BLUR_AMOUNT_MAX}
|
||||
disabled={locked || !artTrack}
|
||||
disabled={locked || !(artTrack || L.blurFill)}
|
||||
value={L.blurAmount}
|
||||
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
|
||||
className="mt-1 w-full disabled:opacity-40"
|
||||
@@ -650,7 +866,7 @@ export function LayoutStudio({
|
||||
type="range"
|
||||
min={BLUR_OPACITY_MIN}
|
||||
max={BLUR_OPACITY_MAX}
|
||||
disabled={locked || !artTrack}
|
||||
disabled={locked || !(artTrack || L.blurFill)}
|
||||
value={L.blurOpacity}
|
||||
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
|
||||
className="mt-1 w-full disabled:opacity-40"
|
||||
@@ -706,6 +922,20 @@ export function LayoutStudio({
|
||||
className="mt-1 w-full disabled:opacity-40"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
className={`flex items-center gap-2 text-sm text-gray-300 sm:col-span-2 ${
|
||||
locked || !artTrack ? "opacity-40" : ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-600 bg-surface-dark text-accent focus:ring-accent"
|
||||
disabled={locked || !artTrack}
|
||||
checked={L.titleBold}
|
||||
onChange={(e) => patchLayout({ titleBold: e.target.checked })}
|
||||
/>
|
||||
Bold song title
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Typography (matches FFmpeg art-track + watermark text fonts) */}
|
||||
@@ -719,7 +949,7 @@ export function LayoutStudio({
|
||||
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
||||
className="input-field mt-1"
|
||||
>
|
||||
<option value="system">Arial (system default)</option>
|
||||
<option value="system">{SYSTEM_FONT.label}</option>
|
||||
{CURATED_FONTS.map((f) => (
|
||||
<option key={f.key} value={f.key}>
|
||||
{f.label}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { DOCS_URL, GITEA_URL } from "@/lib/plans";
|
||||
|
||||
function LegalLink({
|
||||
href,
|
||||
@@ -33,7 +34,7 @@ export function LegalFooter() {
|
||||
return (
|
||||
<footer className="mt-auto w-full border-t border-gray-800 bg-black/40 py-8 text-xs text-gray-500">
|
||||
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-center gap-x-4 gap-y-2 px-6 md:justify-start">
|
||||
<span>© {year} Songs2VID. All rights reserved.</span>
|
||||
<span>© {year} Songs2VID. MIT licensed.</span>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
@@ -50,16 +51,18 @@ export function LegalFooter() {
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/terms">Terms of Service</LegalLink>
|
||||
<LegalLink href="/terms">Terms of Use</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/refund">Refund Policy</LegalLink>
|
||||
<LegalLink href={DOCS_URL} external>
|
||||
Documentation
|
||||
</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="https://status.atakanozban.com/status/2" external>
|
||||
Service Status
|
||||
<LegalLink href={GITEA_URL} external>
|
||||
Source
|
||||
</LegalLink>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { LEGAL_LAST_UPDATED } from "@/lib/legal/constants";
|
||||
import { DOCS_URL } from "@/lib/plans";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
@@ -44,16 +45,13 @@ export function LegalPageLayout({ title, description, children }: Props) {
|
||||
<Link href="/terms" className="hover:text-gray-300">
|
||||
Terms
|
||||
</Link>
|
||||
<Link href="/refund" className="hover:text-gray-300">
|
||||
Refund
|
||||
</Link>
|
||||
<a
|
||||
href="https://status.atakanozban.com/status/2"
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-gray-300"
|
||||
>
|
||||
Service Status
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -3,31 +3,20 @@ import { BRAND_NAME } from "@/lib/branding";
|
||||
|
||||
type Props = {
|
||||
href?: string;
|
||||
size?: "sm" | "md";
|
||||
/** Small Beta mark at top-right of the wordmark (public marketing surfaces). */
|
||||
beta?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
export function Logo({ href = "/", size = "md", beta = false }: Props) {
|
||||
const textSize = size === "sm" ? "text-xl" : "text-2xl";
|
||||
const betaSize = size === "sm" ? "text-[0.5rem]" : "text-[0.55rem]";
|
||||
export function Logo({ href = "/", size = "md" }: Props) {
|
||||
const textSize = size === "sm" ? "text-xl" : size === "lg" ? "text-3xl" : "text-2xl";
|
||||
|
||||
const content = (
|
||||
<span className={`relative inline-flex items-baseline font-bold tracking-tight ${textSize}`}>
|
||||
<span className="text-white">Songs</span>
|
||||
<span className="text-red-400">2VID</span>
|
||||
{beta ? (
|
||||
<span
|
||||
className={`pointer-events-none absolute left-full top-0 ml-0.5 -translate-y-1/2 ${betaSize} font-bold uppercase tracking-wider text-red-400`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
const label = beta ? `${BRAND_NAME} Beta` : BRAND_NAME;
|
||||
const label = BRAND_NAME;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CREDIT_PRICE_CENTS,
|
||||
CREDIT_PURCHASE_MAX,
|
||||
CREDIT_PURCHASE_MIN,
|
||||
formatCreditPrice,
|
||||
} from "@/lib/credits";
|
||||
|
||||
export function PaygPriceCalculator() {
|
||||
const [videos, setVideos] = useState(CREDIT_PURCHASE_MIN);
|
||||
const total = formatCreditPrice(videos);
|
||||
const unit = formatCreditPrice(1);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-xl rounded-2xl border border-gray-700/60 bg-surface/80 p-6 sm:p-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-amber-400">
|
||||
Pay as you go
|
||||
</p>
|
||||
<h3 className="mt-2 text-xl font-semibold text-white sm:text-2xl">
|
||||
Choose how many videos you need
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
{unit} per video · minimum {CREDIT_PURCHASE_MIN} videos (
|
||||
{formatCreditPrice(CREDIT_PURCHASE_MIN)})
|
||||
</p>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<label htmlFor="payg-videos" className="text-sm text-gray-300">
|
||||
Videos
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="payg-videos-number"
|
||||
type="number"
|
||||
min={CREDIT_PURCHASE_MIN}
|
||||
max={CREDIT_PURCHASE_MAX}
|
||||
value={videos}
|
||||
onChange={(e) => {
|
||||
const n = Math.floor(Number(e.target.value));
|
||||
if (!Number.isFinite(n)) return;
|
||||
setVideos(
|
||||
Math.min(CREDIT_PURCHASE_MAX, Math.max(CREDIT_PURCHASE_MIN, n)),
|
||||
);
|
||||
}}
|
||||
className="w-20 rounded border border-gray-600 bg-surface-dark px-3 py-2 text-center text-lg font-semibold text-white focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="payg-videos"
|
||||
type="range"
|
||||
min={CREDIT_PURCHASE_MIN}
|
||||
max={CREDIT_PURCHASE_MAX}
|
||||
value={videos}
|
||||
onChange={(e) => setVideos(Number(e.target.value))}
|
||||
className="mt-4 w-full accent-amber-400"
|
||||
aria-valuemin={CREDIT_PURCHASE_MIN}
|
||||
aria-valuemax={CREDIT_PURCHASE_MAX}
|
||||
aria-valuenow={videos}
|
||||
aria-label="Number of videos"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-500">
|
||||
<span>{CREDIT_PURCHASE_MIN}</span>
|
||||
<span>{CREDIT_PURCHASE_MAX}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-end justify-between gap-4 border-t border-gray-800 pt-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-gray-500">Total</p>
|
||||
<p className="mt-1 text-3xl font-bold text-white sm:text-4xl">{total}</p>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
{videos} × {unit}{" "}
|
||||
<span className="text-gray-500">
|
||||
(€{(CREDIT_PRICE_CENTS / 100).toFixed(2)} each)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/settings"
|
||||
className="inline-flex rounded border border-amber-500/40 bg-amber-500/10 px-5 py-3 text-sm font-medium text-amber-300 transition-colors hover:border-amber-500/60 hover:bg-amber-500/20"
|
||||
>
|
||||
Buy credits
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { QUOTA_REQUEST_EMAIL } from "@/lib/plans";
|
||||
|
||||
type ExtensionRequest = {
|
||||
id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
requestedAt: string;
|
||||
processedAt: string | null;
|
||||
adminNote: string | null;
|
||||
};
|
||||
|
||||
type ExtensionUsage = {
|
||||
used: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
requests: ExtensionRequest[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialUsage: ExtensionUsage;
|
||||
};
|
||||
|
||||
function openQuotaRequestMailto(reason?: string) {
|
||||
const subject = encodeURIComponent("Songs2VID Quota reset / extension request");
|
||||
const body = encodeURIComponent(
|
||||
[
|
||||
"Hi,",
|
||||
"",
|
||||
"I'd like to request a Pro quota reset or temporary extension.",
|
||||
"",
|
||||
reason?.trim() ? `Reason:\n${reason.trim()}` : "Reason: (optional add details here)",
|
||||
"",
|
||||
"Account email: (please keep the address you use to sign in)",
|
||||
"",
|
||||
"Thanks,",
|
||||
].join("\n"),
|
||||
);
|
||||
window.location.href = `mailto:${QUOTA_REQUEST_EMAIL}?subject=${subject}&body=${body}`;
|
||||
}
|
||||
|
||||
function formatEndDate(iso: string | null) {
|
||||
if (!iso) return null;
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function PlanBillingActions({ initialUsage }: Props) {
|
||||
const router = useRouter();
|
||||
const [usage, setUsage] = useState(initialUsage);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const [openingPortal, setOpeningPortal] = useState(false);
|
||||
const [showCancelChoices, setShowCancelChoices] = useState(false);
|
||||
const [cancelAtPeriodEnd, setCancelAtPeriodEnd] = useState(false);
|
||||
const [endsAt, setEndsAt] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/account/cancel-subscription");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
setCancelAtPeriodEnd(Boolean(data.cancelAtPeriodEnd));
|
||||
setEndsAt(data.endsAt ?? null);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleOpenBillingPortal() {
|
||||
setOpeningPortal(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const res = await fetch("/api/account/billing-portal", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Could not open billing portal");
|
||||
if (!data.url) throw new Error("No portal URL returned");
|
||||
window.location.href = data.url;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not open billing portal");
|
||||
setOpeningPortal(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelWithMode(when: "immediate" | "period_end") {
|
||||
setCancelling(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const res = await fetch("/api/account/cancel-subscription", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ when }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to cancel subscription");
|
||||
|
||||
setShowCancelChoices(false);
|
||||
|
||||
if (when === "period_end") {
|
||||
setCancelAtPeriodEnd(true);
|
||||
setEndsAt(data.endsAt ?? null);
|
||||
const label = formatEndDate(data.endsAt ?? null);
|
||||
setSuccess(
|
||||
label
|
||||
? `Cancellation scheduled. You keep Pro until ${label}; no further charges after that.`
|
||||
: "Cancellation scheduled at the end of your current billing period. You keep Pro until then.",
|
||||
);
|
||||
} else {
|
||||
setCancelAtPeriodEnd(false);
|
||||
setEndsAt(null);
|
||||
setSuccess("Subscription canceled immediately. You are now on the Free plan.");
|
||||
}
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to cancel subscription");
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResumeSubscription() {
|
||||
setCancelling(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const res = await fetch("/api/account/cancel-subscription", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "resume" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to resume subscription");
|
||||
setCancelAtPeriodEnd(false);
|
||||
setEndsAt(data.currentPeriodEnd ?? null);
|
||||
setSuccess("Subscription kept. It will renew as usual.");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to resume subscription");
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuotaRequest() {
|
||||
if (usage.remaining <= 0) return;
|
||||
|
||||
const reason = prompt(
|
||||
"Optional: tell us why you need a quota reset or extension (leave blank to skip).",
|
||||
);
|
||||
if (reason === null) return;
|
||||
|
||||
setRequesting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/quota-extension-request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: reason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to submit request");
|
||||
|
||||
setUsage({
|
||||
used: data.used,
|
||||
limit: data.limit,
|
||||
remaining: data.remaining,
|
||||
requests: data.requests ?? usage.requests,
|
||||
});
|
||||
setSuccess(
|
||||
`Request recorded (${data.used} of ${data.limit} this year). Opening your email client to contact support…`,
|
||||
);
|
||||
openQuotaRequestMailto(reason);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to submit request");
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasPending = usage.requests.some((r) => r.status === "PENDING");
|
||||
const endLabel = formatEndDate(endsAt);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="rounded border border-gray-700 bg-surface px-4 py-3 text-sm">
|
||||
<p className="text-gray-300">
|
||||
Extension requests this year:{" "}
|
||||
<span className="font-medium text-white">
|
||||
{usage.used} / {usage.limit}
|
||||
</span>
|
||||
{usage.remaining > 0 ? (
|
||||
<span className="text-gray-500"> · {usage.remaining} remaining</span>
|
||||
) : (
|
||||
<span className="text-yellow-400"> · limit reached</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{cancelAtPeriodEnd && (
|
||||
<div className="rounded border border-yellow-600/40 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100">
|
||||
<p>
|
||||
Cancellation scheduled
|
||||
{endLabel ? (
|
||||
<>
|
||||
{" "}
|
||||
Pro stays active until <strong>{endLabel}</strong>
|
||||
</>
|
||||
) : (
|
||||
<> at the end of your current billing period</>
|
||||
)}
|
||||
. You will not be charged again.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleResumeSubscription()}
|
||||
disabled={cancelling}
|
||||
className="mt-2 text-sm font-medium text-accent underline hover:text-white disabled:opacity-50"
|
||||
>
|
||||
{cancelling ? "Working…" : "Keep my Pro subscription"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-300">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOpenBillingPortal()}
|
||||
disabled={openingPortal}
|
||||
className="inline-flex items-center justify-center rounded border border-gray-600 px-4 py-2 text-sm font-medium text-gray-200 transition-colors hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{openingPortal ? "Opening…" : "Manage billing & card"}
|
||||
</button>
|
||||
|
||||
{!cancelAtPeriodEnd && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCancelChoices(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}}
|
||||
disabled={cancelling}
|
||||
className="inline-flex items-center justify-center rounded border border-yellow-600/50 px-4 py-2 text-sm font-medium text-yellow-300 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
|
||||
>
|
||||
Cancel your plan
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`mailto:${QUOTA_REQUEST_EMAIL}?subject=${encodeURIComponent("Songs2VID Quota reset / extension request")}`}
|
||||
onClick={(e) => {
|
||||
if (requesting || usage.remaining <= 0 || hasPending) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
void handleQuotaRequest();
|
||||
}}
|
||||
aria-disabled={requesting || usage.remaining <= 0 || hasPending}
|
||||
className={`inline-flex items-center justify-center rounded border border-accent/40 px-4 py-2 text-sm font-medium text-accent transition-colors hover:bg-accent/10 ${
|
||||
requesting || usage.remaining <= 0 || hasPending
|
||||
? "pointer-events-none cursor-not-allowed opacity-50"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{requesting
|
||||
? "Submitting…"
|
||||
: hasPending
|
||||
? "Request pending…"
|
||||
: "Request quota reset & extension"}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{showCancelChoices && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-labelledby="cancel-plan-title"
|
||||
className="rounded border border-yellow-600/40 bg-surface px-4 py-4"
|
||||
>
|
||||
<h3 id="cancel-plan-title" className="text-sm font-semibold text-white">
|
||||
When should Pro end?
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
This is sent to Stripe. Choose how you want to cancel your subscription.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={cancelling}
|
||||
onClick={() => void cancelWithMode("period_end")}
|
||||
className="rounded border border-gray-600 bg-surface-light px-3 py-3 text-left transition-colors hover:border-accent/50 hover:bg-accent/5 disabled:opacity-50"
|
||||
>
|
||||
<span className="block text-sm font-medium text-white">
|
||||
At end of billing period
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-gray-400">
|
||||
Keep Pro until your paid period ends. No more renewals after that.
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={cancelling}
|
||||
onClick={() => void cancelWithMode("immediate")}
|
||||
className="rounded border border-yellow-600/50 bg-yellow-500/5 px-3 py-3 text-left transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
|
||||
>
|
||||
<span className="block text-sm font-medium text-yellow-200">
|
||||
Cancel immediately
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-gray-400">
|
||||
End Pro now and switch to Free. Access ends right away.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={cancelling}
|
||||
onClick={() => setShowCancelChoices(false)}
|
||||
className="mt-3 text-xs text-gray-500 underline hover:text-gray-300 disabled:opacity-50"
|
||||
>
|
||||
{cancelling ? "Working…" : "Never mind"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usage.requests.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Request history
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{usage.requests.slice(0, 5).map((req) => (
|
||||
<li
|
||||
key={req.id}
|
||||
className="rounded border border-gray-800 bg-surface px-3 py-2 text-xs text-gray-400"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-gray-300">
|
||||
{new Date(req.requestedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
req.status === "APPROVED"
|
||||
? "text-green-400"
|
||||
: req.status === "REJECTED"
|
||||
? "text-red-400"
|
||||
: "text-yellow-400"
|
||||
}
|
||||
>
|
||||
{req.status}
|
||||
</span>
|
||||
</div>
|
||||
{req.message && <p className="mt-1 text-gray-500">{req.message}</p>}
|
||||
<p className="mt-1 font-mono text-[10px] text-gray-600">ID: {req.id}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Pro users may request up to 5 manual quota resets or extensions per calendar year by emailing{" "}
|
||||
<a
|
||||
href={`mailto:${QUOTA_REQUEST_EMAIL}`}
|
||||
className="text-gray-400 underline hover:text-white"
|
||||
>
|
||||
{QUOTA_REQUEST_EMAIL}
|
||||
</a>
|
||||
. See our{" "}
|
||||
<a href="/terms" className="text-gray-400 underline hover:text-white">
|
||||
Terms of Service
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { UpgradeProLink } from "./UpgradeProLink";
|
||||
|
||||
type Playlist = {
|
||||
id: string;
|
||||
@@ -80,15 +79,6 @@ export function PlaylistSelect({ value, onChange, enabled }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<p className="text-sm text-gray-400">
|
||||
Add uploaded videos to a YouTube playlist with{" "}
|
||||
<UpgradeProLink className="text-accent hover:underline" />.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRef } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
import { SignInButton } from "@/components/SignInButton";
|
||||
import { getVideoAttributionText } from "@/lib/branding";
|
||||
import { SALES_EMAIL } from "@/lib/plans";
|
||||
|
||||
const FEATURE_LABELS = [
|
||||
"Infrastructure",
|
||||
"Limit",
|
||||
"Batch mode",
|
||||
"Bulk image matching",
|
||||
"File type",
|
||||
"Playlists",
|
||||
"API support",
|
||||
"ID3 tags",
|
||||
"Support",
|
||||
"Watermark",
|
||||
] as const;
|
||||
|
||||
type PlanFeatures = Record<(typeof FEATURE_LABELS)[number], string>;
|
||||
|
||||
type InfrastructureChip = "cloud" | "self-hosted";
|
||||
|
||||
type PlanConfig = {
|
||||
name: string;
|
||||
badge: string;
|
||||
price: string;
|
||||
priceNote: string;
|
||||
features: PlanFeatures;
|
||||
infrastructureChips: InfrastructureChip[];
|
||||
watermarkNote?: string;
|
||||
cta:
|
||||
| { type: "signin" }
|
||||
| { type: "link"; label: string; href: string }
|
||||
| { type: "disabled"; label: string }
|
||||
| { type: "mailto"; label: string; email: string; subject: string };
|
||||
highlighted: boolean;
|
||||
hover: string;
|
||||
accent: string;
|
||||
badgeClass: string;
|
||||
ctaClass?: string;
|
||||
};
|
||||
|
||||
const PLANS: PlanConfig[] = [
|
||||
{
|
||||
name: "Bedroom Producer",
|
||||
badge: "Free",
|
||||
price: "€0",
|
||||
priceNote: "/ forever",
|
||||
features: {
|
||||
Infrastructure: "Cloud",
|
||||
Limit: "10 videos* / month · 720p · buy 1–15 extras (€0.25 each)",
|
||||
"Batch mode": "Limited batch · up to 3 files",
|
||||
"Bulk image matching": "1 static image only",
|
||||
"File type": "MP3",
|
||||
Playlists: "Not included",
|
||||
"API support": "Not included",
|
||||
"ID3 tags": "Auto-fill title & metadata from MP3 tags",
|
||||
Support: "Community",
|
||||
Watermark: "Optional Songs2VID badge · bottom-right",
|
||||
},
|
||||
infrastructureChips: ["cloud"],
|
||||
watermarkNote:
|
||||
`Show "${getVideoAttributionText()}" to support open-source development - opt out anytime for a clean video.`,
|
||||
cta: { type: "signin" },
|
||||
highlighted: false,
|
||||
hover:
|
||||
"hover:border-red-500/40 hover:bg-red-500/[0.04] hover:shadow-lg hover:shadow-red-500/15",
|
||||
accent: "text-red-400",
|
||||
badgeClass: "bg-gray-800 text-gray-400",
|
||||
},
|
||||
{
|
||||
name: "Independent Artist",
|
||||
badge: "Pro",
|
||||
price: "€5",
|
||||
priceNote: "/ mo",
|
||||
features: {
|
||||
Infrastructure: "Cloud",
|
||||
Limit: "50 videos / month · 1080p",
|
||||
"Batch mode": "Full batch · up to 5 files",
|
||||
"Bulk image matching": "Unique image per track (PRO)",
|
||||
"File type": "MP3 / WAV / FLAC",
|
||||
Playlists: "Create & add uploads to YouTube playlists",
|
||||
"API support": "REST API · upload, batch & playlists",
|
||||
"ID3 tags": "Extended metadata support",
|
||||
Support: "E-Mail",
|
||||
Watermark: "Custom text/logo/fonts · art-track layouts + blur (PRO)",
|
||||
},
|
||||
infrastructureChips: ["cloud"],
|
||||
cta: { type: "link", label: "Upgrade to Pro", href: "/dashboard/settings" },
|
||||
highlighted: true,
|
||||
hover:
|
||||
"hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20",
|
||||
accent: "text-accent",
|
||||
badgeClass: "bg-accent/15 text-accent",
|
||||
},
|
||||
{
|
||||
name: "Record Label / Studio",
|
||||
badge: "Enterprise",
|
||||
price: "Custom",
|
||||
priceNote: "/ contact sales",
|
||||
features: {
|
||||
Infrastructure: "Cloud or self-hosted",
|
||||
Limit: "Unlimited 4K · zero limit",
|
||||
"Batch mode": "Unlimited synchronized batch processing",
|
||||
"Bulk image matching": "Unique image per track",
|
||||
"File type": "WAV / FLAC / lossless",
|
||||
Playlists: "Org-wide playlist workflows",
|
||||
"API support": "Full API access · custom integrations & SLAs",
|
||||
"ID3 tags": "Full metadata · custom mapping",
|
||||
Support: "Top-priority**",
|
||||
Watermark: "Custom branding · position studio",
|
||||
},
|
||||
infrastructureChips: ["cloud", "self-hosted"],
|
||||
cta: {
|
||||
type: "mailto",
|
||||
label: "Contact Sales",
|
||||
email: SALES_EMAIL,
|
||||
subject: "Songs2VID Enterprise Inquiry",
|
||||
},
|
||||
highlighted: false,
|
||||
hover:
|
||||
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
|
||||
accent: "text-[#609926]",
|
||||
badgeClass: "bg-[#609926]/15 text-[#609926]",
|
||||
ctaClass:
|
||||
"border-[#609926]/40 bg-[#609926]/10 text-[#609926] hover:border-[#609926]/60 hover:bg-[#609926]/20",
|
||||
},
|
||||
];
|
||||
|
||||
function FeatureValue({ value }: { value: string }) {
|
||||
return <span className="text-sm text-gray-200">{value}</span>;
|
||||
}
|
||||
|
||||
function CloudIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M7.5 18.5h9.75a4.25 4.25 0 0 0 .55-8.46A5.75 5.75 0 0 0 6.9 8.8 4.5 4.5 0 0 0 7.5 18.5Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="5" width="16" height="6" rx="2" />
|
||||
<rect x="4" y="13" width="16" height="6" rx="2" />
|
||||
<path d="M7.5 8h.01M7.5 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function InfrastructureChips({
|
||||
chips,
|
||||
}: {
|
||||
chips: InfrastructureChip[];
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{chips.includes("cloud") && (
|
||||
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
|
||||
<CloudIcon className="h-4 w-4 text-gray-300" />
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
{chips.includes("self-hosted") && (
|
||||
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
|
||||
<ServerIcon className="h-4 w-4 text-gray-300" />
|
||||
Self-hosted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingCard({ plan }: { plan: PlanConfig }) {
|
||||
const {
|
||||
name,
|
||||
badge,
|
||||
price,
|
||||
priceNote,
|
||||
features,
|
||||
watermarkNote,
|
||||
infrastructureChips,
|
||||
cta,
|
||||
highlighted,
|
||||
hover,
|
||||
accent,
|
||||
badgeClass,
|
||||
ctaClass,
|
||||
} = plan;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex h-full flex-col rounded-2xl border bg-surface p-6 transition-all duration-300 sm:p-7 ${
|
||||
highlighted
|
||||
? "border-accent/40 shadow-[0_0_32px_rgba(74,158,255,0.12)]"
|
||||
: "border-gray-700/50"
|
||||
} ${hover}`}
|
||||
>
|
||||
{highlighted && (
|
||||
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-accent px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-white">
|
||||
Most popular
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-white">{name}</h3>
|
||||
<span
|
||||
className={`shrink-0 rounded px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${badgeClass}`}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-1">
|
||||
<span className={`text-4xl font-bold ${accent}`}>{price}</span>
|
||||
<span className="mb-1 text-sm text-gray-500">{priceNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mb-6 flex-1 space-y-4 border-t border-gray-700/50 pt-6">
|
||||
{FEATURE_LABELS.map((label) => (
|
||||
<li key={label}>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
|
||||
{label === "Infrastructure" ? (
|
||||
<InfrastructureChips chips={infrastructureChips} />
|
||||
) : (
|
||||
<FeatureValue value={features[label]} />
|
||||
)}
|
||||
{label === "Watermark" && watermarkNote && (
|
||||
<p className="mt-1.5 rounded-lg border border-gray-700/60 bg-surface-dark/80 px-3 py-2 text-xs leading-relaxed text-gray-400">
|
||||
{watermarkNote}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto">
|
||||
{cta.type === "signin" && <SignInButton fullWidth />}
|
||||
{cta.type === "link" && (
|
||||
<Link
|
||||
href={cta.href}
|
||||
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? "border-gray-600 text-white hover:border-gray-400"}`}
|
||||
>
|
||||
{cta.label}
|
||||
</Link>
|
||||
)}
|
||||
{cta.type === "disabled" && (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="w-full cursor-not-allowed rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm font-medium text-gray-500"
|
||||
>
|
||||
{cta.label}
|
||||
</button>
|
||||
)}
|
||||
{cta.type === "mailto" && (
|
||||
<a
|
||||
href={`mailto:${cta.email}?subject=${encodeURIComponent(cta.subject)}`}
|
||||
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? ""}`}
|
||||
>
|
||||
{cta.label}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PricingSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="pricing"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
|
||||
>
|
||||
<SectionScrollTitle sectionRef={sectionRef} title="Pricing" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-4 text-center text-3xl font-bold text-white">Pricing</h2>
|
||||
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
|
||||
Free with optional extras, Pro at €5/month, or Enterprise for studios.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||
{PLANS.map((plan, index) => (
|
||||
<ScrollReveal key={plan.name} delay={index * 100}>
|
||||
<PricingCard plan={plan} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ScrollReveal delay={360}>
|
||||
<p className="mx-auto mt-10 max-w-2xl text-center text-xs text-gray-500">
|
||||
*Free includes 10 videos/month; buy 1–15 extras at €0.25 each (max 15 extras). After both
|
||||
are used, Pro (€5/mo · 50 videos) is required. Total balance capped at 30. Deduction
|
||||
order: monthly first, then extras.
|
||||
</p>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-center text-xs text-gray-500">
|
||||
**Technical support is strictly reserved for Managed Cloud and paid Professional Setup
|
||||
agreements; independent self-hosted deployments are community-supported.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Plan } from "@prisma/client";
|
||||
import { getResolutionsForPlan } from "@/lib/plans";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
plan?: Plan;
|
||||
};
|
||||
|
||||
export function ResolutionSelect({ value, onChange, disabled, plan = "FREE" }: Props) {
|
||||
const resolutions = getResolutionsForPlan(plan);
|
||||
export function ResolutionSelect({ value, onChange, disabled }: Props) {
|
||||
const resolutions = getResolutionsForPlan();
|
||||
|
||||
return (
|
||||
<select
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type Direction = "up" | "left" | "right";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
direction?: Direction;
|
||||
};
|
||||
|
||||
const OFFSET: Record<Direction, string> = {
|
||||
up: "translate-y-10",
|
||||
left: "-translate-x-10",
|
||||
right: "translate-x-10",
|
||||
};
|
||||
|
||||
export function ScrollReveal({
|
||||
children,
|
||||
className = "",
|
||||
delay = 0,
|
||||
direction = "up",
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.unobserve(el);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`transition-all duration-700 ease-out ${className} ${
|
||||
visible ? "translate-x-0 translate-y-0 opacity-100" : `opacity-0 ${OFFSET[direction]}`
|
||||
}`}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
|
||||
type Props = {
|
||||
sectionRef: RefObject<HTMLElement | null>;
|
||||
title: string;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export function SectionScrollTitle({ sectionRef, title, offset = 350 }: Props) {
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!section) return;
|
||||
|
||||
const update = () => {
|
||||
const rect = section.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const progress = Math.min(
|
||||
1,
|
||||
Math.max(0, (viewportHeight - rect.top) / (viewportHeight + rect.height * 0.5)),
|
||||
);
|
||||
setOffsetX(progress * offset);
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", update);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [sectionRef, offset]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-0 left-1/2 z-0 select-none whitespace-nowrap text-7xl font-bold leading-none text-white/[0.04] sm:text-8xl lg:text-9xl"
|
||||
style={{ transform: `translateX(calc(-50% + ${offsetX}px))` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { useMockupProgress } from "@/hooks/useMockupProgress";
|
||||
|
||||
function LayersIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SlidersIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3" />
|
||||
<circle cx="4" cy="14" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<circle cx="20" cy="16" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudUploadIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 16V8M12 8l-3 3M12 8l3 3" />
|
||||
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.9-1A4 4 0 1 1 17 18H7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
number: 1,
|
||||
title: "Single/Batch creation",
|
||||
desc: "One image, many audios, each becomes a separate video.",
|
||||
Icon: LayersIcon,
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Per-video settings",
|
||||
desc: "Title, tags, privacy, and category for every upload.",
|
||||
Icon: SlidersIcon,
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Direct upload",
|
||||
desc: "Connect YouTube once and publish automatically.",
|
||||
Icon: CloudUploadIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function CheckIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AppMockup() {
|
||||
const phaseOneMs = 3000;
|
||||
const phaseTwoMs = 2000;
|
||||
const initialWidth = "20%";
|
||||
const midWidth = "45%";
|
||||
const { ref, inView } = useInView();
|
||||
const { phase, processed, transitionMs } = useMockupProgress(inView, phaseOneMs, phaseTwoMs);
|
||||
|
||||
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="mt-10 overflow-hidden rounded-2xl border border-gray-700/60 bg-surface-dark shadow-2xl shadow-black/40"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-gray-700/50 px-4 py-3">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="rounded-xl border border-gray-700/50 bg-surface p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mockup-upload-box mockup-upload-box-image flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
|
||||
<ImageIcon className="mockup-icon-image mb-3 h-8 w-8 text-gray-500" />
|
||||
<span className="text-xs font-semibold tracking-widest text-gray-500">IMAGE</span>
|
||||
</div>
|
||||
<div className="mockup-upload-box mockup-upload-box-audio flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
|
||||
<AudioIcon className="mockup-icon-audio mb-3 h-8 w-8 text-gray-500" />
|
||||
<span className="text-xs font-semibold tracking-widest text-gray-500">AUDIO</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-gray-400">
|
||||
{processed ? (
|
||||
"Processed"
|
||||
) : (
|
||||
<>
|
||||
Processing
|
||||
<span className="mockup-dot-1">.</span>
|
||||
<span className="mockup-dot-2">.</span>
|
||||
<span className="mockup-dot-3">.</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="relative h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-gray-700">
|
||||
<div
|
||||
className="relative h-full rounded-full bg-white/90 ease-out"
|
||||
style={{
|
||||
width,
|
||||
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
|
||||
}}
|
||||
>
|
||||
{!processed && (
|
||||
<span className="mockup-progress-shimmer absolute inset-y-0 w-1/2 rounded-full bg-gradient-to-r from-transparent via-white/50 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={`h-4 w-4 shrink-0 text-accent transition-all duration-300 ${
|
||||
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepCard({
|
||||
number,
|
||||
title,
|
||||
desc,
|
||||
Icon,
|
||||
}: {
|
||||
number: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
Icon: typeof LayersIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-2xl border border-gray-700/50 bg-surface transition-all duration-300 hover:border-gray-500 hover:bg-surface-light hover:shadow-xl hover:shadow-black/30">
|
||||
<span
|
||||
className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 select-none text-[7.5rem] font-bold leading-none text-gray-600/20 transition-all duration-500 ease-out group-hover:left-1/2 group-hover:-translate-x-1/2 group-hover:scale-[1.18] group-hover:text-red-500/25 sm:text-[8.5rem]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
|
||||
<div className="relative flex items-center gap-6 px-8 py-9 pl-24 sm:pl-28">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-gray-600/50 bg-surface-dark text-gray-400 transition-all duration-300 group-hover:border-red-500/30 group-hover:text-red-400">
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white transition-colors duration-300 group-hover:text-red-400 sm:text-xl">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-2 text-base leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepsSection() {
|
||||
return (
|
||||
<section className="mx-auto max-w-6xl scroll-mt-20 px-6 pb-24 pt-32">
|
||||
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<ScrollReveal direction="left">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold leading-snug text-white sm:text-3xl">
|
||||
Upload your content in 3 simple steps:
|
||||
</h2>
|
||||
<p className="mt-6 text-base leading-relaxed text-gray-400">
|
||||
From a single track to a full batch, Songs2VID walks you through creating and
|
||||
publishing videos to YouTube without touching a video editor.
|
||||
</p>
|
||||
<AppMockup />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="flex flex-col gap-7">
|
||||
{STEPS.map((step, index) => (
|
||||
<ScrollReveal key={step.number} delay={index * 120} direction="right">
|
||||
<StepCard
|
||||
number={step.number}
|
||||
title={step.title}
|
||||
desc={step.desc}
|
||||
Icon={step.Icon}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
import { GITEA_ISSUES_URL, SUPPORT_EMAIL } from "@/lib/plans";
|
||||
|
||||
type SupportCardProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
cta: string;
|
||||
external?: boolean;
|
||||
hover: string;
|
||||
titleHover: string;
|
||||
linkClass: string;
|
||||
linkHover: string;
|
||||
};
|
||||
|
||||
function SupportCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
cta,
|
||||
external,
|
||||
hover,
|
||||
titleHover,
|
||||
linkClass,
|
||||
linkHover,
|
||||
}: SupportCardProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
className={`group block rounded-xl border border-gray-700/50 bg-surface/80 p-6 backdrop-blur-sm transition-all duration-300 ${hover}`}
|
||||
>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
<span
|
||||
className={`inline-flex text-sm font-medium transition-all duration-300 group-hover:underline ${linkClass} ${linkHover}`}
|
||||
>
|
||||
{cta}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const SUPPORT_CARDS: SupportCardProps[] = [
|
||||
{
|
||||
title: "🛠️ Community & Self-Hosting",
|
||||
description:
|
||||
"Found a bug, want to request a feature, or need help setting up your Docker instance? Open an issue on our self-hosted Gitea.",
|
||||
href: GITEA_ISSUES_URL,
|
||||
cta: "Open Gitea Issues",
|
||||
external: true,
|
||||
hover:
|
||||
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
|
||||
titleHover: "group-hover:text-[#609926]",
|
||||
linkClass: "text-[#609926]",
|
||||
linkHover: "group-hover:text-[#7ab33a]",
|
||||
},
|
||||
{
|
||||
title: "📩 Billing & Premium Support",
|
||||
description:
|
||||
"Have questions about your Pro subscription, custom limits, or Enterprise inquiries? Drop us an email.",
|
||||
href: `mailto:${SUPPORT_EMAIL}`,
|
||||
cta: SUPPORT_EMAIL,
|
||||
hover:
|
||||
"hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20",
|
||||
titleHover: "group-hover:text-accent",
|
||||
linkClass: "text-accent",
|
||||
linkHover: "group-hover:text-accent-hover",
|
||||
},
|
||||
];
|
||||
|
||||
export function SupportSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="support"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 pb-24 pt-24"
|
||||
>
|
||||
<SectionScrollTitle sectionRef={sectionRef} title="Support" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-4xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-4 text-center text-3xl font-bold text-white">Support</h2>
|
||||
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
|
||||
Community help for self-hosters, or reach us directly for billing and premium support.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mx-auto mt-10 grid grid-cols-1 gap-8 text-left md:grid-cols-2">
|
||||
{SUPPORT_CARDS.map((card, index) => (
|
||||
<ScrollReveal
|
||||
key={card.title}
|
||||
direction={index === 0 ? "left" : "right"}
|
||||
delay={index * 120}
|
||||
>
|
||||
<SupportCard {...card} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
/** Starts Pro Checkout (€5/mo) or applies a dev mock upgrade. */
|
||||
export function UpgradeProButton({
|
||||
className = "text-accent hover:underline",
|
||||
label = "Upgrade to Pro",
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleUpgrade() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/account/subscribe", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Upgrade failed");
|
||||
if (data.mocked) {
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
if (!data.url) throw new Error("No checkout URL returned");
|
||||
window.location.href = data.url;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upgrade failed");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
disabled={loading}
|
||||
className={className}
|
||||
>
|
||||
{loading ? "Starting…" : label}
|
||||
</button>
|
||||
{error && <span className="ml-2 text-sm text-red-400">{error}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { PRO_UPGRADE_REQUIRED_SUFFIX } from "@/lib/credits";
|
||||
import { UPGRADE_URL } from "@/lib/plans";
|
||||
import { UpgradeProButton } from "@/components/UpgradeProButton";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function UpgradeProLink({ className = "text-accent hover:underline", children }: Props) {
|
||||
return (
|
||||
<Link href={UPGRADE_URL} className={className}>
|
||||
{children ?? "Upgrade to Pro"}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotaErrorMessage({ message }: { message: string }) {
|
||||
if (message.endsWith(PRO_UPGRADE_REQUIRED_SUFFIX)) {
|
||||
return (
|
||||
<>
|
||||
{message.slice(0, -PRO_UPGRADE_REQUIRED_SUFFIX.length)}{" "}
|
||||
<UpgradeProButton
|
||||
className="font-medium text-red-200 underline hover:text-white"
|
||||
label="Upload up to 50 videos with Pro!"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{message}</>;
|
||||
}
|
||||
@@ -2,22 +2,19 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plan, Privacy } from "@prisma/client";
|
||||
import { Privacy } from "@prisma/client";
|
||||
import { filenameWithoutExtension } from "@/lib/constants";
|
||||
import { getVideoAttributionText } from "@/lib/branding";
|
||||
import { audioTagsToMetadata } from "@/lib/audio-tags";
|
||||
import { resolveYouTubeTitle } from "@/lib/titles";
|
||||
import { SONG_TITLE_MAX } from "@/lib/layout";
|
||||
import type { ItemMetadata } from "@/lib/types";
|
||||
import { DEFAULT_LAYOUT, type LayoutSettings } from "@/lib/layout";
|
||||
import { DEFAULT_LAYOUT, isLowerCornerTemplate, type LayoutSettings } from "@/lib/layout";
|
||||
import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark";
|
||||
import { CategorySelect } from "./CategorySelect";
|
||||
import { LayoutStudio } from "./LayoutStudio";
|
||||
import { PlaylistSelect } from "./PlaylistSelect";
|
||||
import { PrivacyToggle } from "./PrivacyToggle";
|
||||
import { ResolutionSelect } from "./ResolutionSelect";
|
||||
import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink";
|
||||
import { UpgradeProButton } from "./UpgradeProButton";
|
||||
|
||||
const UPLOAD_CONCURRENCY = 4;
|
||||
|
||||
@@ -68,45 +65,20 @@ export function UploadForm() {
|
||||
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
|
||||
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
|
||||
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||
const [backgroundImagePath, setBackgroundImagePath] = useState<string | null>(null);
|
||||
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState<string | null>(null);
|
||||
const [quota, setQuota] = useState<{
|
||||
remaining: number;
|
||||
limit: number;
|
||||
plan: Plan;
|
||||
maxBatchSize: number;
|
||||
resetsIn: string;
|
||||
videoCredits: number;
|
||||
extraCredits: number;
|
||||
totalAvailable: number;
|
||||
used: number;
|
||||
selfHosted?: boolean;
|
||||
customWatermark?: boolean;
|
||||
perItemImages?: boolean;
|
||||
artTrackLayouts?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const isPro = Boolean(quota?.selfHosted || quota?.plan === "PREMIUM");
|
||||
const canPerItemImages = Boolean(quota?.perItemImages || isPro);
|
||||
const canCustomWatermark = Boolean(quota?.customWatermark || isPro);
|
||||
const canArtTrackLayouts = Boolean(quota?.artTrackLayouts || isPro);
|
||||
|
||||
const loadQuota = useCallback(async () => {
|
||||
const res = await fetch("/api/quota");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setQuota({
|
||||
remaining: data.remaining,
|
||||
limit: data.limit,
|
||||
plan: data.plan,
|
||||
maxBatchSize: data.maxBatchSize,
|
||||
resetsIn: data.resetsIn,
|
||||
videoCredits: data.videoCredits ?? data.extraCredits ?? 0,
|
||||
extraCredits: data.extraCredits ?? data.videoCredits ?? 0,
|
||||
totalAvailable: data.totalAvailable ?? data.remaining,
|
||||
used: data.used ?? 0,
|
||||
selfHosted: Boolean(data.selfHosted),
|
||||
customWatermark: Boolean(data.customWatermark),
|
||||
perItemImages: Boolean(data.perItemImages),
|
||||
artTrackLayouts: Boolean(data.artTrackLayouts),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
@@ -119,6 +91,7 @@ export function UploadForm() {
|
||||
return () => {
|
||||
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||
if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl);
|
||||
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
|
||||
audioItems.forEach((a) => {
|
||||
if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview);
|
||||
});
|
||||
@@ -229,10 +202,6 @@ export function UploadForm() {
|
||||
}
|
||||
|
||||
async function handleItemImageChange(itemId: string, file: File | null) {
|
||||
if (!canPerItemImages) {
|
||||
setError("Matching a unique image per audio requires Pro.");
|
||||
return;
|
||||
}
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
const preview = URL.createObjectURL(file);
|
||||
@@ -292,6 +261,21 @@ export function UploadForm() {
|
||||
return uploaded.path;
|
||||
}
|
||||
|
||||
async function handleBackgroundUpload(file: File) {
|
||||
const preview = URL.createObjectURL(file);
|
||||
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
|
||||
setBackgroundPreviewUrl(preview);
|
||||
const uploaded = await uploadFile(file, "image");
|
||||
setBackgroundImagePath(uploaded.path);
|
||||
return uploaded.path;
|
||||
}
|
||||
|
||||
function clearBackgroundImage() {
|
||||
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
|
||||
setBackgroundPreviewUrl(null);
|
||||
setBackgroundImagePath(null);
|
||||
}
|
||||
|
||||
function applyWatermarkToAll(next: WatermarkSettings) {
|
||||
const normalized = { ...DEFAULT_WATERMARK, ...next };
|
||||
setJobWatermark(normalized);
|
||||
@@ -310,6 +294,9 @@ export function UploadForm() {
|
||||
function applyLayoutToAll(next: LayoutSettings) {
|
||||
const normalized = { ...DEFAULT_LAYOUT, ...next };
|
||||
setJobLayout(normalized);
|
||||
if (!isLowerCornerTemplate(normalized.template)) {
|
||||
clearBackgroundImage();
|
||||
}
|
||||
setAudioItems((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
@@ -326,9 +313,9 @@ export function UploadForm() {
|
||||
if (!files.length) return;
|
||||
setError(null);
|
||||
|
||||
const maxBatch = quota?.maxBatchSize ?? 3;
|
||||
const maxBatch = quota?.maxBatchSize ?? 100;
|
||||
if (audioItems.length + files.length > maxBatch) {
|
||||
setError(`Your plan allows up to ${maxBatch} audio files per batch.`);
|
||||
setError(`You can upload up to ${maxBatch} audio files per batch.`);
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
@@ -377,20 +364,20 @@ export function UploadForm() {
|
||||
songTitle: a.metadata.songTitle,
|
||||
artist: a.metadata.artist,
|
||||
},
|
||||
isPro ? Plan.PREMIUM : Plan.FREE,
|
||||
),
|
||||
),
|
||||
) &&
|
||||
!submitting;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
async function submitJob() {
|
||||
if (!canSubmit || !imagePath) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const effectiveWatermark: WatermarkSettings = jobWatermark;
|
||||
|
||||
const res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -401,17 +388,14 @@ export function UploadForm() {
|
||||
audioFilename: item.file.name,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
imagePath: canPerItemImages ? item.itemImagePath || item.metadata.imagePath || null : null,
|
||||
includeWatermark: jobWatermark.mode !== "none",
|
||||
watermark: canCustomWatermark
|
||||
? jobWatermark
|
||||
: {
|
||||
mode: jobWatermark.mode === "none" ? "none" : "default",
|
||||
position: "bottom-right",
|
||||
offsetX: 20,
|
||||
offsetY: 20,
|
||||
},
|
||||
layout: canArtTrackLayouts ? jobLayout : { ...DEFAULT_LAYOUT },
|
||||
imagePath: item.itemImagePath || item.metadata.imagePath || null,
|
||||
includeWatermark: effectiveWatermark.mode !== "none",
|
||||
watermark: effectiveWatermark,
|
||||
layout: jobLayout,
|
||||
backgroundImagePath:
|
||||
isLowerCornerTemplate(jobLayout.template) && backgroundImagePath
|
||||
? backgroundImagePath
|
||||
: null,
|
||||
},
|
||||
})),
|
||||
}),
|
||||
@@ -419,9 +403,6 @@ export function UploadForm() {
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (data.code === "PREMIUM_REQUIRED") {
|
||||
throw new Error(data.error || "This feature requires Pro.");
|
||||
}
|
||||
throw new Error(data.error || "Failed to create job");
|
||||
}
|
||||
|
||||
@@ -432,42 +413,24 @@ export function UploadForm() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit || !imagePath) return;
|
||||
|
||||
await submitJob();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{quota && (
|
||||
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
|
||||
{quota.selfHosted ? (
|
||||
<>
|
||||
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per
|
||||
batch
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{quota.remaining} of {quota.limit} plan videos remaining
|
||||
{quota.extraCredits > 0
|
||||
? ` · ${quota.extraCredits} extra credits (${quota.totalAvailable} total available)`
|
||||
: ""}{" "}
|
||||
·{" "}
|
||||
{quota.plan === "FREE"
|
||||
? `resets in ${quota.resetsIn}`
|
||||
: `monthly quota resets on ${quota.resetsIn}`}{" "}
|
||||
· up to {quota.maxBatchSize} files per batch
|
||||
{quota.plan === "FREE" ? (
|
||||
<>
|
||||
{" · "}
|
||||
<a href="/dashboard/settings" className="text-accent hover:underline">
|
||||
Buy 1–15 credits
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
Self-hosted · {quota.used} videos created · up to {quota.maxBatchSize} files per batch
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
<QuotaErrorMessage message={error} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -490,9 +453,7 @@ export function UploadForm() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
{canPerItemImages
|
||||
? "Shared cover for all tracks. Optionally override per audio below (Pro)."
|
||||
: "Free plan: one static cover for the whole batch."}
|
||||
Shared cover for all tracks. You can override it per audio below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -500,11 +461,7 @@ export function UploadForm() {
|
||||
<label className="mb-2 block text-sm text-gray-400">Audio files</label>
|
||||
<input
|
||||
type="file"
|
||||
accept={
|
||||
quota?.selfHosted || quota?.plan === "PREMIUM"
|
||||
? "audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
|
||||
: "audio/mpeg,.mp3"
|
||||
}
|
||||
accept="audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
|
||||
multiple
|
||||
onChange={handleAudioChange}
|
||||
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||
@@ -526,14 +483,14 @@ export function UploadForm() {
|
||||
<h2 className="text-lg font-medium text-white">Video details (per audio)</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Each audio file gets its own metadata. Video title is used for YouTube; song title and
|
||||
artist appear in Pro art-track layouts.
|
||||
artist appear in art-track layouts.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-gray-700 bg-surface p-6">
|
||||
<PlaylistSelect
|
||||
value={playlistId}
|
||||
onChange={handlePlaylistChange}
|
||||
enabled={Boolean(quota?.selfHosted || quota?.plan === "PREMIUM")}
|
||||
enabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -572,39 +529,29 @@ export function UploadForm() {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<ProLockedField
|
||||
label="Song title (on-video)"
|
||||
locked={!isPro}
|
||||
hint="Pro unlocks custom song title burned into art-track layouts."
|
||||
>
|
||||
<Field label="Song title (on-video)">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.songTitle ?? ""}
|
||||
onChange={(e) => updateItemMetadata(item.id, { songTitle: e.target.value })}
|
||||
className="input-field disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={!isPro}
|
||||
placeholder={isPro ? "Shown in art-track layout" : "Pro feature"}
|
||||
className="input-field"
|
||||
placeholder="Shown in art-track layout"
|
||||
maxLength={SONG_TITLE_MAX}
|
||||
/>
|
||||
</ProLockedField>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ProLockedField
|
||||
label="Artist (on-video)"
|
||||
locked={!isPro}
|
||||
hint="Pro unlocks custom artist line in art-track layouts."
|
||||
>
|
||||
<Field label="Artist (on-video)">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.artist ?? ""}
|
||||
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
|
||||
className="input-field disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={!isPro}
|
||||
placeholder={isPro ? "Shown in art-track layout" : "Pro feature"}
|
||||
className="input-field"
|
||||
placeholder="Shown in art-track layout"
|
||||
maxLength={80}
|
||||
/>
|
||||
</ProLockedField>
|
||||
</Field>
|
||||
<Field label="Category">
|
||||
<CategorySelect
|
||||
value={item.metadata.categoryId}
|
||||
@@ -643,30 +590,14 @@ export function UploadForm() {
|
||||
<ResolutionSelect
|
||||
value={item.metadata.resolution}
|
||||
onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
|
||||
plan={quota?.plan}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="relative rounded border border-gray-800 bg-surface-light/40 p-3">
|
||||
{!canPerItemImages && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-accent">
|
||||
Pro
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">Unique image per track</span>
|
||||
</div>
|
||||
)}
|
||||
<Field
|
||||
label={
|
||||
canPerItemImages
|
||||
? "Cover for this track (optional)"
|
||||
: "Cover for this track (Pro)"
|
||||
}
|
||||
>
|
||||
<Field label="Cover for this track (optional)">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
disabled={!canPerItemImages}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0] ?? null;
|
||||
void handleItemImageChange(item.id, f);
|
||||
@@ -680,11 +611,6 @@ export function UploadForm() {
|
||||
{item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}…`}
|
||||
</p>
|
||||
)}
|
||||
{!canPerItemImages && (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
<UpgradeProButton label="Upgrade for bulk unique image matching" />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -708,18 +634,6 @@ export function UploadForm() {
|
||||
checked={item.metadata.creativeCommons}
|
||||
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
|
||||
/>
|
||||
{!canCustomWatermark && (
|
||||
<Checkbox
|
||||
label={`Show '${getVideoAttributionText()}' watermark (support open-source)`}
|
||||
checked={jobWatermark.mode !== "none"}
|
||||
onChange={(v) =>
|
||||
applyWatermarkToAll({
|
||||
...DEFAULT_WATERMARK,
|
||||
mode: v ? "default" : "none",
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -727,10 +641,11 @@ export function UploadForm() {
|
||||
)}
|
||||
|
||||
<LayoutStudio
|
||||
locked={!canArtTrackLayouts && !canCustomWatermark}
|
||||
locked={false}
|
||||
previewImageUrl={
|
||||
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
|
||||
}
|
||||
previewBackgroundUrl={backgroundPreviewUrl}
|
||||
songTitle={
|
||||
audioItems[0]?.metadata.songTitle ||
|
||||
audioItems[0]?.metadata.title ||
|
||||
@@ -744,23 +659,12 @@ export function UploadForm() {
|
||||
onWatermarkChange={applyWatermarkToAll}
|
||||
onUploadLogo={handleLogoUpload}
|
||||
onUploadFont={handleFontUpload}
|
||||
onUploadBackground={handleBackgroundUpload}
|
||||
onClearBackground={clearBackgroundImage}
|
||||
hasCustomBackground={Boolean(backgroundImagePath)}
|
||||
logoPreviewUrl={logoPreviewUrl}
|
||||
/>
|
||||
|
||||
{quota && !quota.selfHosted && quota.plan === "FREE" && (
|
||||
<>
|
||||
<p className="text-sm text-gray-400">
|
||||
Free plan: one static cover image for the batch · optional Songs2VID watermark
|
||||
(bottom-right only).
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
<UpgradeProLink className="text-accent hover:underline" /> for custom branding, typography,
|
||||
blurred art-track layouts, watermark positioning, unique image matching, 1080p, and
|
||||
lossless audio.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
@@ -791,40 +695,6 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
);
|
||||
}
|
||||
|
||||
function ProLockedField({
|
||||
label,
|
||||
locked,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
locked: boolean;
|
||||
hint: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-2 text-sm text-gray-400">
|
||||
{label}
|
||||
{locked && (
|
||||
<span
|
||||
className="rounded border border-accent/50 bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent"
|
||||
title={hint}
|
||||
>
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
{children}
|
||||
{locked && (
|
||||
<p className="mt-1 text-xs text-gray-500">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({
|
||||
label,
|
||||
checked,
|
||||
|
||||
@@ -11,9 +11,14 @@ import { getVideoAttributionText } from "@/lib/branding";
|
||||
import {
|
||||
CURATED_FONTS,
|
||||
googleFontsStylesheetUrl,
|
||||
SYSTEM_FONT,
|
||||
type CuratedFontKey,
|
||||
type WatermarkFontKey,
|
||||
} from "@/lib/fonts";
|
||||
import {
|
||||
curatedFontApiUrl,
|
||||
previewFontFamilyCss,
|
||||
} from "@/lib/preview-typography";
|
||||
import {
|
||||
WATERMARK_OFFSET_MAX,
|
||||
WATERMARK_OFFSET_MIN,
|
||||
@@ -23,7 +28,6 @@ import {
|
||||
type WatermarkPosition,
|
||||
type WatermarkSettings,
|
||||
} from "@/lib/watermark";
|
||||
import { UpgradeProButton } from "./UpgradeProButton";
|
||||
|
||||
type Props = {
|
||||
enabled: boolean;
|
||||
@@ -79,10 +83,7 @@ function previewStyle(
|
||||
}
|
||||
|
||||
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
|
||||
if (!fontKey || fontKey === "system") return "ui-sans-serif, system-ui, sans-serif";
|
||||
if (fontKey === "custom") return `'${CUSTOM_PREVIEW_FAMILY}', sans-serif`;
|
||||
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
|
||||
return meta ? `'${meta.cssFamily}', sans-serif` : "sans-serif";
|
||||
return previewFontFamilyCss(fontKey);
|
||||
}
|
||||
|
||||
export function WatermarkPreview({
|
||||
@@ -111,7 +112,32 @@ export function WatermarkPreview({
|
||||
[value.fontKey],
|
||||
);
|
||||
|
||||
// Load curated Google Fonts for live canvas preview
|
||||
// Load bundled + curated fonts for live canvas preview (same files as FFmpeg)
|
||||
useEffect(() => {
|
||||
const styleId = "s2vid-watermark-preview-fonts";
|
||||
let el = document.getElementById(styleId) as HTMLStyleElement | null;
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = styleId;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = [
|
||||
`@font-face {
|
||||
font-family: '${SYSTEM_FONT.previewFamily}';
|
||||
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
|
||||
font-display: swap;
|
||||
}`,
|
||||
...CURATED_FONTS.map(
|
||||
(f) => `@font-face {
|
||||
font-family: 'S2VIDPreview-${f.key}';
|
||||
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
|
||||
font-display: swap;
|
||||
}`,
|
||||
),
|
||||
].join("\n");
|
||||
}, []);
|
||||
|
||||
// Legacy Google Fonts link (kept for any older preview paths)
|
||||
useEffect(() => {
|
||||
const id = "s2vid-watermark-google-fonts";
|
||||
if (document.getElementById(id)) return;
|
||||
@@ -203,26 +229,8 @@ export function WatermarkPreview({
|
||||
locked ? "opacity-80" : ""
|
||||
}`}
|
||||
>
|
||||
{locked && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-lg bg-black/55 px-4 text-center backdrop-blur-[1px]">
|
||||
<span className="rounded border border-accent/50 bg-accent/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent">
|
||||
Pro
|
||||
</span>
|
||||
<p className="max-w-sm text-sm text-gray-200">
|
||||
Custom branding, typography, logo overlay, and position controls are Pro features.
|
||||
</p>
|
||||
<UpgradeProButton
|
||||
label="Unlock watermark studio"
|
||||
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-white">Watermark layout</h3>
|
||||
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
|
||||
Pro / B2B
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -306,7 +314,7 @@ export function WatermarkPreview({
|
||||
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
|
||||
className="input-field mt-1"
|
||||
>
|
||||
<option value="system">System default</option>
|
||||
<option value="system">{SYSTEM_FONT.label}</option>
|
||||
{CURATED_FONTS.map((f) => (
|
||||
<option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}>
|
||||
{f.label}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
songs2vid.com, www.songs2vid.com {
|
||||
encode gzip
|
||||
reverse_proxy web:3000
|
||||
}
|
||||
|
||||
docs.songs2vid.com {
|
||||
encode gzip
|
||||
root * /srv/docs
|
||||
file_server
|
||||
try_files {path} /index.html
|
||||
}
|
||||
|
||||
# Basic-auth hash is generated on the server (caddy hash-password).
|
||||
# Do not commit live hashes/passwords here.
|
||||
studio.songs2vid.com {
|
||||
encode gzip
|
||||
basicauth {
|
||||
admin REPLACE_WITH_CADDY_HASH
|
||||
}
|
||||
reverse_proxy studio:5555
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: s2vid
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: s2vid
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U s2vid -d s2vid"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
web:
|
||||
image: atakanozban/songs2vid:latest
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "3000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://s2vid:${POSTGRES_PASSWORD}@postgres:5432/s2vid
|
||||
REDIS_URL: redis://redis:6379
|
||||
UPLOAD_DIR: /app/uploads
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
command: >
|
||||
sh -c "npx prisma db push && node server.js"
|
||||
|
||||
worker:
|
||||
image: atakanozban/songs2vid:latest
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://s2vid:${POSTGRES_PASSWORD}@postgres:5432/s2vid
|
||||
REDIS_URL: redis://redis:6379
|
||||
UPLOAD_DIR: /app/uploads
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
command: ["npx", "tsx", "worker/index.ts"]
|
||||
|
||||
studio:
|
||||
image: atakanozban/songs2vid:latest
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "5555"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://s2vid:${POSTGRES_PASSWORD}@postgres:5432/s2vid
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
command: ["npx", "prisma", "studio", "--hostname", "0.0.0.0", "--port", "5555", "--browser", "none"]
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./docs-site:/srv/docs:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- web
|
||||
- studio
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
uploads_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -5,15 +5,15 @@ services:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: s2yt
|
||||
POSTGRES_PASSWORD: s2yt
|
||||
POSTGRES_DB: s2yt
|
||||
POSTGRES_USER: songs2vid
|
||||
POSTGRES_PASSWORD: songs2vid
|
||||
POSTGRES_DB: songs2vid
|
||||
ports:
|
||||
- "127.0.0.1:5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U s2yt -d s2yt"]
|
||||
test: ["CMD-SHELL", "pg_isready -U songs2vid -d songs2vid"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
@@ -3,13 +3,13 @@ services:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: s2yt
|
||||
POSTGRES_PASSWORD: s2yt
|
||||
POSTGRES_DB: s2yt
|
||||
POSTGRES_USER: songs2vid
|
||||
POSTGRES_PASSWORD: songs2vid
|
||||
POSTGRES_DB: songs2vid
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U s2yt -d s2yt"]
|
||||
test: ["CMD-SHELL", "pg_isready -U songs2vid -d songs2vid"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -27,16 +27,15 @@ services:
|
||||
retries: 10
|
||||
|
||||
web:
|
||||
image: atakanozban/songs2vid:${S2VID_IMAGE_TAG:-latest}
|
||||
build: .
|
||||
# Multi-arch Hub release: ./scripts/release-cloud.sh --push
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${S2VID_PORT:-3000}:3000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
S2VID_EDITION: selfhosted
|
||||
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
|
||||
DATABASE_URL: postgresql://songs2vid:songs2vid@postgres:5432/songs2vid
|
||||
REDIS_URL: redis://redis:6379
|
||||
UPLOAD_DIR: /app/uploads
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||
@@ -51,13 +50,13 @@ services:
|
||||
sh -c "npx prisma db push && node server.js"
|
||||
|
||||
worker:
|
||||
image: atakanozban/songs2vid:${S2VID_IMAGE_TAG:-latest}
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
S2VID_EDITION: selfhosted
|
||||
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
|
||||
DATABASE_URL: postgresql://songs2vid:songs2vid@postgres:5432/songs2vid
|
||||
REDIS_URL: redis://redis:6379
|
||||
UPLOAD_DIR: /app/uploads
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Songs2VID OSS documentation
|
||||
|
||||
This folder ships with the OSS repo so API reference stays available without the docs site.
|
||||
|
||||
| Doc | Contents |
|
||||
|-----|----------|
|
||||
| [API overview](./api/overview.md) | Auth, rate limits, recommended flows, discovery (`layoutTemplates`, `compositionFamilies`) |
|
||||
| [API endpoints](./api/endpoints.md) | curl examples for upload, jobs, layouts (incl. lower-corner templates), playlists, errors |
|
||||
|
||||
Live docs (same product truth for self-host): [docs.songs2vid.com](https://docs.songs2vid.com)
|
||||
|
||||
Setup and env: see the root [README](../README.md) and [`.env.example`](../.env.example).
|
||||
@@ -0,0 +1,380 @@
|
||||
# Endpoints
|
||||
|
||||
Set these for the examples below:
|
||||
|
||||
```bash
|
||||
export BASE_URL="http://localhost:3000" # local / self-hosted app
|
||||
export API_KEY="s2yt_live_your_key_here"
|
||||
```
|
||||
|
||||
Live HTML docs: [docs.songs2vid.com/docs/api/endpoints](https://docs.songs2vid.com/docs/api/endpoints).
|
||||
|
||||
## Discovery
|
||||
|
||||
```bash
|
||||
curl "$BASE_URL/api/v1"
|
||||
```
|
||||
|
||||
Returns the endpoint list and requirements (**no auth**). No billing routes are listed or implemented.
|
||||
|
||||
## Upload a file (two-step)
|
||||
|
||||
```bash
|
||||
curl -X POST "$BASE_URL/api/v1/upload" \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-F "file=@cover.jpg" \
|
||||
-F "type=image"
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST "$BASE_URL/api/v1/upload" \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-F "file=@track1.mp3" \
|
||||
-F "type=audio"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Optional: PNG watermark logo
|
||||
curl -X POST "$BASE_URL/api/v1/upload" \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-F "file=@logo.png" \
|
||||
-F "type=logo"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Optional: custom font (.ttf / .otf, max 10 MB)
|
||||
curl -X POST "$BASE_URL/api/v1/upload" \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-F "file=@Brand.ttf" \
|
||||
-F "type=font"
|
||||
```
|
||||
|
||||
### Request fields
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|--------|
|
||||
| `file` | Yes | Multipart file |
|
||||
| `type` | Yes | `image` \| `audio` \| `logo` \| `font` |
|
||||
|
||||
### Allowed files
|
||||
|
||||
| `type` | Formats | Max size |
|
||||
|--------|---------|----------|
|
||||
| `image` | JPEG, PNG, WebP, GIF | 500 MB |
|
||||
| `audio` | MP3, WAV, FLAC | 500 MB |
|
||||
| `logo` | PNG only | 500 MB |
|
||||
| `font` | `.ttf` / `.otf` | **10 MB** |
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "/uploads/.../track.mp3",
|
||||
"filename": "track.mp3",
|
||||
"size": 4123456,
|
||||
"audioTags": {
|
||||
"title": "Song Title",
|
||||
"artist": "Artist Name",
|
||||
"album": "Album",
|
||||
"genre": "Electronic",
|
||||
"year": "2024"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`audioTags` is present for MP3 when tags are readable; otherwise `null`.
|
||||
|
||||
## Create job / render from paths (recommended)
|
||||
|
||||
Self-hosted unlocks per-track covers, custom watermarks/fonts, and art-track layouts. Max batch size: **100**. Watermarks are optional — nothing forces the default Songs2VID badge.
|
||||
|
||||
`POST /api/v1/render` is an **n8n-friendly alias** of `POST /api/v1/jobs` (identical body and response). `GET /api/v1/render` lists jobs like `GET /api/v1/jobs`.
|
||||
|
||||
Optional `webhookUrl` (absolute `http(s)` URL) makes Songs2VID POST JSON when items/jobs finish — preferred for n8n.
|
||||
|
||||
```bash
|
||||
curl -X POST "$BASE_URL/api/v1/render" \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"imagePath": "/uploads/.../cover.jpg",
|
||||
"webhookUrl": "https://your-n8n.example/webhook/songs2vid-complete",
|
||||
"items": [{
|
||||
"audioPath": "/uploads/.../track.mp3",
|
||||
"audioFilename": "track.mp3",
|
||||
"metadata": {
|
||||
"title": "My Artist - My Track (Official Audio)",
|
||||
"songTitle": "My Track",
|
||||
"artist": "My Artist",
|
||||
"description": "",
|
||||
"tags": "electronic",
|
||||
"privacy": "PUBLIC",
|
||||
"categoryId": "10",
|
||||
"resolution": "1920x1080",
|
||||
"notifySubscribers": true,
|
||||
"madeForKids": false,
|
||||
"embeddable": true,
|
||||
"creativeCommons": false,
|
||||
"includeWatermark": true,
|
||||
"layout": {
|
||||
"template": "LOWER_LEFT_COVER_TEXT",
|
||||
"blurAmount": 60,
|
||||
"blurOpacity": 85,
|
||||
"textPadding": 48,
|
||||
"titleArtistGap": 12,
|
||||
"titleBold": true,
|
||||
"textOffsetX": 0,
|
||||
"textOffsetY": 0
|
||||
},
|
||||
"watermark": {
|
||||
"mode": "default",
|
||||
"fontKey": "montserrat",
|
||||
"position": "bottom-right",
|
||||
"offsetX": 24,
|
||||
"offsetY": 24
|
||||
},
|
||||
"playlistId": null
|
||||
}
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
Equivalent path: `POST $BASE_URL/api/v1/jobs` with the same JSON.
|
||||
|
||||
### Success response
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "clxxxxxxxx",
|
||||
"itemCount": 1,
|
||||
"status": "PENDING",
|
||||
"statusUrl": "/api/v1/jobs/clxxxxxxxx",
|
||||
"webhookUrl": "https://your-n8n.example/webhook/songs2vid-complete",
|
||||
"playlist": null
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook payload
|
||||
|
||||
| `event` | Meaning |
|
||||
|---------|---------|
|
||||
| `job.item.completed` | One track finished (`youtubeVideoId` set) |
|
||||
| `job.item.failed` | One track failed (`error` set) |
|
||||
| `job.completed` | All items succeeded |
|
||||
| `job.failed` | All items failed |
|
||||
| `job.partial` | Mix of success and failure |
|
||||
|
||||
Community node: [`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid) — see [docs/n8n.md](../n8n.md) and [docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n).
|
||||
|
||||
<details>
|
||||
<summary>Legacy example without webhook (same metadata shape)</summary>
|
||||
|
||||
```bash
|
||||
curl -X POST "$BASE_URL/api/v1/jobs" \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"imagePath": "/uploads/.../cover.jpg",
|
||||
"items": [{
|
||||
"audioPath": "/uploads/.../track.mp3",
|
||||
"audioFilename": "track.mp3",
|
||||
"metadata": {
|
||||
"title": "My Artist - My Track (Official Audio)",
|
||||
"songTitle": "My Track",
|
||||
"artist": "My Artist",
|
||||
"privacy": "PUBLIC",
|
||||
"categoryId": "10",
|
||||
"resolution": "1920x1080",
|
||||
"includeWatermark": false
|
||||
}
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Metadata fields
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|--------|
|
||||
| `title` | string | YouTube video title |
|
||||
| `songTitle` | string \| null | On-video song title for art-track (max **120**) |
|
||||
| `artist` | string \| null | On-video artist line (max **80**) |
|
||||
| `description` | string | YouTube description |
|
||||
| `tags` | string | Comma-separated |
|
||||
| `privacy` | string | `PUBLIC` \| `PRIVATE` \| `UNLISTED` |
|
||||
| `categoryId` | string | YouTube category ID |
|
||||
| `resolution` | string | See resolutions below |
|
||||
| `notifySubscribers` | boolean | YouTube notify flag |
|
||||
| `madeForKids` | boolean | Made for kids |
|
||||
| `embeddable` | boolean | Allow embedding |
|
||||
| `creativeCommons` | boolean | CC vs standard YouTube license |
|
||||
| `includeWatermark` | boolean | Apply watermark settings |
|
||||
| `imagePath` | string \| null | Per-track cover |
|
||||
| `backgroundImagePath` | string \| null | Lower-corner templates only: separate blur-fill image (else cover is blurred) |
|
||||
| `playlistId` | string \| null | Existing playlist ID |
|
||||
| `layout` | object | Art-track layout |
|
||||
| `watermark` | object | Watermark settings |
|
||||
|
||||
Snake_case aliases are accepted for layout/watermark fields.
|
||||
|
||||
### Resolutions
|
||||
|
||||
| Value | Aspect |
|
||||
|-------|--------|
|
||||
| `1920x1080` | 16:9 |
|
||||
| `1280x720` | 16:9 |
|
||||
| `854x480` | 16:9 |
|
||||
| `720x720` | 1:1 |
|
||||
| `640x360` | 16:9 |
|
||||
| `426x240` | 16:9 |
|
||||
|
||||
### YouTube categories
|
||||
|
||||
| ID | Name |
|
||||
|----|------|
|
||||
| `1` | Film & Animation |
|
||||
| `2` | Autos & Vehicles |
|
||||
| `10` | Music |
|
||||
| `15` | Pets & Animals |
|
||||
| `17` | Sports |
|
||||
| `19` | Travel & Events |
|
||||
| `20` | Gaming |
|
||||
| `22` | People & Blogs |
|
||||
| `23` | Comedy |
|
||||
| `24` | Entertainment |
|
||||
| `25` | News & Politics |
|
||||
| `26` | Howto & Style |
|
||||
| `27` | Education |
|
||||
| `28` | Science & Technology |
|
||||
| `29` | Nonprofits & Activism |
|
||||
|
||||
### Watermark fields
|
||||
|
||||
`watermark.mode`: `none` | `default` | `text` | `logo`
|
||||
|
||||
- `none` — no overlay
|
||||
- `default` — built-in Songs2VID badge PNG (`assets/watermark.png`)
|
||||
- `logo` — requires prior `type=logo` upload; set `logoPath`
|
||||
- `text` — custom string (max **80**); set `text`
|
||||
|
||||
`watermark.position`: `top-left` | `top-right` | `bottom-left` | `bottom-right` | `center`
|
||||
|
||||
`watermark.offsetX` / `offsetY`: `0`–`200` (default `20`)
|
||||
|
||||
`watermark.fontKey`: `system` | `inter` | `montserrat` | `roboto` | `oswald` | `playfair` | `custom` (styles art-track text and text watermarks)
|
||||
|
||||
### Art-track layouts
|
||||
|
||||
`metadata.layout.template` (or flat `layout_template` / `layoutTemplate`). Valid enums are also listed on `GET /api/v1` as `layoutTemplates`. Mirrored pairs used by the Layout Studio composition grid are listed under `compositionFamilies` (`side`: cover beside text; `lower`: lower corner).
|
||||
|
||||
| Enum | Description |
|
||||
|------|-------------|
|
||||
| `COVER_LEFT_TEXT_RIGHT` | Cover left, title & artist right |
|
||||
| `COVER_TOP_TEXT_BOTTOM` | Cover top, title & artist below |
|
||||
| `COVER_RIGHT_TEXT_LEFT` | Cover right, title & artist left |
|
||||
| `CENTERED_COMPACT` | Centered cover + text stack |
|
||||
| `LOWER_LEFT_COVER_TEXT` | Lower-left cover; `textPadding` is equal left + bottom inset (diagonal from frame corner) with title/artist to the right |
|
||||
| `LOWER_RIGHT_COVER_TEXT` | Lower-right cover; `textPadding` is equal right + bottom inset (diagonal from frame corner) with title/artist to the left |
|
||||
|
||||
For lower-corner templates only, optional `metadata.backgroundImagePath` (or `background_image_path`) sets a separate full-frame blur fill. Upload with `type=image` first, then pass the returned path. The cover (`imagePath` / per-item `metadata.imagePath`) stays the sharp corner square. Omit the field to blur the cover itself (default). `blurAmount` / `blurOpacity` still apply to whichever image is used as the fill.
|
||||
|
||||
Optional fine-tuning (clamped; camelCase or snake_case):
|
||||
|
||||
| Field | Range | Default | Purpose |
|
||||
|-------|-------|---------|---------|
|
||||
| `blurAmount` / `blur_amount` | 0–100 | 55 | Background `boxblur` intensity |
|
||||
| `blurOpacity` / `blur_opacity` | 0–100 | 100 | Blurred fill vs black |
|
||||
| `blurFill` / `blur_fill` | boolean | `false` | Classic letterbox only: fill bars with blurred cover (ignored for art-track templates) |
|
||||
| `textPadding` / `text_padding` | 16–120 | 48 | Edge inset for cover/text. On lower-corner templates this value is applied equally on both axes (left=bottom or right=bottom) so the cover corner sits on a true diagonal from the frame corner |
|
||||
| `titleArtistGap` / `title_artist_gap` | 0–64 | 10 | Space between title and artist |
|
||||
| `titleBold` / `title_bold` | boolean | `true` | Bold song title (preview + FFmpeg) |
|
||||
| `textOffsetX` / `text_offset_x` | −120–120 | 0 | Shift text block horizontally |
|
||||
| `textOffsetY` / `text_offset_y` | −120–120 | 0 | Shift text block vertically |
|
||||
|
||||
Also set `metadata.songTitle` (max 120) and `metadata.artist` (max 80) for the on-video text lines. `metadata.title` remains the YouTube title.
|
||||
|
||||
Omit `layout.template` for classic letterbox (black-padded cover by default). Set `layout.blurFill` / `blur_fill` to `true` to fill letterbox bars with a blurred cover; then `blurAmount` / `blurOpacity` apply. Free-form cover coordinates (`x`, `y`, `coverX`, …) and layout-level `offsetX`/`offsetY` are **rejected**.
|
||||
|
||||
Invalid template strings return **400**:
|
||||
|
||||
```json
|
||||
{ "error": "Invalid layout template. Refer to API documentation for valid enum values." }
|
||||
```
|
||||
|
||||
## YouTube playlists
|
||||
|
||||
```bash
|
||||
curl "$BASE_URL/api/v1/playlists" \
|
||||
-H "Authorization: Bearer $API_KEY"
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST "$BASE_URL/api/v1/playlists" \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"My Album","description":"From Songs2VID","privacy":"unlisted"}'
|
||||
```
|
||||
|
||||
Or pass `createPlaylist` on a job / batch body to create a playlist and attach all videos.
|
||||
|
||||
## One-shot batch (small packs only)
|
||||
|
||||
```bash
|
||||
curl -X POST "$BASE_URL/api/v1/jobs/batch" \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-F "image=@cover.jpg" \
|
||||
-F "audio=@track1.mp3" \
|
||||
-F "audio=@track2.mp3" \
|
||||
-F 'metadata={"defaults":{"privacy":"PUBLIC"},"items":[{"title":"Track One"},{"title":"Track Two"}]}'
|
||||
```
|
||||
|
||||
Prefer two-step upload + jobs for larger packs.
|
||||
|
||||
## Poll job status
|
||||
|
||||
```bash
|
||||
curl "$BASE_URL/api/v1/jobs/JOB_ID" \
|
||||
-H "Authorization: Bearer $API_KEY"
|
||||
```
|
||||
|
||||
```bash
|
||||
curl "$BASE_URL/api/v1/jobs?limit=10" \
|
||||
-H "Authorization: Bearer $API_KEY"
|
||||
```
|
||||
|
||||
`GET /api/v1/jobs` accepts `limit` (default **20**, max **100**).
|
||||
|
||||
### Job statuses
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `PENDING` | Queued |
|
||||
| `PROCESSING` | Encoding or uploading |
|
||||
| `COMPLETED` | All items succeeded |
|
||||
| `FAILED` | All items failed |
|
||||
| `PARTIAL` | Mix of completed and failed |
|
||||
|
||||
### Item statuses
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `PENDING` | Waiting |
|
||||
| `ENCODING` | FFmpeg building video |
|
||||
| `UPLOADING` | Uploading to YouTube |
|
||||
| `COMPLETED` | Live (`youtubeVideoId` set) |
|
||||
| `FAILED` | Failed (`error` message) |
|
||||
|
||||
YouTube channel daily upload caps are enforced by Google, not Songs2VID.
|
||||
|
||||
## HTTP errors
|
||||
|
||||
| Status | When |
|
||||
|--------|------|
|
||||
| `400` | Validation error |
|
||||
| `401` | Missing or invalid API key |
|
||||
| `403` | YouTube not connected, or request not allowed |
|
||||
| `404` | Job not found |
|
||||
| `429` | API rate limit exceeded |
|
||||
|
||||
There is no `402` payment / credits status in OSS.
|
||||
@@ -0,0 +1,68 @@
|
||||
# API overview
|
||||
|
||||
Programmatic uploads and batch jobs for self-hosted Songs2VID. Generate your API key under **Dashboard → Settings → API key**.
|
||||
|
||||
Keys start with `s2yt_live_` and are shown once at creation. The OSS / Docker image always allows API use — there is no plan, credit, or paywall gate.
|
||||
|
||||
There are **no billing endpoints** in this edition.
|
||||
|
||||
## Authentication
|
||||
|
||||
Send the key on every request:
|
||||
|
||||
```http
|
||||
Authorization: Bearer s2yt_live_your_key_here
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
- YouTube channel connected (sign in with Google OAuth that includes YouTube scopes)
|
||||
|
||||
OAuth setup: root [README](../../README.md) and Google’s [OAuth 2.0 for Web Server Applications](https://developers.google.com/identity/protocols/oauth2/web-server). YouTube scopes/API: [YouTube Data API Overview](https://developers.google.com/youtube/v3/getting-started).
|
||||
|
||||
## Rate limits
|
||||
|
||||
Self-hosted OSS uses a very high per-account ceiling (effectively unlimited for normal automation). You will rarely see `429`.
|
||||
|
||||
If a limit is hit, the response is **429** with `retryAfterSeconds` and a `Retry-After` header. See [Endpoints — HTTP errors](./endpoints.md#http-errors).
|
||||
|
||||
## Choosing a flow
|
||||
|
||||
### Recommended: two-step (especially 5+ audio files)
|
||||
|
||||
1. Upload each file with `POST /api/v1/upload`
|
||||
2. Create the job with `POST /api/v1/render` (alias of `/api/v1/jobs`) — JSON paths + optional `webhookUrl`
|
||||
|
||||
This avoids huge multipart bodies. Max batch size is **100** tracks per job.
|
||||
|
||||
Prefer `webhookUrl` for n8n so long encodes do not block an HTTP Request node. Details: [n8n](../n8n.md).
|
||||
|
||||
### One-shot batch: small packs only
|
||||
|
||||
`POST /api/v1/jobs/batch` accepts one cover image and a few audio files in a single multipart request. Large bodies often fail with:
|
||||
|
||||
```text
|
||||
failed to parse body as FormData
|
||||
```
|
||||
|
||||
Prefer two-step for albums or long tracklists.
|
||||
|
||||
## Job lifecycle
|
||||
|
||||
1. Create job → status `PENDING`
|
||||
2. Worker picks items → `ENCODING` → `UPLOADING` → `COMPLETED` or `FAILED`
|
||||
3. Job rolls up to `COMPLETED`, `FAILED`, or `PARTIAL`
|
||||
|
||||
Poll with `GET /api/v1/jobs/:id`.
|
||||
|
||||
## Discovery
|
||||
|
||||
```http
|
||||
GET /api/v1
|
||||
```
|
||||
|
||||
Returns the endpoint list, requirements, `layoutTemplates` (every art-track enum id + label, including `LOWER_LEFT_COVER_TEXT` / `LOWER_RIGHT_COVER_TEXT`), and `compositionFamilies` (UI grouping for mirrored left/right variants). No auth required.
|
||||
|
||||
## Next
|
||||
|
||||
See [Endpoints](./endpoints.md) for curl examples. n8n: [docs/n8n.md](../n8n.md). Hosted HTML docs: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview).
|
||||
@@ -0,0 +1,36 @@
|
||||
# n8n integration (OSS)
|
||||
|
||||
Automate self-hosted Songs2VID with the community package **[`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid)**.
|
||||
|
||||
Full hosted guide: [https://docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n)
|
||||
|
||||
## Install
|
||||
|
||||
In n8n → **Settings → Community nodes** → install:
|
||||
|
||||
```text
|
||||
n8n-nodes-songs2vid
|
||||
```
|
||||
|
||||
Or from this repo for local development:
|
||||
|
||||
```bash
|
||||
cd integrations/n8n
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Auth
|
||||
|
||||
1. Dashboard → **Settings → API key** → create `s2yt_live_…`
|
||||
2. n8n credential **Songs2VID API**: paste key, Base URL = your instance (e.g. `http://localhost:3000`)
|
||||
|
||||
OSS has **no plan gate** — API keys work whenever YouTube is connected.
|
||||
|
||||
## Flow
|
||||
|
||||
1. **Upload** cover (`type=image`) and audio (`type=audio`)
|
||||
2. **Create Render** (`POST /api/v1/render`) with paths + optional `webhookUrl`
|
||||
3. Handle completion via n8n **Webhook** or poll `GET /api/v1/jobs/:id`
|
||||
|
||||
Package source: [`integrations/n8n`](../integrations/n8n/).
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
*.tgz
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Songs2VID
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,89 @@
|
||||
# n8n-nodes-songs2vid
|
||||
|
||||
Official community node for **[Songs2VID](https://songs2vid.com)** — turn audio + cover art into YouTube-ready videos from [n8n](https://n8n.io/).
|
||||
|
||||
**Documentation:** [https://docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n) · in-repo [docs/n8n.md](../../docs/n8n.md)
|
||||
|
||||
Self-hosted OSS: set **Base URL** to your instance. API keys work without a paid plan (YouTube must be connected).
|
||||
|
||||
## Install
|
||||
|
||||
### From n8n (recommended)
|
||||
|
||||
1. Open n8n → **Settings → Community nodes**
|
||||
2. **Install a community node**
|
||||
3. Enter:
|
||||
|
||||
```text
|
||||
n8n-nodes-songs2vid
|
||||
```
|
||||
|
||||
4. Confirm and restart n8n if prompted
|
||||
5. Search the canvas for **Songs2VID**
|
||||
|
||||
Self-hosted n8n needs community packages enabled (`N8N_COMMUNITY_PACKAGES_ENABLED=true`).
|
||||
|
||||
### From npm (manual)
|
||||
|
||||
```bash
|
||||
cd ~/.n8n
|
||||
npm install n8n-nodes-songs2vid
|
||||
```
|
||||
|
||||
Restart n8n.
|
||||
|
||||
## Credentials
|
||||
|
||||
1. Create an API key under [Dashboard → Settings](https://songs2vid.com/dashboard/settings) (`s2yt_live_…`)
|
||||
2. Cloud requires **Developer & Automation** (€15) or **Enterprise**, plus YouTube connected
|
||||
3. In n8n: create a **Songs2VID API** credential
|
||||
- **API Key:** your token
|
||||
- **Base URL:** `https://songs2vid.com` (or your self-hosted origin, no trailing slash)
|
||||
|
||||
Requests use `Authorization: Bearer …` only.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. **File → Upload** — cover (`type=image`) → save `path`
|
||||
2. **File → Upload** — audio (`type=audio`) → save `path` / `filename`
|
||||
3. **Render → Create Render** — paths + privacy + resolution
|
||||
Optional: **Additional Fields → Webhook URL** for async completion
|
||||
4. Handle `job.completed` / `job.item.completed` on an n8n **Webhook** node, or poll **Get Render Status**
|
||||
|
||||
## Operations
|
||||
|
||||
| Resource | Operation | API |
|
||||
|----------|-----------|-----|
|
||||
| Discovery | Get API Catalog | `GET /api/v1` |
|
||||
| File | Upload | `POST /api/v1/upload` |
|
||||
| Render | Create Render | `POST /api/v1/render` |
|
||||
| Render | Create Job (alias) | `POST /api/v1/jobs` |
|
||||
| Render | Create Batch | `POST /api/v1/jobs/batch` |
|
||||
| Render | Get Render Status | `GET /api/v1/jobs/:id` |
|
||||
| Render | List Renders | `GET /api/v1/jobs` |
|
||||
| Playlist | List / Create | `/api/v1/playlists` |
|
||||
| API Key | List / Create / Delete | `/api/v1/user/api-keys` |
|
||||
|
||||
Layout, watermark, playlist, and multi-track options are under **Additional Fields** on Create Render.
|
||||
|
||||
There is no Delete Render endpoint — finished videos live on YouTube.
|
||||
|
||||
## Links
|
||||
|
||||
- Guide: https://songs2vid.com/docs/n8n
|
||||
- REST endpoints: https://songs2vid.com/docs/api/endpoints
|
||||
- Workflow template: https://songs2vid.com/docs/n8n/workflow-template.json
|
||||
- npm: https://www.npmjs.com/package/n8n-nodes-songs2vid
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
`prepublishOnly` runs the build before `npm publish`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||